diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c13eeed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,254 @@ +# 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 ` 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 (RenderEffect blur), 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` 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** — real `Modifier.blur()` (RenderEffect on API 31+), tint + inner highlight + border. API 26–30 fallback: higher tint alpha. 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. + +**What's NOT yet migrated to the design system:** The existing UI components (ChatScreen, Sidebar, ChatInput, etc.) still use the old inline glass styling with manual `Brush` + `border`. Migration to `GlassSurface` + `KaizenShapes` + `kaizenShadow` is the next step. + +### 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` ↔ 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` 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` ↔ 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.