Compare commits
3 commits
430d5b609e
...
7aefd7ba70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aefd7ba70 | ||
|
|
e6fa8ef8a6 | ||
|
|
dca9cb7593 |
8 changed files with 131 additions and 60 deletions
59
CLAUDE.md
59
CLAUDE.md
|
|
@ -48,7 +48,7 @@ settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecurityS
|
||||||
ui/
|
ui/
|
||||||
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
|
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
|
||||||
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
|
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
|
||||||
icon/ KaizenIcons (central registry, Lucide base, custom override layer)
|
icon/ KaizenIcons (central registry, Lucide base, custom override layer), KaizenCustomIcons (hand-drawn 1.0px stroke)
|
||||||
shape/ SquircleShape (superellipse), KaizenShapes (token system)
|
shape/ SquircleShape (superellipse), KaizenShapes (token system)
|
||||||
effect/ GlassSurface (frosted glass), Shadow (multi-layer stack)
|
effect/ GlassSurface (frosted glass), Shadow (multi-layer stack)
|
||||||
motion/ Motion (easing curves, spring specs, duration tokens)
|
motion/ Motion (easing curves, spring specs, duration tokens)
|
||||||
|
|
@ -75,7 +75,7 @@ Full spec: `DESIGN.md`.
|
||||||
4. **Multi-layer shadows** — `ShadowStack` with 2–4 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
|
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.
|
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), `SENSOR_DELAY_UI` (~15 Hz, sufficient for parallax). Auto-disables on battery saver, reduced motion, background, extreme tilt.
|
6. **Sensor-reactive motion** — `rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift), `SENSOR_DELAY_UI` (~15 Hz, sufficient for parallax). Auto-disables on battery saver, reduced motion, background, extreme tilt.
|
||||||
7. **KaizenIcons** (`ui/icon/KaizenIcons.kt`) — centralized icon registry, same architecture as Google's Luminous Symbols. Lucide (1.5px stroke, same library as web frontend) as base, with a custom override layer for brand-critical icons. Every UI component references `KaizenIcons.Send`, `KaizenIcons.Lock`, etc. — never a library directly. Zero Material Icon imports remain. To add a custom icon: create an `ImageVector` literal (hand-drawn SVG converted to Compose path data) and swap the getter in `KaizenIcons.kt` — all usages update instantly, no call-site changes. Icons grouped semantically: Navigation, Chat (incl. `AudioLines` for call feature), Modes, Conversation, Files, Search, Settings, Scroll. `com.composables:icons-lucide-android:1.0.0`.
|
7. **KaizenIcons** (`ui/icon/KaizenIcons.kt`) — centralized icon registry, same architecture as Google's Luminous Symbols. Lucide (1.5px stroke, same library as web frontend) as base, with a custom override layer (`KaizenCustomIcons.kt`) for the highest-visibility touchpoints. Custom icons use 1.0px stroke to match the lighter W300 typography. Currently custom: Menu, Plus, Mic, AudioLines, SlidersHorizontal, Globe, Image. Every UI component references `KaizenIcons.Send`, `KaizenIcons.Lock`, etc. — never a library directly. Zero Material Icon imports remain. `com.composables:icons-lucide-android:1.0.0`.
|
||||||
|
|
||||||
**Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object.
|
**Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object.
|
||||||
|
|
||||||
|
|
@ -83,15 +83,19 @@ Full spec: `DESIGN.md`.
|
||||||
|
|
||||||
**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.
|
**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 and bottom dock have soft transparent gradient scrims. Bottom dock: 0% → 25% → 55% alpha when keyboard closed, 50% → 85% when keyboard open. Mesh background bleeds through — no opaque block.
|
**Floating bar scrims:** Top bar has soft transparent gradient scrim. Bottom dock is the ChatInput capsule itself (no separate scrim needed).
|
||||||
|
|
||||||
**Unified top bar:** Sidebar trigger (menu icon) and model name merged into a single `GlassSurface` pill (38dp, `KaizenShapes.pill`). Menu icon (17dp, muted) → 3dp dot divider → model name (13sp Medium, 180dp max with ellipsis). No chevron — pill is self-evidently tappable. Matches the TokenBadge glass language on the right.
|
**Unified top bar:** Sidebar trigger (menu icon) and model name merged into a single `GlassSurface` pill (38dp, `KaizenShapes.pill`). Menu icon (17dp, muted) → 3dp dot divider → model name (13sp W300, 180dp max with ellipsis). No chevron — pill is self-evidently tappable. Right side: PenLine "New Chat" button → TokenBadge.
|
||||||
|
|
||||||
**MeshBackground** (`chat/Background.kt`) — `animateBlobs` parameter: blob drift animation (22s cycle) only runs on empty state (`messages.isEmpty()`), frozen at midpoint during chat to save GPU. Blobs remain visible as static background.
|
**ChatInput capsule:** Single-row Gemini-style input — Plus | TextField | Tune | Mic | Send/Call. 64dp min height, pill shape (34dp radius), level4 shadow, high opacity (0.92/0.97). Mode pills hidden by default, expandable via tune icon (SlidersHorizontal) with slide animation inside the same GlassSurface card. Replaces the old two-row layout + separate floating pills row.
|
||||||
|
|
||||||
|
**MeshBackground** (`chat/Background.kt`) — `animateBlobs` parameter: blob drift animation (22s cycle) only runs on empty state (`messages.isEmpty()`), frozen at midpoint during chat to save GPU. Blobs remain visible as static background. Blobs are 400-460dp (large, fill entire screen on S25 Ultra), alpha 0.36-0.48 (dark) with light-mode factor 0.78 (was 0.65). Vignette 0.35 alpha.
|
||||||
|
|
||||||
**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/`.
|
**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`.
|
**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`. **Visual refresh (2026-06-23):** Body text W400→W300 for airier feel (inspired by Gemini's Neural Expressive). Headings scaled up: H1 26sp/W600, H2 22sp/W600, H3 18sp/W500. User bubble text, input field, hero all W300. Labels/pills stay W400-W500 for legibility.
|
||||||
|
|
||||||
|
**Semantic Colors** — `KaizenSemanticColors` object in `Color.kt`: `error`, `errorDeep`, `success`, `successBright`, `indigo`, `linkLight/Dark`, `dropdownDark/Light`. Single source of truth for status/action colors — no `Color(0xFF...)` for these outside `Color.kt`. Syntax highlighting colors (Dracula theme) and glass-surface tints (Slate) intentionally stay local.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|
@ -148,7 +152,7 @@ The `/api/v1/chat` stream embeds control sections via sentinel code points (from
|
||||||
|
|
||||||
### App icon
|
### 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.
|
Adaptive icon: translucent glass-lens orb (8 layers — subtle amber tint at 50-60% alpha, light speculars, thin rims). Matches the in-app KaizenOrb aesthetic (glass, not opaque paint). Light mode: neutral off-white background (#F6F7F9). Dark mode: Obsidian gradient (#0F1520). Monochrome layer for Android 13+ themed icons. Splash screen uses orb foreground via `windowSplashScreenAnimatedIcon` (API 31+). Vector-only — no raster WebPs.
|
||||||
|
|
||||||
### Offline-first cache layer (Room) — added 2026-06-21
|
### Offline-first cache layer (Room) — added 2026-06-21
|
||||||
|
|
||||||
|
|
@ -279,6 +283,15 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, removes message + all after it, puts text back in input field |
|
| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, removes message + all after it, puts text back in input field |
|
||||||
| `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons |
|
| `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons |
|
||||||
| `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards |
|
| `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards |
|
||||||
|
| `75ca8c0` | **Visual refresh** — body text W400→W300 (airier), headings scaled up (H1 26sp, H2 22sp, H3 18sp), `animateItem()` on messages (350ms fade-in, spring placement), blobs 20% larger + better distribution, higher alpha on OLED, light-mode factor 0.65→0.78, distinct `primaryDeep` per theme |
|
||||||
|
| `38678a2` | **KaizenSemanticColors** — extracted ~25 hardcoded `Color(0xFF...)` into semantic tokens (error, success, indigo, link, dropdown). Single source of truth in `Color.kt` |
|
||||||
|
| `0f6e55d` | **NetworkImageCache upgrade** — disk cache (`cacheDir/img`), shared `KaizenApi.imageClient` (reuses connection pool), 32MB size-based memory limit, Crossfade animation on load |
|
||||||
|
| `3c6a891` | **Custom 1.0px icons** — `KaizenCustomIcons.kt` with hand-drawn Menu, Plus, Mic, AudioLines, SlidersHorizontal, Globe, Image. Thinner stroke matches W300 typography |
|
||||||
|
| `ad98b9f` | **App icon + splash redesign** — translucent glass-lens orb (was opaque plastic "Spiegelei"). Neutral backgrounds matching app colors |
|
||||||
|
| `4958ca8` | **Single-row ChatInput** — Gemini-style capsule: Plus \| TextField \| Tune \| Mic \| Send/Call. Mode pills hidden by default, expandable via tune icon inside the same card. New Chat button (PenLine) in top bar |
|
||||||
|
| `1c9491e` | **Text selection** — `SelectionContainer` on user + assistant messages. Long-press to select, copy, share |
|
||||||
|
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
|
||||||
|
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
|
||||||
|
|
||||||
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
||||||
- Liquid glass styling, floating panels, glassmorphic sidebar
|
- Liquid glass styling, floating panels, glassmorphic sidebar
|
||||||
|
|
@ -286,7 +299,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
- Mesh gradient background with dithered blobs
|
- Mesh gradient background with dithered blobs
|
||||||
- Phase-aware haptics (click → thinkingStart → responseStart → responseEnd)
|
- Phase-aware haptics (click → thinkingStart → responseStart → responseEnd)
|
||||||
- Tablet/landscape optimization (width capping)
|
- 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.
|
- Dynamic keyboard handling: `WindowInsets.ime` offset (NOT `imePadding()` — that inflates the dock; NOT `adjustResize` — hides the dock with edge-to-edge). `windowSoftInputMode=adjustNothing` in manifest.
|
||||||
- Settings screen with SettingsViewModel
|
- Settings screen with SettingsViewModel
|
||||||
|
|
||||||
### Bugs fixed (2026-06-19/20/21)
|
### Bugs fixed (2026-06-19/20/21)
|
||||||
|
|
@ -337,22 +350,44 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
- **No edit/resend for user messages** — only copy + delete were available. Fix: SquarePen button removes message + successors, puts text back in input.
|
- **No edit/resend for user messages** — only copy + delete were available. Fix: SquarePen button removes message + successors, puts text back in input.
|
||||||
- **`webSearchProvider` not sent by app** — Vertex's Google vs Enterprise search toggle was missing. Fix: added `webSearchProvider` to `ChatRequest`, dropdown pill in mode pills row (only visible for `vertex:` models with search active).
|
- **`webSearchProvider` not sent by app** — Vertex's Google vs Enterprise search toggle was missing. Fix: added `webSearchProvider` to `ChatRequest`, dropdown pill in mode pills row (only visible for `vertex:` models with search active).
|
||||||
|
|
||||||
|
### Visual refresh + UX overhaul (2026-06-23 — design session)
|
||||||
|
|
||||||
|
- **Typography too heavy** — W400 body everywhere looked dense. Fix: W300 for body/input/hero, W600/W500 for headings (inspired by Gemini's Neural Expressive thinner Roboto Flex).
|
||||||
|
- **Headings too small** — H1 22sp, H2 19sp, H3 17sp. Fix: H1 26sp, H2 22sp, H3 18sp with proper line-height.
|
||||||
|
- **Messages pop in without animation** — no entry/exit transition. Fix: `animateItem()` on LazyColumn (350ms fade-in, spring placement, 200ms fade-out).
|
||||||
|
- **Themes washed out / blobs too small** — bottom half of screen had no color, light mode barely visible. Fix: blobs 20% larger (400-460dp), better full-screen distribution, higher alpha (0.48 peak), light factor 0.65→0.78, `primaryDeep` distinct per theme.
|
||||||
|
- **Hardcoded colors scattered** — ~25 `Color(0xFF...)` across 10 files. Fix: `KaizenSemanticColors` object in `Color.kt`.
|
||||||
|
- **Image cache had no disk persistence** — images re-downloaded every app restart. Fix: file-based disk cache in `cacheDir/img`, shared `KaizenApi.imageClient`, 32MB size-based memory limit, Crossfade on load.
|
||||||
|
- **App icon still old "Spiegelei"** — opaque plastic orb didn't match glass-lens in-app orb. Fix: redesigned to translucent glass-lens with neutral backgrounds.
|
||||||
|
- **Mode pills floating separately above input** — two disconnected UI elements, ugly scrim. Fix: pills moved inside ChatInput card, hidden by default, expandable via tune icon.
|
||||||
|
- **ChatInput was two-row layout** — wasted vertical space. Fix: single-row Gemini-style capsule (64dp, 34dp radius, level4 shadow).
|
||||||
|
- **No "New Chat" button** — only accessible via sidebar. Fix: PenLine icon in top bar.
|
||||||
|
- **Text not selectable** — couldn't select/copy text from messages. Fix: `SelectionContainer` on user + assistant messages.
|
||||||
|
- **Sidebar profile was heavy card** — background, border, shadow. Fix: flat row with thin separator.
|
||||||
|
- **Custom icons too thick** — Lucide 1.5px at 16-19dp looked chunky next to W300 text. Fix: 7 custom `ImageVector` icons at 1.0px stroke in `KaizenCustomIcons.kt`.
|
||||||
|
|
||||||
## What's NOT done yet (app, priority order)
|
## What's NOT done yet (app, priority order)
|
||||||
|
|
||||||
### 1. Remaining visual/UX gaps (daily-drive blockers)
|
### 1. Remaining visual/UX gaps (daily-drive blockers)
|
||||||
|
|
||||||
These are what separate "prototype" from "daily-driver":
|
All done:
|
||||||
|
|
||||||
- [x] **Markdown rendering during streaming** — all regex patterns pre-compiled as top-level constants, `remember(text)` memoization. Verified performant
|
- [x] **Markdown rendering during streaming** — all regex patterns pre-compiled as top-level constants, `remember(text)` memoization. Verified performant
|
||||||
- [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback)
|
- [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback)
|
||||||
- [x] **Horizontal rule / blockquote rendering** — `---` and `> quote` parsed and rendered
|
- [x] **Horizontal rule / blockquote rendering** — `---` and `> quote` parsed and rendered
|
||||||
- [x] **Table rendering** — `| header | header |` with separator detection, styled borders, header background, horizontal scroll for wide tables, inline markdown in cells
|
- [x] **Table rendering** — `| header | header |` with separator detection, styled borders, header background, horizontal scroll for wide tables, inline markdown in cells
|
||||||
- [x] **Link rendering** — clickable via `ClickableText` + `ACTION_VIEW`, opens browser on tap
|
- [x] **Link rendering** — clickable via `LinkAnnotation.Url` + `LocalUriHandler`, opens browser on tap
|
||||||
- [x] **Strikethrough** — `~~text~~` rendered with line-through + dimmed alpha
|
- [x] **Strikethrough** — `~~text~~` rendered with line-through + dimmed alpha
|
||||||
- [x] **Task lists** — `- [ ]` / `- [x]` with Check/Square icons in accent color
|
- [x] **Task lists** — `- [ ]` / `- [x]` with Check/Square icons in accent color
|
||||||
|
- [x] **Text selection** — `SelectionContainer` on user + assistant messages, long-press to select/copy/share
|
||||||
|
- [x] **Single-row ChatInput** — Gemini-style capsule (64dp, pill shape), mode pills inside card via tune icon
|
||||||
|
- [x] **New Chat button** — PenLine icon in top bar
|
||||||
|
- [x] **Visual refresh** — W300 typography, larger headings, message animations, richer themes/blobs, semantic colors, custom 1.0px icons, glass-lens app icon
|
||||||
|
|
||||||
### 2. Conversation management from the app
|
### 2. Conversation management from the app
|
||||||
|
|
||||||
|
All done:
|
||||||
|
|
||||||
- [x] **Conversation rename/delete** from the sidebar — 3-dot menu on each chat, `PATCH`/`DELETE /api/v1/conversations/[id]`
|
- [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] **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
|
- [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
|
||||||
|
|
@ -360,7 +395,7 @@ These are what separate "prototype" from "daily-driver":
|
||||||
### 3. Security hardening
|
### 3. Security hardening
|
||||||
|
|
||||||
- [x] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner
|
- [x] **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] **Server version check** — calls `GET /api/v1/meta` after login + on each foreground, compares `apiVersion` against `EXPECTED_API_VERSION`, shows dismissible amber warning banner on skew
|
||||||
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — hardware-enforced, password fallback dialog when unavailable, biometric toggle in Settings, all unlock paths gated
|
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — hardware-enforced, password fallback dialog when unavailable, biometric toggle in Settings, all unlock paths gated
|
||||||
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
|
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
|
||||||
- [x] **Password change** — `POST /api/v1/auth/change-password` (Argon2id, rate-limited)
|
- [x] **Password change** — `POST /api/v1/auth/change-password` (Argon2id, rate-limited)
|
||||||
|
|
@ -414,7 +449,7 @@ The web's call mode (`use-call-mode.ts`, `call-view.tsx`) is the reference imple
|
||||||
- [ ] **Stale-check enforcement** — use `cachedAt` to trigger background refresh when older than N minutes
|
- [ ] **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)
|
- [ ] **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)
|
- [ ] **Incremental sync** — only fetch changed conversations (needs server-side `since` parameter)
|
||||||
- [ ] **Media cache** — cache images/files locally (Coil or custom disk cache)
|
- [x] **Media cache** — `NetworkImageCache` with file-based disk cache (`cacheDir/img`), 32MB size-based memory LRU, shared `KaizenApi.imageClient`
|
||||||
- [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively)
|
- [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively)
|
||||||
- [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB
|
- [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,7 @@ fun ChatScreen(
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
loadError = errors.joinToString(" · ")
|
loadError = errors.joinToString(" · ")
|
||||||
}
|
}
|
||||||
|
scope.launch { session.checkServerVersion(cfg.baseUrl) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val ids = chat.conversationRepo.allUnlockedIds()
|
val ids = chat.conversationRepo.allUnlockedIds()
|
||||||
chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids)
|
chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids)
|
||||||
|
|
@ -1071,6 +1072,30 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version skew warning banner
|
||||||
|
val skew = session.versionSkew
|
||||||
|
if (skew != null) {
|
||||||
|
val warningColor = Color(0xFFD97706)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = if (loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp)
|
||||||
|
.clip(KaizenShapes.sm)
|
||||||
|
.background(warningColor.copy(alpha = 0.15f))
|
||||||
|
.border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm)
|
||||||
|
.clickable { session.dismissVersionWarning() }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion),
|
||||||
|
color = warningColor,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Floating Bottom Control Dock
|
// 3. Floating Bottom Control Dock
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
val imeVisible = WindowInsets.isImeVisible
|
val imeVisible = WindowInsets.isImeVisible
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package dev.kaizen.app.chat
|
package dev.kaizen.app.chat
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.ClickableText
|
|
||||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -38,8 +35,9 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
|
@ -204,8 +202,6 @@ private fun parseBlocks(src: String): List<MdBlock> {
|
||||||
|
|
||||||
// ── Inline markdown ────────────────────────────────────────────────────────────
|
// ── Inline markdown ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val LINK_TAG = "URL"
|
|
||||||
|
|
||||||
private fun inlineMarkdown(
|
private fun inlineMarkdown(
|
||||||
text: String,
|
text: String,
|
||||||
baseColor: Color,
|
baseColor: Color,
|
||||||
|
|
@ -225,10 +221,8 @@ private fun inlineMarkdown(
|
||||||
if (closeParen > closeBracket) {
|
if (closeParen > closeBracket) {
|
||||||
val linkText = src.substring(pos + 1, closeBracket)
|
val linkText = src.substring(pos + 1, closeBracket)
|
||||||
val url = src.substring(closeBracket + 2, closeParen)
|
val url = src.substring(closeBracket + 2, closeParen)
|
||||||
pushStringAnnotation(tag = LINK_TAG, annotation = url)
|
pushLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline))))
|
||||||
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
|
append(linkText)
|
||||||
append(linkText)
|
|
||||||
}
|
|
||||||
pop()
|
pop()
|
||||||
pos = closeParen + 1
|
pos = closeParen + 1
|
||||||
continue
|
continue
|
||||||
|
|
@ -440,7 +434,6 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
|
||||||
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
|
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
|
||||||
val blocks = remember(text) { parseBlocks(text) }
|
val blocks = remember(text) { parseBlocks(text) }
|
||||||
|
|
||||||
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6)
|
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6)
|
||||||
|
|
@ -461,7 +454,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
||||||
is MdBlock.Heading -> {
|
is MdBlock.Heading -> {
|
||||||
if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp))
|
if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp))
|
||||||
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
|
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
|
||||||
ClickableMarkdownText(rendered, context, cs.onBackground,
|
ClickableMarkdownText(rendered, cs.onBackground,
|
||||||
fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp },
|
fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp },
|
||||||
fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 },
|
fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 },
|
||||||
lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp },
|
lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp },
|
||||||
|
|
@ -593,7 +586,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
||||||
}
|
}
|
||||||
val appendCursor = isLast && itemIdx == block.items.lastIndex
|
val appendCursor = isLast && itemIdx == block.items.lastIndex
|
||||||
val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
|
val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
|
||||||
ClickableMarkdownText(rendered, context, cs.onBackground,
|
ClickableMarkdownText(rendered, cs.onBackground,
|
||||||
fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
|
fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
@ -630,7 +623,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor)
|
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor)
|
||||||
ClickableMarkdownText(rendered, context, cs.onSurfaceVariant,
|
ClickableMarkdownText(rendered, cs.onSurfaceVariant,
|
||||||
fontSize = 15.sp, lineHeight = 24.sp,
|
fontSize = 15.sp, lineHeight = 24.sp,
|
||||||
fontStyle = FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
@ -642,7 +635,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
||||||
is MdBlock.Paragraph -> {
|
is MdBlock.Paragraph -> {
|
||||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||||
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
|
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
|
||||||
ClickableMarkdownText(rendered, context, cs.onBackground,
|
ClickableMarkdownText(rendered, cs.onBackground,
|
||||||
fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
|
fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -653,12 +646,11 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clickable text (handles link taps) ─────────────────────────────────────────
|
// ── Clickable text (handles link taps via LinkAnnotation + LocalUriHandler) ────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ClickableMarkdownText(
|
private fun ClickableMarkdownText(
|
||||||
text: AnnotatedString,
|
text: AnnotatedString,
|
||||||
context: android.content.Context,
|
|
||||||
color: Color,
|
color: Color,
|
||||||
fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
|
fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
|
||||||
lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp,
|
lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp,
|
||||||
|
|
@ -666,37 +658,15 @@ private fun ClickableMarkdownText(
|
||||||
fontStyle: FontStyle? = null,
|
fontStyle: FontStyle? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hasLinks = text.getStringAnnotations(LINK_TAG, 0, text.length).isNotEmpty()
|
Text(
|
||||||
if (hasLinks) {
|
text = text,
|
||||||
ClickableText(
|
color = color,
|
||||||
text = text,
|
fontSize = fontSize,
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
lineHeight = lineHeight,
|
||||||
color = color,
|
fontWeight = fontWeight,
|
||||||
fontSize = fontSize,
|
fontStyle = fontStyle,
|
||||||
lineHeight = lineHeight,
|
modifier = modifier,
|
||||||
fontWeight = fontWeight,
|
)
|
||||||
fontStyle = fontStyle,
|
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
onClick = { offset ->
|
|
||||||
text.getStringAnnotations(LINK_TAG, offset, offset).firstOrNull()?.let { ann ->
|
|
||||||
try {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ann.item)))
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
color = color,
|
|
||||||
fontSize = fontSize,
|
|
||||||
lineHeight = lineHeight,
|
|
||||||
fontWeight = fontWeight,
|
|
||||||
fontStyle = fontStyle,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Copy button ────────────────────────────────────────────────────────────────
|
// ── Copy button ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -611,6 +611,19 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMeta(baseUrl: String): MetaResponse? = withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return@use null
|
||||||
|
json.decodeFromString<MetaResponse>(resp.body!!.string())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "fetchMeta: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
|
suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
|
val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
|
||||||
|
|
@ -678,4 +691,10 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable data class MetaResponse(
|
||||||
|
val app: String = "",
|
||||||
|
val apiVersion: Int = 0,
|
||||||
|
val features: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable private data class ErrorBody(val error: String = "unknown")
|
@Serializable private data class ErrorBody(val error: String = "unknown")
|
||||||
|
|
|
||||||
|
|
@ -37,5 +37,8 @@ const val DEFAULT_BASE_URL = "https://ask.kryptomrx.de"
|
||||||
/** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */
|
/** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */
|
||||||
const val DEFAULT_MODEL = "vertex:gemini-2.5-flash"
|
const val DEFAULT_MODEL = "vertex:gemini-2.5-flash"
|
||||||
|
|
||||||
|
/** The v1 API version this app build expects from the server. */
|
||||||
|
const val EXPECTED_API_VERSION = 1
|
||||||
|
|
||||||
/** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */
|
/** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */
|
||||||
fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/')
|
fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/')
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import androidx.compose.runtime.setValue
|
||||||
*
|
*
|
||||||
* Held at Activity scope (instantiated directly like SettingsViewModel) — no DI yet.
|
* Held at Activity scope (instantiated directly like SettingsViewModel) — no DI yet.
|
||||||
*/
|
*/
|
||||||
|
data class VersionSkew(val serverVersion: Int, val expectedVersion: Int)
|
||||||
|
|
||||||
class SessionViewModel(val store: SecureStore) {
|
class SessionViewModel(val store: SecureStore) {
|
||||||
|
|
||||||
var config by mutableStateOf(store.load())
|
var config by mutableStateOf(store.load())
|
||||||
|
|
@ -21,8 +23,13 @@ class SessionViewModel(val store: SecureStore) {
|
||||||
var favorites by mutableStateOf(store.loadFavorites())
|
var favorites by mutableStateOf(store.loadFavorites())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var versionSkew by mutableStateOf<VersionSkew?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
val isLoggedIn: Boolean get() = config != null
|
val isLoggedIn: Boolean get() = config != null
|
||||||
|
|
||||||
|
fun dismissVersionWarning() { versionSkew = null }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-app login: mint a token, then resolve the user's default model from the
|
* In-app login: mint a token, then resolve the user's default model from the
|
||||||
* server (falling back to [DEFAULT_MODEL]). On success the config is persisted
|
* server (falling back to [DEFAULT_MODEL]). On success the config is persisted
|
||||||
|
|
@ -36,12 +43,22 @@ class SessionViewModel(val store: SecureStore) {
|
||||||
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
|
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
|
||||||
store.save(cfg)
|
store.save(cfg)
|
||||||
config = cfg
|
config = cfg
|
||||||
|
checkServerVersion(baseUrl)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
else -> result
|
else -> result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun checkServerVersion(baseUrl: String) {
|
||||||
|
val meta = KaizenApi.fetchMeta(baseUrl) ?: return
|
||||||
|
if (meta.apiVersion != EXPECTED_API_VERSION) {
|
||||||
|
versionSkew = VersionSkew(meta.apiVersion, EXPECTED_API_VERSION)
|
||||||
|
} else {
|
||||||
|
versionSkew = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Switch the chat model (persisted). No-op if not logged in. */
|
/** Switch the chat model (persisted). No-op if not logged in. */
|
||||||
fun setModel(modelId: String) {
|
fun setModel(modelId: String) {
|
||||||
val cfg = config ?: return
|
val cfg = config ?: return
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
<string name="error_rate_limited">Too many requests. Please wait.</string>
|
<string name="error_rate_limited">Too many requests. Please wait.</string>
|
||||||
<string name="error_server">Server error (%1$d).</string>
|
<string name="error_server">Server error (%1$d).</string>
|
||||||
<string name="error_no_connection">No connection to server.</string>
|
<string name="error_no_connection">No connection to server.</string>
|
||||||
|
<string name="error_version_skew">Server API v%1$d — app expects v%2$d. Please update server or app.</string>
|
||||||
|
|
||||||
<!-- Mode pills -->
|
<!-- Mode pills -->
|
||||||
<string name="mode_standard">Standard</string>
|
<string name="mode_standard">Standard</string>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
<string name="error_rate_limited">Zu viele Anfragen. Kurz warten.</string>
|
<string name="error_rate_limited">Zu viele Anfragen. Kurz warten.</string>
|
||||||
<string name="error_server">Fehler vom Server (%1$d).</string>
|
<string name="error_server">Fehler vom Server (%1$d).</string>
|
||||||
<string name="error_no_connection">Keine Verbindung zum Server.</string>
|
<string name="error_no_connection">Keine Verbindung zum Server.</string>
|
||||||
|
<string name="error_version_skew">Server API v%1$d — App erwartet v%2$d. Bitte Server oder App aktualisieren.</string>
|
||||||
|
|
||||||
<!-- Mode pills -->
|
<!-- Mode pills -->
|
||||||
<string name="mode_standard">Standard</string>
|
<string name="mode_standard">Standard</string>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue