Files
obsidian-vault/personal/projects/psychologist-app/dev-setup.md
T

16 KiB
Raw Blame History

created, updated, tags
created updated tags
2026-05-16 2026-05-19
project
dev
ios
swift
android
skip

Reflect App — Dev Setup & Architecture

iOS (reflect-app): ~/Desktop/reflect-app/ — нативный SwiftUI, не трогать Cross-platform (reflect-skip): ~/Developer/reflect-skip/ — Skip Fuse, iOS + Android Gitea: git.mallexxx.duckdns.org/git_admin/reflect-app

F-Droid Internal Repo

Параметр Значение
URL https://reflect.qentra.top
CF Tunnel mbp black (same as zulip.qentra.top), hostname reflect → localhost:8766
CF Token (Zone:DNS) cfat_iKrPBaTAqlL8Zo7Ss4EHaiGqRGxBK4RDVX78jhgD51fdd7a1
Сервер python3 ~/Developer/reflect-skip/scripts/fdroid-server.py (LaunchAgent: com.reflect.fdroid-server)
Workspace ~/Developer/reflect-skip/fdroid-workspace/ — подписанный репо, генерируется fdroid update

Как устроен деплой

Репо генерируется через fdroidserver (pip3 install fdroidserver), а не вручную. HTTP-сервер (fdroid-server.py) раздаёт содержимое fdroid-workspace/repo/ — там лежат подписанные index-v1.jar, entry.jar, index-v2.json.

Ключевые файлы workspace:

fdroid-workspace/
├── config.yml         ← repo_url, keystore, имя репо
├── keystore.p12       ← подписывающий ключ (не коммитить)
└── repo/              ← раздаётся HTTP-сервером
    ├── index-v1.jar   ← подписанный JAR (Droid-ify требует его)
    ├── entry.jar      ← новый формат entry point
    ├── index-v2.json
    └── reflect-app-debug.apk

Сборка и публикация APK

cd ~/Developer/reflect-skip

# Полная сборка (Xcode + Gradle) + публикация
bash scripts/build-and-publish.sh

# Быстрая (только Gradle, если Swift не менялся)
bash scripts/build-and-publish.sh --quick

build-and-publish.sh вызывает generate-index.sh, который:

  1. Копирует APK в fdroid-workspace/repo/
  2. Запускает fdroid update -c (JAVA_HOME=/opt/homebrew/opt/openjdk@21)
  3. Генерирует подписанные JAR и JSON индексы

Первоначальная настройка (один раз)

cd ~/Developer/reflect-skip/fdroid-workspace

# Удалить старый keystore если есть, создать новый
keytool -genkey -v \
  -keystore keystore.p12 -storetype PKCS12 \
  -alias fdroid -storepass reflect123 -keypass reflect123 \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -dname "CN=Reflect App, O=Dev, C=US"

# Сгенерировать репо
ANDROID_HOME=~/Library/Android/sdk \
JAVA_HOME=/opt/homebrew/opt/openjdk@21 \
PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH" \
fdroid update -c

Добавить репо в Droid-ify

Settings → Repositories → +https://reflect.qentra.top

⚠️ URL без /repo — сервер уже раздаёт содержимое fdroid-workspace/repo/ напрямую с корня.


Статус (2026-05-19)

Что реализовано

Компонент Статус
iOS build (reflect-app) Build succeeded, 30/30 тестов
Android build (reflect-skip) APK собирается, запускается на эмуляторе
Экран 0: Privacy intro Личный текст, GDPR opt-out, дисклеймер
Онбординг 3 info-слайда + 5 вопросов + AI инсайт
Сессия: dynamic intro AI-генерированное вступление + max 2 крючка из истории
Сессия: one-by-one вопросы Вопросы по одному, батч-подбивка агенту
Сессия: closing phrase Тёплая фраза от Narrator'а
Сессия: one last thing Маленький input + неакцентная кнопка Завершить
Dual-bot pipeline Narrator (Haiku) → Analyst (Sonnet)
Crisis detection Hardcoded RU+EN, CrisisView
Profile vault profile.md, sessions/, insights.md
App Lock (Face ID) BiometricAuthentication
Settings: API key, proxy Keychain + LocalProxy
Settings: Сбросить данные Алерт + полный сброс + онбординг заново
Session scheduler UNCalendarNotification

Что не реализовано (план)

Компонент Приоритет
ProfileView: история сессий 🟠 Средний
Session recovery (незавершённые) 🟠 Средний
Полноценный sessions/YYYY-MM-DD.md 🟡 Низкий
RevenueCat paywall 🔴 Нужен до публикации
Голосовой ввод (iOS) 🟡 Phase 2
Push-уведомления opt-in 🟡 Phase 2

Android / Skip Fuse — сборка

Требования

  • Xcode 16+, xcodebuild
  • Java 21: /opt/homebrew/opt/openjdk@21
  • Android SDK: ~/Library/Android/sdk
  • Android Emulator: reflect-android (Pixel 6, API 35)
  • Skip CLI: skip (из Homebrew или из Xcode DerivedData)

Полная сборка (первый раз или после новых Swift файлов)

cd ~/Developer/reflect-skip

export ANDROID_HOME=~/Library/Android/sdk
export JAVA_HOME=/opt/homebrew/opt/openjdk@21
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:/opt/homebrew/bin:$PATH"

# Шаг 1: Xcode генерирует Kotlin стабы (skipstone) + собирает Android
xcodebuild \
  -workspace Project.xcworkspace \
  -scheme "ReflectApp App" \
  -destination "generic/platform=iOS Simulator" \
  -configuration Debug \
  -skipPackagePluginValidation \
  SKIP_ACTION=build

# Шаг 2: Линкуем symlinks субмодулей (один раз после очистки)
# Делается скриптом или вручную — см. раздел ниже

# Шаг 3: Gradle собирает APK
cd Darwin
skip gradle -p ../Android assembleDebug

Быстрая пересборка (инкрементальная)

cd ~/Developer/reflect-skip/Darwin
export ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=/opt/homebrew/opt/openjdk@21
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:/opt/homebrew/bin:$PATH"

skip gradle -p ../Android assembleDebug

Деплой на эмулятор

export ANDROID_HOME=~/Library/Android/sdk
export PATH="$ANDROID_HOME/platform-tools:$PATH"

# Запустить эмулятор если не запущен
$ANDROID_HOME/emulator/emulator -avd reflect-android &

APK=~/Developer/reflect-skip/.build/Android/app/outputs/apk/debug/app-debug.apk
adb -s emulator-5554 install -r "$APK"
adb -s emulator-5554 shell am start -n com.reflect.app/reflect.app.MainActivity

⚠️ Критические особенности Skip Fuse

1. Новые Swift View файлы требуют ручного Kotlin стаба

При добавлении нового *.swift файла с View в Sources/ReflectApp/Views/: skipstone не всегда автоматически генерирует .kt стаб. Нужно создать вручную.

Шаблон (копируем OnboardingView.kt, меняем класс):

package reflect.app
import skip.lib.*
import skip.ui.*
import skip.foundation.*
import skip.model.*

@androidx.annotation.Keep
internal class MyNewView: skip.ui.View, skip.bridge.SwiftPeerBridged, skip.lib.SwiftProjecting {
    var Swift_peer: skip.bridge.SwiftObjectPointer = skip.bridge.SwiftObjectNil

    constructor(Swift_peer: skip.bridge.SwiftObjectPointer, marker: skip.bridge.SwiftPeerMarker?) {
        this.Swift_peer = Swift_peer
    }
    fun finalize() { Swift_release(Swift_peer); Swift_peer = skip.bridge.SwiftObjectNil }
    private external fun Swift_release(Swift_peer: skip.bridge.SwiftObjectPointer)
    override fun Swift_peer(): skip.bridge.SwiftObjectPointer = Swift_peer
    override fun equals(other: Any?): Boolean {
        if (other !is skip.bridge.SwiftPeerBridged) return false
        return Swift_peer == other.Swift_peer()
    }
    override fun hashCode(): Int = Swift_peer.hashCode()

    override fun body(): skip.ui.View {
        return skip.ui.ComposeBuilder { ctx -> Swift_composableBody(Swift_peer)?.Compose(ctx) ?: skip.ui.ComposeResult.ok }
    }
    private external fun Swift_composableBody(Swift_peer: skip.bridge.SwiftObjectPointer): skip.ui.View?

    constructor(/* params */) { Swift_peer = Swift_constructor_0(/* params */) }
    private external fun Swift_constructor_0(/* params */): skip.bridge.SwiftObjectPointer

    override fun Swift_projection(options: Int): () -> Any = Swift_projectionImpl(options)
    private external fun Swift_projectionImpl(options: Int): () -> Any

    @androidx.annotation.Keep companion object {}
}

Файл кладём в оба места:

~/Library/Developer/Xcode/DerivedData/Project-bpfpf.../Build/Intermediates.noindex/BuildToolPluginIntermediates/reflect-skip.output/ReflectApp/skipstone/ReflectApp/src/main/kotlin/reflect/app/
~/Developer/reflect-skip/.build/plugins/outputs/reflect-skip/ReflectApp/destination/skipstone/ReflectApp/src/main/kotlin/reflect/app/

После очистки кеша/DerivedData Kotlin исходники субмодулей (SkipLib, SkipBridge, etc.) перестают быть видны Gradle. Нужно перелинковать:

DERIVED="$HOME/Library/Developer/Xcode/DerivedData/Project-bpfpf..."
SKIPSTONE="$HOME/Developer/reflect-skip/.build/plugins/outputs/reflect-skip/ReflectApp/destination/skipstone"

for MODULE in SkipLib SkipBridge SkipFoundation SkipUI SkipAndroidBridge SkipModel SkipSwiftUI SkipUnit; do
  case "$MODULE" in
    SkipLib) PKG="skip-lib" ;;
    SkipBridge) PKG="skip-bridge" ;;
    SkipFoundation) PKG="skip-foundation" ;;
    SkipUI) PKG="skip-ui" ;;
    SkipAndroidBridge) PKG="skip-android-bridge" ;;
    SkipModel) PKG="skip-model" ;;
    SkipSwiftUI) PKG="skip-fuse-ui" ;;
    SkipUnit) PKG="skip-unit" ;;
  esac
  REAL_KT="$DERIVED/Build/Intermediates.noindex/BuildToolPluginIntermediates/${PKG}.output/${MODULE}/skipstone/${MODULE}/src/main/kotlin"
  mv "$SKIPSTONE/$MODULE/src/main/kotlin" "$SKIPSTONE/$MODULE/src/main/kotlin.bak" 2>/dev/null || true
  ln -sfn "$REAL_KT" "$SKIPSTONE/$MODULE/src/main/kotlin"
done

3. AndroidBridge.initBridge — обязательно до первого JNI вызова

В Android/app/src/main/kotlin/Main.kt порядок в onCreate:

ProcessInfo.launch(applicationContext)
AndroidBridge.initBridge("ReflectApp")  // ← ДО AppDelegate.shared.onInit()
AppDelegate.shared.onInit()

Структура проекта (reflect-skip)

reflect-skip/
├── Package.swift                    ← SPM, SkipFuseUI зависимость
├── Skip.env                         ← ANDROID_PACKAGE_NAME=reflect.app
├── Project.xcworkspace/             ← Workspace для xcodebuild
├── Darwin/                          ← Xcode проект (iOS таргет + Skip build phases)
│   ├── ReflectApp.xcodeproj/
│   └── Sources/Main.swift
├── Android/                         ← Gradle Android проект
│   ├── app/src/main/
│   │   ├── AndroidManifest.xml
│   │   └── kotlin/Main.kt           ← Entry point, AndroidBridge.initBridge
│   └── settings.gradle.kts
└── Sources/ReflectApp/
    ├── ReflectAppMain.swift          ← App root + PrivacyIntroView → Onboarding → Home
    ├── Models/
    ├── Services/
    │   ├── ClaudeService.swift       ← Claude API (cloud + local proxy)
    │   ├── AnalystBot.swift          ← Бот 2 (Sonnet, CBT вопросы + tools)
    │   ├── NarratorBot.swift         ← Бот 1 (Haiku, 3rd-person reframe)
    │   ├── CrisisDetector.swift      ← Hardcoded SI/SH
    │   ├── ProfileManager.swift      ← On-device vault (profile.md, sessions/)
    │   ├── SessionManager.swift      ← Оркестратор: intro/closing/pipeline
    │   └── ScheduleNegotiationService.swift
    └── Views/
        ├── PrivacyIntroView.swift    ← Экран 0: личный текст + GDPR
        ├── OnboardingView.swift      ← Онбординг
        ├── HomeView.swift
        ├── SessionView.swift         ← Фазы: loading/intro/dialogue/closing/oneLastThing
        ├── CardView.swift
        ├── SettingsView.swift        ← AI backend + Сбросить данные
        └── OrbCharacter.swift

Архитектура флоу онбординга

Первый запуск
  → PrivacyIntroView (privacy_accepted)
    → OnboardingView (5 вопросов + AI инсайт → profile.md)
      → HomeView
        → SessionView (loading → intro → dialogue → closing → oneLastThing → done)

On-device хранилище

Documents/UserProfile/
├── profile.md         ← паттерны, наблюдения, счётчик сессий
├── insights.md        ← кристаллизованные инсайты
└── sessions/
    ├── index.md       ← таблица дат/тем для быстрого поиска
    └── 2026-05-19.md

Сброс: Settings → Данные → Сбросить все данные → удаляет UserProfile/, сбрасывает privacy_accepted, onboarding_completed, API key.


Локальный AI прокси

cd ~/Developer/reflect-skip
PROXY_TOKEN=<token> PROXY_BACKEND=claude-code PROXY_PORT=8765 python3 proxy_server.py

Публичный endpoint (ai.qentra.top)

Параметр Значение
URL https://ai.qentra.top
CF Tunnel Кракен, тот же connector, маршрут ai.qentra.top → localhost:8765
Proxy Token bab835a69b4073c32a283472658d8815bfae59d8
Заголовок X-Proxy-Token: <token>
Backend claude-code (OAuth через ~/.claude/.credentials.json)
Запуск PROXY_TOKEN=<token> PROXY_BACKEND=claude-code PROXY_PORT=8765 python3 proxy_server.py

Защита: GET /health публичный. POST /v1/messages без токена → 401.

DevSetupView (debug): при первом запуске debug-сборки — экран ввода URL + токена. Сохраняет в UserDefaults, переключает backend на localProxy, флаг dev_setup_done = true. В релизной сборке (#if DEBUG) экран не показывается.

В Settings: AI Backend → Локальный сервер → URL + Токен.


Архитектура двойного бота

User input (1-е лицо)
      ↓
CrisisDetector.detect() — hardcoded, <5ms
      ↓ (если .critical → CrisisView)
NarratorBot (Haiku) — переформулировка в 3-е лицо
      ↓
AnalystBot (Sonnet) — читает profile.md + инструменты (search_sessions, read_profile...)
      ↓
1 вопрос из батча → ответ → следующий вопрос → нумерованная подбивка → AnalystBot

Закрытие сессии:

  1. endSession() → AnalystBot генерирует наблюдение (3-е лицо)
  2. NarratorBot генерирует тёплую closing phrase
  3. One last thing: маленький input
  4. Если нет расписания → предложить дату следующей сессии