397 lines
17 KiB
Markdown
397 lines
17 KiB
Markdown
---
|
||
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 |
|
||
|
||
---
|
||
|
||
## 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. Если нет расписания → предложить дату следующей сессии
|