KaizenOrb now uses centralized rememberTiltState() (GAME_ROTATION_VECTOR, lifecycle-aware, battery-saver-aware) instead of inline sensor code. Breath animation uses Durations tokens from motion system. CLAUDE.md: updated design system section with GlassSurface correction (no backdrop-filter on Android), migration status (all components done), i18n section documenting the de/en string resource architecture.
266 lines
19 KiB
Markdown
266 lines
19 KiB
Markdown
# Kaizen App
|
||
|
||
Architecture map for the native Android client. Source of truth for any agent or human picking up this codebase.
|
||
|
||
## Goal
|
||
|
||
Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks to the **Kaizen backend** (`kaizen`). One binary, deployment-agnostic: one uniform `Authorization: Bearer <token>` contract, same against the official server or any self-host. Design constraint (hard): backend complexity stays invisible to the user.
|
||
|
||
## Repos (absolute paths)
|
||
|
||
- Backend (Next.js 16, Drizzle, Postgres): `/Users/brunodeanoz/Kaizen/kaizen` — git remote `origin` = `git@git.kryptomrx.de:krypto/kaizen.git`, branch `main`. Read its `CLAUDE.md` first; it is the architecture map.
|
||
- Android app (Kotlin, Jetpack Compose, package `dev.kaizen.app`): `/Users/brunodeanoz/Kaizen/kaizen-app` — built/run from Windows Android Studio on a real S25 Ultra; everything else (backend, Postgres, Docker) runs on the dev Mac + dedicated servers.
|
||
- **Note:** `/Users/brunodeanoz/Kaizen` itself is NOT a git repo. Only `kaizen` and `kaizen-app` are separate git repos.
|
||
|
||
## Platform strategy
|
||
|
||
- Android: Kotlin + Jetpack Compose (native, no WebView)
|
||
- iOS (planned): Swift + SwiftUI + SwiftData (native, separate codebase)
|
||
- No KMP — each platform uses its native stack for best feel
|
||
|
||
## The API contract the app uses (source of truth — verify, don't guess)
|
||
|
||
- **Auth:** every request carries `Authorization: Bearer kzn_...`. Backend resolves via `requireActiveUserOrToken` (`lib/auth/guard.ts`).
|
||
- **Token acquisition:** in-app login (`POST {baseUrl}/api/v1/auth/token` with `{ email, password, name }`). Consumer "Sign in" pattern — no copy-paste.
|
||
- **Chat:** `POST {baseUrl}/api/v1/chat` — `{ model, messages: [{ role, content }], reasoningEffort?, preferThroughput? }`. Response is `text/plain` chunked stream (NOT SSE). Parsed by `StreamConsumer.Incremental` in `net/StreamConsumer.kt`.
|
||
- **Sentinels** (`lib/chat/constants.ts`): `\x00` USAGE, `\x01` REASONING, `\x02` SOURCES, `\x03` TOOL, `\x04` QUERY. App currently strips all sentinels and renders only visible content.
|
||
- **Models:** `GET {baseUrl}/api/v1/models` → `{ models: [{ id, name?, provider? }] }`.
|
||
- **Conversations:** `GET /api/v1/conversations` → `{ conversations, hasMore }`. `POST /api/v1/conversations` → create. `GET /api/v1/conversations/[id]` → `{ messages }`. `POST /api/v1/conversations/[id]/messages` → persist. `POST /api/v1/conversations/[id]/title` → auto-title.
|
||
- **Identity:** `GET /api/v1/me` → `{ id, email, name, role, defaultModel }`.
|
||
- **Capability handshake:** `GET /api/v1/meta` → `{ app, apiVersion, features }` (unauth).
|
||
- **Upload:** `POST /api/v1/upload` — multipart file → `{ url, kind, name, mimeType, size, fileId }`.
|
||
|
||
## App architecture
|
||
|
||
### Package structure (`app/src/main/java/dev/kaizen/app/`)
|
||
|
||
```
|
||
auth/ LoginScreen
|
||
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
|
||
ModelSheet, Markdown, Background
|
||
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
||
Repositories, Converters, Mappers
|
||
haptics/ Phase-aware haptics
|
||
net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
|
||
SessionViewModel, ServerConfig, StreamConsumer
|
||
settings/ SettingsScreen, SettingsViewModel, SettingsComponents
|
||
ui/
|
||
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
|
||
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
|
||
shape/ SquircleShape (superellipse), KaizenShapes (token system)
|
||
effect/ GlassSurface (frosted glass), Shadow (multi-layer stack)
|
||
motion/ Motion (easing curves, spring specs, duration tokens)
|
||
sensor/ TiltState (gyro/accel parallax, lifecycle-aware)
|
||
```
|
||
|
||
### Key components
|
||
|
||
- **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Streaming chat returns `Flow<String>` with incremental sentinel-stripping.
|
||
- **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream.
|
||
- **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`.
|
||
- **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreen`.
|
||
- **ChatScreen** (`chat/ChatScreen.kt`) — main composable, orchestrates sidebar, messages, streaming, file uploads.
|
||
|
||
### Design System v2 (`ui/`) — added 2026-06-21
|
||
|
||
Full spec: `DESIGN.md`. Branch: `feature/design-system-v2`.
|
||
|
||
**Six pillars** (load-bearing — if any regress, the design regresses):
|
||
|
||
1. **P3 color pipeline** — all tokens in DisplayP3 color space (`Color.kt`). `window.colorMode = COLOR_MODE_HDR` (API 34+) / `COLOR_MODE_WIDE_COLOR_GAMUT` (API 26+) in `MainActivity`. No `Color(0xFF…)` for brand colors outside `Color.kt`.
|
||
2. **Squircle shapes** — iOS-style superellipse with G2 continuity (`ui/shape/Squircle.kt`). Token system `KaizenShapes.{xs,sm,md,lg,xl,pill,circle}`. Replaces all `RoundedCornerShape`.
|
||
3. **GlassSurface** — frosted-glass composable (gradient tint + specular border + inner highlight + multi-layer shadow). Android has no backdrop-filter; glass effect works via semi-transparent tint over the blob layer. Opacity tiers per component in `GlassTiers`.
|
||
4. **Multi-layer shadows** — `ShadowStack` with 2–4 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
|
||
5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. Server sync planned via `GET /api/v1/me` → `uiTheme`.
|
||
6. **Sensor-reactive motion** — `rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift). Auto-disables on battery saver, reduced motion, background, extreme tilt.
|
||
|
||
**Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object.
|
||
|
||
**Migration status:** All UI components migrated to the design system. Sidebar, ChatInput, ChatScreen, LoginScreen, ModelSheet, SettingsCard, SettingsScreen use `GlassSurface` + `KaizenShapes` + `kaizenShadow`. Only Markdown code-block shapes remain as `RoundedCornerShape` (intentionally rectangular for code).
|
||
|
||
**KaizenOrb** (`chat/Background.kt`) uses `rememberTiltState()` from `ui/sensor/` instead of inline sensor code. Breath animation uses `Durations.ORB_BREATH` / `ORB_BREATH_STREAMING` from `ui/motion/`.
|
||
|
||
### Internationalization (i18n)
|
||
|
||
- **de** (default), **en** — same as web (`messages/de.json`, `messages/en.json`).
|
||
- Strings: `res/values/strings.xml` (de) + `res/values-en/strings.xml` (en).
|
||
- All UI strings use `stringResource(R.string.xxx)` in `@Composable` functions, or `context.getString(R.string.xxx)` in non-composable contexts (error handlers, coroutines).
|
||
- Date/time: `DateTimeFormatter` with `Locale.getDefault()`.
|
||
- Greeting: time-of-day-based (Morgen/Tag/Abend → Morning/Afternoon/Evening), boundaries 5/12/18.
|
||
- ChatModels: `Suggestion.labelRes`/`promptRes` and `ChatMode.labelRes` are resource IDs (Int), not hardcoded strings.
|
||
- `ModePillsRow`: `selected` state is `Int` (resource ID), not `String`.
|
||
|
||
### Offline-first cache layer (Room) — added 2026-06-21
|
||
|
||
Architecture decision document: `OFFLINE_CACHE_ADD.md`.
|
||
|
||
**Principle:** Read-cache, not sync engine. Server is source of truth. No offline-queue, no conflict resolution.
|
||
|
||
**Stack:** Room 2.8.4 + KSP 2.2.10-2.0.2. `room-ktx` merged into `room-runtime` since Room 2.7 (no separate dep needed). `exportSchema = false`, `fallbackToDestructiveMigration()` — it's a cache, not a DB. On schema changes the SQLite is wiped; cache rebuilds on next server fetch.
|
||
|
||
**Schema:**
|
||
- `conversations` — id, title, locked, pinned, updatedAt, cachedAt
|
||
- `messages` — id, conversationId (FK CASCADE), role, content, attachments (JSON via TypeConverter), sortOrder
|
||
|
||
**Data flow:**
|
||
```
|
||
App start → Room (instant) → UI shows cached data
|
||
→ Background: server fetch → Room update → Flow emits → UI updates
|
||
Chat open → Room (instant) → Background: server fetch → Room update
|
||
Send message → in-memory during streaming → Room flush after stream ends
|
||
```
|
||
|
||
**Streaming hybrid model:** During active streaming, messages are mutated in-memory (60x/s). Room is NOT written during streaming — that would be absurd I/O. After stream completion, user + assistant messages are upserted into Room. This avoids SQLite lock contention while keeping the cache fresh.
|
||
|
||
**Files:**
|
||
- `db/Entities.kt` — `ConversationEntity`, `MessageEntity`
|
||
- `db/ConversationDao.kt` — `observeAll()` (Flow), `replaceAll()` (transaction: deleteAll + upsertAll, avoids SQLite 999-variable limit), `lastCachedAt()`
|
||
- `db/MessageDao.kt` — `observeByConversation()`, `getByConversation()`, `upsertAll()`, `deleteByConversation()`
|
||
- `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe
|
||
- `db/ConversationRepository.kt` — `observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room)
|
||
- `db/MessageRepository.kt` — `observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`
|
||
- `db/Converters.kt` — `List<Attachment>` ↔ JSON string via kotlinx.serialization
|
||
- `db/Mappers.kt` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity`
|
||
|
||
**AGP 9.x compat:** `android.disallowKotlinSourceSets=false` in `gradle.properties` (KSP issue [#2729](https://github.com/google/ksp/issues/2729)).
|
||
|
||
## What's DONE and verified
|
||
|
||
### Backend — all complete, deployed, tested
|
||
|
||
All backend work for the app is done. `pnpm test:run` green (774+), `tsc` + `lint` clean.
|
||
|
||
- **`api_tokens` table** + SHA-256 hash storage (never raw). Migration `0024`.
|
||
- **Bearer auth path** (`getTokenSession` / `requireActiveUserOrToken` in `lib/auth/guard.ts`).
|
||
- **Token mint endpoint** `POST /api/v1/auth/token` — unauthenticated, Argon2id verify, anti-enumeration, rate-limited.
|
||
- **`GET /api/v1/me`** — identity + `defaultModel` for native clients.
|
||
- **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog.
|
||
- **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed.
|
||
- **`GET /api/v1/meta`** — capability handshake.
|
||
- **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`.
|
||
- **API versioning** — `/api/v1/` for outward contract, additive-only within v1. Web-internal endpoints stay unversioned.
|
||
|
||
### App — shipped features (on-device verified on S25 Ultra)
|
||
|
||
All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKotlin` clean).
|
||
|
||
| Commit | Feature |
|
||
|--------|---------|
|
||
| `44c17b6` | In-app login (email+password → `POST /api/v1/auth/token`), `EncryptedSharedPreferences`, real streaming via `StreamConsumer.Incremental`, `ServerConfig` persistence |
|
||
| `aa0a97b` | Dither overlay to kill 8-bit sRGB banding on orb + mesh gradients |
|
||
| `d2f269f` | Model + speed picker (top pill → bottom sheet), response-speed segment, favorites, provider badges |
|
||
| `ea5e625` | Sidebar wired to real conversations, persistence (create/save/load/auto-title), date-grouped history |
|
||
| `7399c73` | Model sheet redesign with OpenRouter/Vertex badging, offline-first caching |
|
||
| `d81c6ff` | Robust JSON parsing (malformed response handling) |
|
||
| `827a2f3` | Streaming performance, error handling |
|
||
| `423abc0` | **Specific error diagnostics** — `FetchResult<T>` replaces null-returns; error banner shows exact cause (HTTP status, DNS, SSL, timeout, JSON parsing) instead of generic "Verbindung zum Server fehlgeschlagen" |
|
||
| `4616ef2` | **Markdown rendering with syntax-highlighted code blocks** — native Compose renderer: headings, bold/italic, inline code, fenced code blocks with keyword highlighting (15+ languages), ordered/unordered lists, streaming cursor integration |
|
||
| `d8dcb25` | **Attachment support — display, upload, and send files** — parse + render message attachments from loaded conversations (images inline via OkHttp + in-memory cache, other kinds as icon chips); + button wired to Android file picker with upload to `/api/v1/upload`, progress chips, send attachments with chat requests + persist in saved messages. Also: Markdown horizontal rules, blockquotes, links, code block copy button |
|
||
| `9759d47` | **Offline-first cache layer (Room)** — sidebar + messages load instantly from SQLite cache, background refresh syncs with server, streaming hybrid model (in-memory during stream, Room flush after), ChatViewModel, repositories, mappers, TypeConverters, unit tests |
|
||
|
||
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
||
- Liquid glass styling, floating panels, glassmorphic sidebar
|
||
- KaizenOrb with 3D sphere rendering, streaming animation
|
||
- Mesh gradient background with dithered blobs
|
||
- Phase-aware haptics (click → thinkingStart → responseStart → responseEnd)
|
||
- Tablet/landscape optimization (width capping)
|
||
- Dynamic keyboard handling (suggestion pills hide, spacer shrinks)
|
||
- Settings screen with SettingsViewModel
|
||
|
||
### Bug fixed this session (2026-06-19/20)
|
||
|
||
**"Verbindung zum Server fehlgeschlagen" on S25** — root cause was **server running an old build** without the v1/conversations route (404) and without Bearer auth on models (401). Fix was `git pull && docker compose -f docker-compose.prod.yml up -d --build` on the server. Additionally, the error diagnostics were improved (see `423abc0`) so future issues show the exact cause in the UI banner instead of a generic message.
|
||
|
||
## What's NOT done yet (app, priority order)
|
||
|
||
### 1. Remaining visual/UX gaps (daily-drive blockers)
|
||
|
||
These are what separate "prototype" from "daily-driver":
|
||
|
||
- [ ] **Markdown rendering during streaming** — currently renders live but performance with long code blocks during high-speed streaming needs real-device verification
|
||
- [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback)
|
||
- [x] **Horizontal rule / blockquote rendering** — `---` and `> quote` not yet parsed
|
||
- [ ] **Table rendering** — Markdown tables render as raw text
|
||
- [x] **Link rendering** — styled visually, not yet clickable (tap-to-open pending)
|
||
|
||
### 2. Conversation management from the app
|
||
|
||
- [ ] **Conversation rename/delete** from the sidebar (currently read-only)
|
||
- [ ] **Conversation pin/unpin** from the sidebar
|
||
- [ ] **In-app unlock for locked conversations** (currently skipped with "Gesperrter Chat" label)
|
||
|
||
### 3. Security hardening
|
||
|
||
- [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner
|
||
- [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly
|
||
- [ ] **Biometric lock** (`BiometricPrompt` + KeyStore binding) — see `LATER.md` §1C
|
||
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
|
||
|
||
### 4. Features for parity with the web
|
||
|
||
- [x] **Attachments** — image/file upload, attachment preview in messages, display in loaded conversations (camera capture still TODO)
|
||
- [ ] **Reasoning/thinking display** — parse `\x01` sentinel, show collapsible thinking block
|
||
- [ ] **Web search sources** — parse `\x02`/`\x04` sentinels, render source cards
|
||
- [ ] **Tool call display** — parse `\x03` sentinel, show live tool steps
|
||
- [ ] **Native passkey login** (WebAuthn/FIDO2 on Android)
|
||
- [ ] **WebSocket streaming** (replace chunked HTTP for lower latency on mobile)
|
||
- [ ] **Usage/cost display**
|
||
- [ ] **User context / KI-Profil**
|
||
|
||
### 5. App distribution
|
||
|
||
- [ ] **Push notifications** (FCM)
|
||
- [ ] **Play Store / F-Droid listing**
|
||
- [ ] **Auto-update mechanism**
|
||
|
||
### 6. Offline-cache enhancements (future iterations, see `OFFLINE_CACHE_ADD.md` §11)
|
||
|
||
- [ ] **Stale-check enforcement** — use `cachedAt` to trigger background refresh when older than N minutes
|
||
- [ ] **Offline-queue for sends** — queue messages locally, send when network returns (needs conflict resolution)
|
||
- [ ] **Incremental sync** — only fetch changed conversations (needs server-side `since` parameter)
|
||
- [ ] **Media cache** — cache images/files locally (Coil or custom disk cache)
|
||
- [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively)
|
||
- [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB
|
||
|
||
## Dependencies
|
||
|
||
```toml
|
||
# gradle/libs.versions.toml (key versions)
|
||
agp = "9.2.1"
|
||
kotlin = "2.2.10"
|
||
ksp = "2.2.10-2.0.2"
|
||
room = "2.8.4"
|
||
composeBom = "2026.02.01"
|
||
okhttp = "4.12.0"
|
||
kotlinxSerialization = "1.7.3"
|
||
securityCrypto = "1.1.0-alpha06"
|
||
```
|
||
|
||
## Tests
|
||
|
||
**Unit tests** (`app/src/test/`): JVM-only, no emulator needed.
|
||
|
||
- `SettingsViewModelTest` — state transitions, default values, theme selection, name updates
|
||
- `StreamConsumerTest` — incremental parser, sentinel stripping, edge cases
|
||
- `OklabTest` — 2 pre-existing failures (round-trip color drift > 0.01 tolerance, unrelated to backend wiring, likely too-tight tolerance or headless float precision)
|
||
- `MappersTest` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity`, round-trip integrity, attachment preservation
|
||
- `ConvertersTest` — `List<Attachment>` ↔ JSON round-trips, malformed JSON returns empty list, unknown fields tolerance via `ignoreUnknownKeys`
|
||
- `ColorPipelineTest` — all tokens in DisplayP3, approximate value ranges, no pure black/white in neutrals
|
||
- `SquircleShapeTest` — KaizenShapes token existence/distinctness, SquircleShape toString
|
||
- `ThemeRegistryTest` — 4 themes present, forId lookup, fromString parsing + fallback, distinct blob colors
|
||
- `ShadowStackTest` — layer counts per level, alpha range validation, blur/alpha progression
|
||
|
||
## Pre-existing issues (not from this work)
|
||
|
||
- `OklabTest` has 2 headless failures (`testColorToOklabAndBack`, `testGradientGeneration` — round-trip color drift > 0.01 tolerance). Unrelated to backend wiring; likely too-tight tolerance or headless float precision.
|
||
|
||
## Constraints & gotchas
|
||
|
||
- Local dev Mac has **no RustFS** (storage on server only); upload/storage flows fail locally with `ECONNREFUSED :9000`.
|
||
- Live OpenRouter key is encrypted in the DB (`api_keys`), `.env.local` is stale.
|
||
- Never hand-write migrations or fake timestamps — use `pnpm db:generate`. Always `git pull` before `docker build` on prod.
|
||
- App philosophy: the real driver is *feel*, not features; native Compose (no WebView). Backend complexity stays invisible.
|
||
- **Deploy gotcha (learned 2026-06-19):** `git pull` BEFORE `docker compose up --build`, else the image bakes stale code and the app gets 404/401 against endpoints that exist in the repo but aren't in the running build.
|
||
- Headless compile on Mac: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin` — JDK 21 via Homebrew, Android SDK at `/opt/homebrew/share/android-commandlinetools`.
|
||
- **AGP 9.x + KSP:** requires `android.disallowKotlinSourceSets=false` in `gradle.properties`.
|
||
- **Room is a cache:** `fallbackToDestructiveMigration()` means the SQLite DB is wiped on schema changes. No data loss — server is source of truth, cache rebuilds on next fetch.
|