Files
obsidian-vault/personal/projects/psychologist-app/dev-setup.md
T
2026-05-29 23:00:57 +00:00

401 lines
17 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
created: '2026-05-16'
updated: '2026-05-19'
tags:
- 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
```bash
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 индексы
### Первоначальная настройка (один раз)
```bash
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 |
| Session id to measure token burn | |
| Analytics | |
---
Paywall option - Adapty
## Android Signing
**Keystore:** `~/.android/signing/nolvu-release.jks` (вне репо, не в git)
**Fingerprint (SHA-256):** `9A:17:73:F0:A7:4E:8C:7B:2E:AA:D5:0F:AD:FC:71:84:92:D1:88:FB:04:2F:32:56:63:12:ED:3E:28:3E:2B:F7`
**keystore.properties:** `Android/app/keystore.properties` (в .gitignore, абсолютный путь к keystore)
- storeFile: `/Users/admin/.android/signing/nolvu-release.jks`
- keyAlias: `nolvu` / пароли: `nolvu2026`
> При переезде на новую машину — скопировать `~/.android/signing/nolvu-release.jks` и пересоздать `Android/app/keystore.properties` с правильным путём.
> Fingerprint при этом останется тем же — пользователям переподключать репо не нужно.
## 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 файлов)
```bash
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
```
### Быстрая пересборка (инкрементальная)
```bash
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
```
### Деплой на эмулятор
```bash
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`, меняем класс):
```kotlin
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/
```
### 2. Kotlin субмодули — symlinks
После очистки кеша/DerivedData Kotlin исходники субмодулей (SkipLib, SkipBridge, etc.) перестают быть видны Gradle. Нужно перелинковать:
```bash
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`:
```kotlin
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 прокси
```bash
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. Если нет расписания → предложить дату следующей сессии