Inter Variable (v4.1, 859 KB): full type scale with 9 weight axes (W100–W900), negative tracking at display sizes, positive at labels. Theme picker: SettingsViewModel now uses KaizenThemeId from the design system (AMBER/BLAU/MONO/AURORA). MainActivity wires selectedTheme + appearanceMode to KaizenTheme composable — switching themes in Settings instantly propagates to the entire app. No server sync needed — web stores theme in localStorage, app stores locally. Backend /api/v1/me has no uiTheme field in the DB. Settings strings migrated to string resources (de + en). SettingsViewModelTest updated for new enum values + System default.
20 KiB
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 remoteorigin=git@git.kryptomrx.de:krypto/kaizen.git, branchmain. Read itsCLAUDE.mdfirst; 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/Kaizenitself is NOT a git repo. Onlykaizenandkaizen-appare 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 viarequireActiveUserOrToken(lib/auth/guard.ts). - Token acquisition: in-app login (
POST {baseUrl}/api/v1/auth/tokenwith{ email, password, name }). Consumer "Sign in" pattern — no copy-paste. - Chat:
POST {baseUrl}/api/v1/chat—{ model, messages: [{ role, content }], reasoningEffort?, preferThroughput? }. Response istext/plainchunked stream (NOT SSE). Parsed byStreamConsumer.Incrementalinnet/StreamConsumer.kt. - Sentinels (
lib/chat/constants.ts):\x00USAGE,\x01REASONING,\x02SOURCES,\x03TOOL,\x04QUERY. 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) — singletonobject, all HTTP calls via OkHttp. Streaming chat returnsFlow<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. HoldsServerConfig(baseUrl, token, model, email, speed). Persisted inEncryptedSharedPreferencesviaSecureStore. - ChatViewModel (
chat/ChatViewModel.kt) — owns Room database instance + repositories. Created inMainActivity, passed toChatScreen. - 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):
- 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+) inMainActivity. NoColor(0xFF…)for brand colors outsideColor.kt. - Squircle shapes — iOS-style superellipse with G2 continuity (
ui/shape/Squircle.kt). Token systemKaizenShapes.{xs,sm,md,lg,xl,pill,circle}. Replaces allRoundedCornerShape. - 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. - Multi-layer shadows —
ShadowStackwith 2–4 overlapping layers (KaizenShadows.level0..4). Dark mode multiplies alphas ×1.8. Replaces allModifier.shadow(). - 4-theme system — Amber/Blau/Mono/Aurora. Each theme =
KaizenAccentScheme(primary + 4 blob colors). Neutrals are theme-independent.LocalKaizenAccentCompositionLocal. 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. - Sensor-reactive motion —
rememberTiltState()provides smoothed tiltOffset(-1..1). UsesTYPE_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/.
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@Composablefunctions, orcontext.getString(R.string.xxx)in non-composable contexts (error handlers, coroutines). - Date/time:
DateTimeFormatterwithLocale.getDefault(). - Greeting: time-of-day-based (Morgen/Tag/Abend → Morning/Afternoon/Evening), boundaries 5/12/18.
- ChatModels:
Suggestion.labelRes/promptResandChatMode.labelResare resource IDs (Int), not hardcoded strings. ModePillsRow:selectedstate isInt(resource ID), notString.
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, cachedAtmessages— 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,MessageEntitydb/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 viacompanion object(double-checked locking), thread-safedb/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.serializationdb/Mappers.kt—ConversationSummary↔ConversationEntity,StoredMessage↔MessageEntity
AGP 9.x compat: android.disallowKotlinSourceSets=false in gradle.properties (KSP issue #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_tokenstable + SHA-256 hash storage (never raw). Migration0024.- Bearer auth path (
getTokenSession/requireActiveUserOrTokeninlib/auth/guard.ts). - Token mint endpoint
POST /api/v1/auth/token— unauthenticated, Argon2id verify, anti-enumeration, rate-limited. GET /api/v1/me— identity +defaultModelfor 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
- Code block copy button — tap-to-copy on fenced code blocks (clipboard + haptic feedback)
- Horizontal rule / blockquote rendering —
---and> quotenot yet parsed - Table rendering — Markdown tables render as raw text
- 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/metaafter login to detect server skew and warn cleanly - Biometric lock (
BiometricPrompt+ KeyStore binding) — seeLATER.md§1C FLAG_SECUREon sensitive screens — seeLATER.md§1B
4. Features for parity with the web
- Attachments — image/file upload, attachment preview in messages, display in loaded conversations (camera capture still TODO)
- Reasoning/thinking display — parse
\x01sentinel, show collapsible thinking block - Web search sources — parse
\x02/\x04sentinels, render source cards - Tool call display — parse
\x03sentinel, 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
cachedAtto 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
sinceparameter) - 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
# 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 updatesStreamConsumerTest— incremental parser, sentinel stripping, edge casesOklabTest— 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 preservationConvertersTest—List<Attachment>↔ JSON round-trips, malformed JSON returns empty list, unknown fields tolerance viaignoreUnknownKeysColorPipelineTest— all tokens in DisplayP3, approximate value ranges, no pure black/white in neutralsSquircleShapeTest— KaizenShapes token existence/distinctness, SquircleShape toStringThemeRegistryTest— 4 themes present, forId lookup, fromString parsing + fallback, distinct blob colorsShadowStackTest— layer counts per level, alpha range validation, blur/alpha progression
Pre-existing issues (not from this work)
OklabTesthas 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.localis stale. - Never hand-write migrations or fake timestamps — use
pnpm db:generate. Alwaysgit pullbeforedocker buildon 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 pullBEFOREdocker 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=falseingradle.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.