Added 16 new feature commits to the shipped features table. Updated TODO lists (conversation management, biometric unlock, sources all marked done). Documented 10 bugs fixed. Added 3 new constraints/gotchas (keyboard handling, Room schema, BiometricPrompt context). Updated dependencies (biometric), test descriptions (reasoning/sources round-trips, SearchSource converter).
363 lines
32 KiB
Markdown
363 lines
32 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, BiometricUnlock (KeyStore + CryptoObject)
|
||
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`.
|
||
|
||
**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. Theme selection wired end-to-end: `SettingsViewModel.selectedTheme` → `MainActivity` → `KaizenTheme(themeId=...)`. No server sync (web stores in localStorage, app stores locally). Appearance (Light/Dark/System) also wired end-to-end.
|
||
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).
|
||
|
||
**Theme switching fully wired:** All 7 files that previously imported hardcoded `Amber`/`AmberDeep`/`OnAmber`/`BlobX` now read from `LocalKaizenAccent.current`. Changing theme in Settings immediately updates: message bubbles, send button, orb, mesh background blobs, sidebar highlights, model sheet, stream blocks, login screen, settings toggles. No hardcoded amber imports remain outside `Color.kt` and theme definitions.
|
||
|
||
**Floating bar scrims:** Top bar (hamburger + model pill) and bottom dock (mode pills + input) have vertical gradient scrims (`MaterialTheme.colorScheme.background` → transparent) to prevent message content from bleeding through. Model pill opacity increased to 0.92.
|
||
|
||
**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/`.
|
||
|
||
**Typography** — Inter Variable (`res/font/inter_variable.ttf`, 859 KB, SIL OFL). Full type scale from DESIGN.md §8: `displayLarge` (32sp/W600) through `labelSmall` (11sp/W500). All 9 weight axes registered (W100–W900). Negative tracking at display sizes, positive at label sizes. `@OptIn(ExperimentalTextApi::class)` for `FontVariation.Settings`.
|
||
|
||
**Oklab** (`ui/theme/Oklab.kt`) — Double-precision matrix math for accurate sRGB↔Oklab round-trips. Used by theme picker's Oklab gradient swatches and `generateGradientStops()`. Forward LMS matrix corrected to Björn Ottosson reference.
|
||
|
||
### 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`.
|
||
|
||
### Settings persistence + server sync
|
||
|
||
- **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale survive app restart.
|
||
- Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora").
|
||
- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System").
|
||
- On login + on each foreground: `KaizenApi.fetchMe()` → `MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email.
|
||
- **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration.
|
||
- No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local.
|
||
|
||
### Sentinel parsing — reasoning, sources, tools (added 2026-06-21)
|
||
|
||
The `/api/v1/chat` stream embeds control sections via sentinel code points (from `lib/chat/constants.ts`):
|
||
|
||
| Sentinel | Code | What | App UI |
|
||
|----------|------|------|--------|
|
||
| `\x00` USAGE | trailing | Usage/cost JSON | Stripped (display TODO) |
|
||
| `\x01` REASONING | toggle | Model's chain-of-thought | `ReasoningBlock` — collapsible "Gedankengang" |
|
||
| `\x02` SOURCES | trailing | Web search sources JSON | `SourcesBlock` — compact cards with domain |
|
||
| `\x03` TOOL | leading | Tool-call events (JSON+newline) | `ToolEventsBlock` — state machine (thinking/analyzing/start/end/error) |
|
||
| `\x04` QUERY | trailing | Search query string | Shown above sources |
|
||
|
||
**StreamConsumer.Incremental** (`net/StreamConsumer.kt`) returns `StreamState` (not `String`):
|
||
- `content` — visible text (reasoning stripped, tool events stripped)
|
||
- `reasoning` — full reasoning text
|
||
- `tools` — `List<ToolEvent>` (type, name, args, result, error, elapsed)
|
||
- `query` — search query string
|
||
- `sources` — `List<SearchSource>` (title, url, snippet)
|
||
|
||
**KaizenApi.chat()** returns `Flow<StreamState>`. ChatScreen maps all fields to `Message` during streaming.
|
||
|
||
**UI files:** `chat/StreamBlocks.kt` — `ReasoningBlock`, `ToolEventsBlock`, `SourcesBlock`, `SourceChip`.
|
||
|
||
**ToolEventsBlock state machine** (matches web frontend `LiveToolSteps`):
|
||
- `type: "thinking"` → "✨ Verarbeitet …" (only shown when no other tool activity)
|
||
- `type: "analyzing"` → "📄 Analysiert {name} …" (file processing)
|
||
- `type: "start"` → wrench icon + tool name (in progress)
|
||
- `type: "end"` → ✅ checkmark + tool name + elapsed ms (completed)
|
||
- `type: "error"` → ❌ error icon + tool name (failed)
|
||
- Empty/thinking-only events are hidden (no generic "Tool" fallback).
|
||
|
||
### App icon
|
||
|
||
Adaptive icon: amber orb with multi-layer radial gradients (body, depth shadow, specular highlight, warm bounce light, crisp rim). Light mode: warm off-white background. Dark mode: Obsidian gradient. Monochrome layer for Android 13+ themed icons. Splash screen uses orb foreground (API 31+). Vector-only — no raster WebPs.
|
||
|
||
### 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
|
||
→ Background: prefetch messages for all uncached conversations
|
||
Chat open → Room (instant) → Background: server fetch → Room update
|
||
Send message → in-memory during streaming → Room flush after stream ends
|
||
```
|
||
|
||
**Background prefetch:** After conversation list sync, `MessageRepository.prefetchMissing()` iterates all unlocked conversations and fetches messages for any not yet cached. This ensures all chats are readable offline, not just previously opened ones. Already-cached conversations are skipped.
|
||
|
||
**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()`, `allUnlockedIds()`
|
||
- `db/MessageDao.kt` — `observeByConversation()`, `getByConversation()`, `upsertAll()`, `deleteByConversation()`, `cachedConversationIds()`
|
||
- `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe
|
||
- `db/ConversationRepository.kt` — `observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()`
|
||
- `db/MessageRepository.kt` — `observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline)
|
||
- `db/Converters.kt` — `List<Attachment>` + `List<SearchSource>` ↔ 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.
|
||
- **`POST /api/v1/auth/unlock`** — Bearer-authed, returns unlock token in response body (not cookie) for native clients. Rate-limited 10/5min per user.
|
||
- **`GET /api/v1/conversations/[id]`** — accepts `X-Unlock-Token` header alongside the existing `kaizen_unlock` cookie for locked conversations.
|
||
- **`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 |
|
||
| `5804b33` | **Gradient scrims on floating top/bottom bars** — content no longer bleeds through the model pill or mode pills |
|
||
| `78f03ad` | **Mode pills cleanup** — removed muddy amber tint, solid surface fills, neutral borders |
|
||
| `3560665` | **Mode pills multi-select** — modes are now independent toggles, not mutually exclusive |
|
||
| `e4c6e7f` | **EmptyHero centered** — orb + greeting vertically centered between top/bottom bars |
|
||
| `fe5e93c` | **Settings layout fix** — option rows stack title above controls instead of cramming side-by-side |
|
||
| `9186594` | **Background prefetch** — all conversation messages prefetched for offline access on app start |
|
||
| `d468ec8` | **Theme switching wired** — all 7 files migrated from hardcoded Amber to `LocalKaizenAccent.current` |
|
||
| `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` |
|
||
| `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) |
|
||
| `da01b57` | **Attachment picker menu** — three floating GlassSurface pills (camera/gallery/file) with colored round icons, slide animation, dismiss-on-tap-outside. Positioned bottom-left above the + button. Camera uses `TakePicturePreview`, gallery filters `image/*`, file allows `*/*` |
|
||
| `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card |
|
||
| `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability |
|
||
| `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below |
|
||
| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background |
|
||
| `30d3f28` | **Search mode functional** — `webSearch: true` in ChatRequest triggers agentive web search. Sources/query parsed by StreamConsumer, rendered in collapsible SourcesBlock with clickable links (ACTION_VIEW) |
|
||
| `f6dc5db` | **Reasoning presets + sampling controls** — 8-preset dropdown (Sofort→Maximal) sends `reasoningEffort`/`preferThroughput`. Sampling popover with per-parameter toggle + slider for Temperature/Top-P/Top-K |
|
||
| `3ca1d2c` | **Token counter** — parses usage sentinel from stream (`inputTokens`/`outputTokens`), shows ⚡ badge in top bar. Sums tokens from server messages for loaded chats. Context length from model's `context_length` field |
|
||
| `3ca1d2c` | **3D user bubble** — multi-layer gradient, inner highlight, kaizenShadow level1, thicker border |
|
||
| `3ca1d2c` | **Scroll buttons** — glass arrow at center-right, directional (up OR down), hidden during streaming |
|
||
| `f784902` | **Orb rewrite** — transparent glass lens (15-22% alpha tint) instead of opaque painted ball. Background blobs bleed through. Elliptical speculars, double rim, subtle caustics |
|
||
| `4ebe812` | **Theme visibility fix** — added missing blob3, increased blob alpha (0.30-0.42), reduced vignette (0.40). Themes now visually distinct |
|
||
| `f22838e` | **Message actions** — Copy/Delete/Regenerate buttons under each message. Delete via `DELETE /api/v1/conversations/[id]/messages` |
|
||
| `2d57aff` | **Input field redesign** — pill shape, higher opacity (0.85/0.90), Mic/Send toggle (mic when empty, send when text), bottom-aligned buttons |
|
||
| `e9e2a66` | **Stronger haptics** — send CLICK 1.0, thinkingStart double-tap, responseStart crescendo, responseEnd firm settle |
|
||
| `e9e2a66` | **Live language switching** — `LocaleManager` (API 33+) or `recreate()` fallback, immediate effect |
|
||
|
||
**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: `WindowInsets.ime` offset (NOT `imePadding()` — that inflates the dock; NOT `adjustResize` — hides the dock with edge-to-edge). Mode pills hide when keyboard open. `windowSoftInputMode=adjustNothing` in manifest.
|
||
- Settings screen with SettingsViewModel
|
||
|
||
### Bugs fixed (2026-06-19/20/21)
|
||
|
||
- **"Verbindung zum Server fehlgeschlagen" on S25** (06-19) — server running old build without v1 routes. Fix: `git pull && docker compose up -d --build`.
|
||
- **Server 500 on conversation load** (06-21) — Drizzle migration 0025 (`model_id` column) skipped due to rogue watermark entry (`created_at = 1781600000000`) in `drizzle.__drizzle_migrations` table, caused by hand-typed fake timestamps. Fix: lowered rogue entry's timestamp, re-ran `node scripts/migrate.mjs`.
|
||
- **Deploy gotcha (migrations):** fake timestamps in journal entries 0017–0025 (60-second increments) can cause watermark conflicts. Future migrations via `pnpm db:generate` use real timestamps and won't hit this.
|
||
|
||
### Bugs fixed (2026-06-21/22 — app session)
|
||
|
||
- **Room crash on launch** — schema hash mismatch after adding `reasoning`/`sources` fields to `MessageEntity` with version still at 1. Fix: bump to version 2, `fallbackToDestructiveMigration(true)`.
|
||
- **Keyboard handling** — `imePadding()` on Compose Box/Column caused input field to float to top of screen OR be hidden behind keyboard. Fix: read `WindowInsets.ime.getBottom()` directly, apply as negative Y `offset()` on the bottom dock. `windowSoftInputMode=adjustNothing` in manifest.
|
||
- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain. Added fallback when biometrics unavailable.
|
||
- **Error banner stuck** — `loadError` never cleared after unlock failure. Fix: auto-clear after 4s, reset on navigation.
|
||
- **Reasoning/sources lost** — not persisted to server or Room cache. Fix: added fields to `SaveMessage`, `StoredMessage`, `MessageEntity`, `Converters`, `Mappers`.
|
||
- **Reasoning at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`.
|
||
- **Themes barely visible** — blob3 never rendered, alpha too low (0.22-0.30), vignette too aggressive (0.55). Fix: 4 blobs, alpha 0.30-0.42, vignette 0.40.
|
||
- **Mode pills were mock** — didn't send anything to backend. Fix: search sends `webSearch: true`, reasoning sends `reasoningEffort`/`preferThroughput`, sampling sends `temperature`/`topP`/`topK`.
|
||
- **Token counter always 0** — only updated during streaming, not for loaded chats. Fix: sum `inputTokens + outputTokens` from server messages.
|
||
- **Model pill wrong** — showed global default instead of per-chat model. Fix: `chatModel` state from last assistant message's `model` field.
|
||
|
||
## 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
|
||
|
||
- [x] **Conversation rename/delete** from the sidebar — 3-dot menu on each chat, `PATCH`/`DELETE /api/v1/conversations/[id]`
|
||
- [x] **Conversation pin/unpin** from the sidebar — `PATCH { pinned }`, star icon for pinned items
|
||
- [x] **In-app unlock for locked conversations** — BiometricPrompt with CryptoObject (AES key in TEE/StrongBox), hardware-enforced biometric auth → server unlock token via `POST /api/v1/auth/unlock` → fetch messages with `X-Unlock-Token` header. Auto-lock on background. Fallback to direct unlock if biometrics unavailable
|
||
|
||
### 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
|
||
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — implemented for conversation unlock; `setUserAuthenticationRequired(true)` + `setInvalidatedByBiometricEnrollment(true)` ensures hardware-enforced biometric, not bypassable even on rooted devices
|
||
- [ ] **`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, attachment picker menu (camera/gallery/file)
|
||
- [x] **Reasoning/thinking display** — `ReasoningBlock` (collapsible), parses `\x01` sentinel
|
||
- [x] **Web search sources** — `SourcesBlock` (collapsible, clickable links via ACTION_VIEW), parses `\x02`/`\x04` sentinels. `webSearch: true` in ChatRequest triggers agentive search
|
||
- [x] **Tool call display** — `ToolEventsBlock`, parses `\x03` sentinel, status icons per step
|
||
- [ ] **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"
|
||
biometric = "1.4.0-alpha02"
|
||
```
|
||
|
||
## Tests
|
||
|
||
**Unit tests** (`app/src/test/`): JVM-only, no emulator needed.
|
||
|
||
- `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default
|
||
- `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools/usage parsing, char-by-char edge cases
|
||
- `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix)
|
||
- `MappersTest` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity`, round-trip integrity, attachment preservation, reasoning round-trip, null reasoning preserved, sources round-trip, empty sources → null, reasoning+sources together
|
||
- `ConvertersTest` — `List<Attachment>` + `List<SearchSource>` ↔ 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
|
||
|
||
## 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`.
|
||
- **Keyboard handling:** do NOT use `imePadding()` (inflates the dock composable, input floats to top) or `adjustResize` (hides input behind keyboard with edge-to-edge). Instead: read `WindowInsets.ime.getBottom()` and apply as `Modifier.offset(y = -bottomPx.toDp())` on the bottom dock. `windowSoftInputMode=adjustNothing` in manifest.
|
||
- **Room schema changes:** bump `version` in `@Database` annotation AND use `fallbackToDestructiveMigration(true)`. `false` only covers downgrades, not same-version hash mismatches → crash.
|
||
- **BiometricPrompt context:** `LocalContext.current` may be a `ContextWrapper`, not `FragmentActivity`. Walk the wrapper chain: `while (ctx is ContextWrapper) { if (ctx is FragmentActivity) break; ctx = ctx.baseContext }`.
|
||
- **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.
|