feat: wire chat to the real Kaizen backend (in-app login + streaming)

Replaces the Phase-0 fakeReply mock with the real v1 API. Deployment-agnostic:
one binary, one Authorization: Bearer contract, server-URL field switches between
the official instance and any self-host.

- auth/LoginScreen: server-URL + email + password -> POST /api/v1/auth/token
- net/SecureStore: kzn_ token in EncryptedSharedPreferences (KeyStore AES-256-GCM)
- net/SessionViewModel: snapshot-state login/logout routing, restores on launch
- net/KaizenApi: POST /api/v1/chat streaming + GET /api/v1/me for the default model
- net/StreamConsumer: Kotlin port of lib/chat/stream-consumer.ts (JVM-unit-tested)
- deps: okhttp, kotlinx-serialization, security-crypto; INTERNET permission

Code-complete; not yet built/run on device (no Android SDK on the dev Mac).
This commit is contained in:
Bruno Deanoz 2026-06-19 01:04:55 +02:00
parent 8a527451d7
commit 44c17b6f9f
16 changed files with 635 additions and 73 deletions

View file

@ -10,7 +10,11 @@ To maintain clean execution and avoid technical debt, backend-dependent features
When transitioning from the Web PWA to the native Android App, we can leverage Android OS-level hardware security that is completely unavailable to standard webbrowsers.
### A. Android KeyStore System (API 23+)
### A. Android KeyStore System (API 23+) — ✅ DONE (Phase 1)
> Implemented in `net/SecureStore.kt`: the `kzn_` bearer token is stored in
> `EncryptedSharedPreferences` (AES-256-GCM, KeyStore-backed). Only this item
> graduated — biometrics binding (§C) and `FLAG_SECURE` (§B) stay deferred.
* **Concept:** Instead of storing API keys or session tokens in plain-text `SharedPreferences` (which are readable on rooted devices), we will use Android's `EncryptedSharedPreferences`.
* **Mechanism:**
- Uses the **hardware-backed KeyStore** (StrongBox / Secure Enclave on flagship devices like the S25 Ultra or Google Pixel 10).

View file

@ -2,33 +2,45 @@
Native Android-App für Kaizen (Jetpack Compose + Kotlin). Kein WebView, kein Tauri.
## Phase 0 — Feel-Prototyp
## Phase 1 — echtes Backend (aktiv)
Ein einziger Chat-Screen, der das **Gefühl** treffen soll (snappy, Design, Haptik) —
**noch ohne Backend**. Die KI-Antworten sind ein gefakter Stream (`fakeReply`), damit
das Feel ohne Token-Auth/SSE testbar ist.
Der Feel-Prototyp redet jetzt mit dem **echten Kaizen-Backend** — echte, gestreamte
Antworten statt `fakeReply`. Die App ist **deployment-agnostisch**: ein Binary, das auf
die offizielle Instanz ODER jeden Self-Host zeigt, über einen einheitlichen
`Authorization: Bearer`-Contract. SaaS vs. Self-Host ist nur „welche Base-URL".
Drin:
- Obsidian/Amber-Design + Mesh-/Blob-Hintergrund (`Background.kt`)
- Liquid-Glass-`KaizenOrb` (Hero + Assistant-Avatar, atmet, schneller beim Streamen)
- Empty-Hero mit Begrüßung + Vorschlags-Kacheln
- Mock-Streaming Wort für Wort mit natürlichem Rhythmus
- **Haptik** (`haptics/Haptics.kt`): Klick beim Senden, „Rise" wenn die Antwort kommt,
sanftes Absetzen am Ende, Tick bei Kachel-Auswahl — feine Composition-Primitives
auf API 30+, mit Fallbacks darunter.
- **In-App-Login** (`auth/LoginScreen.kt`): Server-URL (Default = offizielle Instanz,
editierbar = die „offiziell/Self-Host"-Adresse) + E-Mail + Passwort →
`POST {baseUrl}/api/v1/auth/token``kzn_`-Token. Kein Token-Copy-Paste.
- **Sichere Token-Ablage** (`net/SecureStore.kt`): `EncryptedSharedPreferences`
(AES-256-GCM, Schlüssel im Android-KeyStore/StrongBox) — der Token liegt nie im
Klartext, auch nicht auf gerooteten Geräten.
- **Modell vom Server** (`net/SessionViewModel.kt`): nach dem Login holt die App per
`GET /api/v1/me` das vom Nutzer unter `/settings/models` gewählte Default-Modell —
kein hartgecodetes Modell.
- **Echtes Streaming** (`net/KaizenApi.kt` + `net/StreamConsumer.kt`): `POST /api/v1/chat`,
liest den `text/plain`-Chunked-Stream inkrementell und strippt die Sentinels
(Port von `lib/chat/stream-consumer.ts` aus dem Backend, JVM-unit-getestet).
- Design + Haptik + Orb unverändert — **nur die Datenquelle hat sich geändert**.
Bewusst **noch nicht** drin: Backend, Auth, Settings, Bild/Video, Local LLM.
Bewusst **noch nicht** drin (siehe `LATER.md`): Conversation-History vom Server,
Passkey-Login nativ, Bild/Video, Biometric-Lock, WebSocket-Streaming.
## Bauen & laufen
> **Wichtig:** Auf dem **echten S25 Ultra** laufen lassen, nicht im Emulator —
> der Emulator kann keine echte Haptik, und genau die wollen wir fühlen.
1. Projekt in Android Studio öffnen, Gradle syncen lassen.
2. S25 Ultra per **Wireless Debugging** (Einstellungen → Entwickleroptionen) oder USB verbinden.
3. Run ▶ auf das Gerät.
1. Projekt in Android Studio öffnen, **Gradle syncen** (neue Deps: OkHttp,
kotlinx-serialization, security-crypto + INTERNET-Permission).
2. S25 Ultra per **Wireless Debugging** oder USB verbinden.
3. Run ▶ auf das Gerät → Login-Screen erscheint. Server-URL prüfen, einloggen.
`DEFAULT_BASE_URL` (`net/ServerConfig.kt`) ist die vorausgefüllte Instanz-Adresse —
bei Domainwechsel hier anpassen.
## Der Test
Eine Woche nutzen. Geht der Finger anfangen hierher statt zu Gemini → wir bauen voll aus
(Token-Auth + echtes SSE-Streaming = Phase 1). Wenn nicht → wir wissen billig, woran's liegt.
Eine Woche nutzen. Geht der Finger anfangen hierher statt zu Gemini → wir bauen voll
aus (History, Passkey, Medien = Phase 2). Wenn nicht → wir wissen billig, woran's liegt.

View file

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
@ -49,6 +50,11 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
// Networking + JSON for the real backend (POST /api/v1/auth/token, /chat, /me)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
// Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences)
implementation(libs.androidx.security.crypto)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -5,6 +5,9 @@
<!-- Required for haptic feedback (Vibrator.vibrate) -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Required to talk to the Kaizen backend (login + chat streaming) -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View file

@ -8,7 +8,10 @@ import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.ui.theme.KaizenTheme
@ -25,8 +28,10 @@ class MainActivity : ComponentActivity() {
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
}
// Initialize the decoupled viewmodel at the Activity scope (or Hilt/Dagger later)
// Initialize the decoupled viewmodels at the Activity scope (or Hilt/Dagger later)
val settingsViewModel = SettingsViewModel()
// Restores the persisted (encrypted) server config — drives login vs. chat routing
val sessionViewModel = SessionViewModel(SecureStore(applicationContext))
// auto() picks light/dark system-bar icons based on the system theme
enableEdgeToEdge(
@ -35,7 +40,13 @@ class MainActivity : ComponentActivity() {
)
setContent {
KaizenTheme {
ChatScreen(settingsViewModel = settingsViewModel)
// Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) {
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel)
} else {
LoginScreen(session = sessionViewModel)
}
}
}
}

View file

@ -0,0 +1,237 @@
package dev.kaizen.app.auth
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.chat.KaizenOrb
import dev.kaizen.app.chat.MeshBackground
import dev.kaizen.app.net.DEFAULT_BASE_URL
import dev.kaizen.app.net.LoginResult
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.ui.theme.Amber
import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.OnAmber
import kotlinx.coroutines.launch
/**
* In-app sign-in (consumer "Sign in" pattern, not token copy-paste). The server-URL
* field IS the "official / self-host address" default points at the official
* instance, edit it to authenticate against any self-host. On success the session
* flips and the app routes into the chat.
*/
@Composable
fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
var baseUrl by remember { mutableStateOf(DEFAULT_BASE_URL) }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val deviceName = remember { Build.MODEL ?: "Android" }
val canSubmit = baseUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() && !loading
fun submit() {
if (!canSubmit) return
loading = true
error = null
scope.launch {
when (val result = session.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> Unit // session.config flips → MainActivity routes to chat
LoginResult.InvalidCredentials -> error = "E-Mail oder Passwort ist falsch."
LoginResult.RateLimited -> error = "Zu viele Versuche. Bitte kurz warten."
is LoginResult.Error -> error = "Verbindung fehlgeschlagen. Server-Adresse prüfen."
}
loading = false
}
}
MeshBackground {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Spacer(Modifier.size(64.dp))
KaizenOrb(96.dp)
Spacer(Modifier.size(24.dp))
Text("Willkommen", color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.size(6.dp))
Text(
"Melde dich bei deiner Kaizen-Instanz an",
color = cs.onSurfaceVariant.copy(alpha = 0.8f),
fontSize = 14.sp,
)
Spacer(Modifier.size(36.dp))
GlassField(
value = baseUrl,
onValueChange = { baseUrl = it; error = null },
placeholder = "https://deine-instanz.de",
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
)
Spacer(Modifier.size(12.dp))
GlassField(
value = email,
onValueChange = { email = it; error = null },
placeholder = "E-Mail",
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
)
Spacer(Modifier.size(12.dp))
GlassField(
value = password,
onValueChange = { password = it; error = null },
placeholder = "Passwort",
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
password = true,
onImeDone = { submit() },
)
if (error != null) {
Spacer(Modifier.size(14.dp))
Text(error!!, color = Color(0xFFEF4444), fontSize = 13.sp)
}
Spacer(Modifier.size(24.dp))
SignInButton(enabled = canSubmit, loading = loading, onClick = { submit() })
Spacer(Modifier.size(80.dp))
}
}
}
@Composable
private fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
keyboardType: KeyboardType,
imeAction: ImeAction,
password: Boolean = false,
onImeDone: () -> Unit = {},
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val glassBg = if (isDark) {
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.72f), Color(0xFF0F172A).copy(alpha = 0.85f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.90f), Color(0xFFEEF2F6).copy(alpha = 0.80f)))
}
val glassBorder = if (isDark) {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.04f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.60f), Color.Black.copy(alpha = 0.06f)))
}
Box(
Modifier
.fillMaxWidth()
.widthIn(max = 420.dp)
.shadow(elevation = 6.dp, shape = RoundedCornerShape(20.dp), clip = false)
.clip(RoundedCornerShape(20.dp))
.background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(20.dp))
.heightIn(min = 54.dp)
.padding(horizontal = 18.dp, vertical = 16.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) {
Text(placeholder, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 16.sp)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp),
cursorBrush = SolidColor(Amber),
singleLine = true,
visualTransformation = if (password) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onImeDone() }),
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme
val fill = if (enabled || loading) {
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
} else {
Modifier.background(cs.surface)
}
Box(
Modifier
.fillMaxWidth()
.widthIn(max = 420.dp)
.heightIn(min = 54.dp)
.shadow(elevation = 8.dp, shape = RoundedCornerShape(20.dp), clip = false)
.clip(RoundedCornerShape(20.dp))
.then(fill)
.clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
if (loading) {
CircularProgressIndicator(color = OnAmber, strokeWidth = 2.4.dp, modifier = Modifier.size(22.dp))
} else {
Text(
"Anmelden",
color = if (enabled) OnAmber else cs.onSurfaceVariant,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}

View file

@ -9,10 +9,6 @@ import androidx.compose.material.icons.rounded.Movie
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.random.Random
enum class Role { User, Assistant }
@ -52,35 +48,3 @@ fun greeting(hour: Int): String = when (hour) {
in 18..22 -> "Guten Abend"
else -> "Hallo"
}
// --- Phase 0: faked thinking + stream so the feel is testable without a backend ---
/** Variable "thinking" time before the answer streams — real models think for different durations. */
fun thinkingDurationMs(): Long = Random.nextLong(700L, 2600L)
private val cannedReplies = listOf(
"Klar, das kriegen wir hin. Lass uns das Schritt für Schritt durchgehen: " +
"Zuerst schauen wir uns das Ziel an, dann sammeln wir die wichtigsten Punkte und " +
"bringen sie in eine sinnvolle Reihenfolge. Sag mir einfach, wo du starten willst.",
"Gute Frage. Im Kern geht es darum, das Wesentliche vom Rauschen zu trennen. " +
"Ich würde es so angehen: erst die Grundidee in einem Satz, dann zwei, drei " +
"konkrete Beispiele, und am Ende ein kurzes Fazit, das alles zusammenhält.",
"Mach ich. Hier ist ein erster Entwurf, den wir danach gemeinsam schärfen können. " +
"Er ist bewusst kompakt gehalten — sag mir, ob der Ton passt oder ob ich es " +
"lockerer, formeller oder ausführlicher formulieren soll.",
)
fun fakeReply(prompt: String): Flow<String> = flow {
val reply = cannedReplies[Random.nextInt(cannedReplies.size)]
val tokens = tokenize(reply)
for (token in tokens) {
emit(token)
val base = Random.nextLong(26L, 64L)
// small pause after punctuation -> more natural rhythm
val pause = if (token.endsWith(". ") || token.endsWith(": ")) base + 130L else base
delay(pause)
}
}
private fun tokenize(text: String): List<String> =
text.split(" ").mapIndexed { i, word -> if (i == 0) word else " $word" }

View file

@ -48,9 +48,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
@ -59,6 +62,7 @@ enum class AppScreen { Chat, Settings }
@Composable
fun ChatScreen(
settingsViewModel: SettingsViewModel,
session: SessionViewModel,
modifier: Modifier = Modifier
) {
val haptics = rememberHaptics()
@ -86,32 +90,48 @@ fun ChatScreen(
fun send(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty() || isStreaming) return
val cfg = session.config ?: return
haptics.click()
messages.add(Message(nextId++, Role.User, trimmed))
input = ""
val assistantId = nextId++
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
isStreaming = true
// Wire history = the visible conversation minus the streaming placeholder.
val history = messages
.filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
scope.launch {
// Thinking phase — variable, like a real model
haptics.thinkingStart()
delay(thinkingDurationMs())
var first = true
fakeReply(trimmed).collect { chunk ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
messages[idx] = if (first) {
first = false
haptics.responseStart() // the answer begins
messages[idx].copy(thinking = false, content = chunk)
} else {
messages[idx].copy(content = messages[idx].content + chunk)
var sawContent = false
try {
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history).collect { visible ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) {
sawContent = true
haptics.responseStart() // the answer begins (reasoning may have streamed first)
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
content = visible,
)
}
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
} catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) messages[idx] = messages[idx].copy(
streaming = false,
thinking = false,
content = chatErrorText(e),
)
} finally {
isStreaming = false
haptics.responseEnd()
}
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
isStreaming = false
haptics.responseEnd()
}
}
@ -147,6 +167,12 @@ fun ChatScreen(
haptics.tick()
currentScreen = AppScreen.Settings
scope.launch { drawerState.close() }
},
onLogout = {
haptics.tick()
messages.clear()
scope.launch { drawerState.close() }
session.logout()
}
)
},
@ -240,3 +266,12 @@ fun ChatScreen(
}
}
}
/** Maps a streaming failure to a short, human German message shown in the bubble. */
private fun chatErrorText(e: Throwable): String = when {
e is ChatHttpException && e.code == 401 -> "Sitzung abgelaufen. Bitte erneut anmelden."
e is ChatHttpException && e.code == 402 -> "Guthaben aufgebraucht."
e is ChatHttpException && e.code == 429 -> "Zu viele Anfragen. Kurz warten."
e is ChatHttpException -> "Fehler vom Server (${e.code})."
else -> "Keine Verbindung zum Server."
}

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Search
@ -87,6 +88,7 @@ fun KaizenSidebar(
onClose: () -> Unit,
onNewChat: () -> Unit,
onOpenSettings: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
) {
val cs = MaterialTheme.colorScheme
@ -288,6 +290,32 @@ fun KaizenSidebar(
modifier = Modifier.size(20.dp)
)
}
Spacer(Modifier.height(10.dp))
// --- Logout: clears the encrypted token + returns to the login screen ---
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable { onLogout() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Logout,
contentDescription = "Abmelden",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(10.dp))
Text(
text = "Abmelden",
color = cs.onSurfaceVariant.copy(alpha = 0.85f),
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}

View file

@ -0,0 +1,125 @@
package dev.kaizen.app.net
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.TimeUnit
// --- Wire DTOs (additive v1 contract — ignoreUnknownKeys tolerates new fields) ---
@Serializable private data class TokenRequest(val email: String, val password: String, val name: String)
@Serializable private data class TokenResponse(val token: String)
@Serializable private data class MeResponse(val defaultModel: String? = null)
@Serializable data class WireMessage(val role: String, val content: String)
@Serializable private data class ChatRequest(val model: String, val messages: List<WireMessage>)
/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
sealed interface LoginResult {
data class Success(val token: String) : LoginResult
data object InvalidCredentials : LoginResult // 401
data object RateLimited : LoginResult // 429
data class Error(val message: String) : LoginResult
}
/** Thrown by [KaizenApi.chat] when the backend rejects the request before streaming. */
class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code")
/**
* Thin client over the Kaizen v1 API. One uniform `Authorization: Bearer` contract,
* same against the official server or any self-host. The chat response is a
* `text/plain` chunked stream (NOT SSE) parsed incrementally by [StreamConsumer].
*/
object KaizenApi {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
.build()
private val json = Json { ignoreUnknownKeys = true }
private val jsonMedia = "application/json".toMediaType()
/** POST /api/v1/auth/token — exchange email+password for a `kzn_` bearer token. */
suspend fun login(baseUrl: String, email: String, password: String, deviceName: String): LoginResult =
withContext(Dispatchers.IO) {
val body = json.encodeToString(TokenRequest(email, password, deviceName)).toRequestBody(jsonMedia)
val req = Request.Builder().url("$baseUrl/api/v1/auth/token").post(body).build()
try {
client.newCall(req).execute().use { resp ->
when {
resp.isSuccessful -> {
val token = json.decodeFromString<TokenResponse>(resp.body!!.string()).token
LoginResult.Success(token)
}
resp.code == 401 -> LoginResult.InvalidCredentials
resp.code == 429 -> LoginResult.RateLimited
else -> LoginResult.Error("HTTP ${resp.code}")
}
}
} catch (e: IOException) {
LoginResult.Error(e.message ?: "network error")
}
}
/** GET /api/v1/me — resolve the user's configured default model (best-effort, null on any failure). */
suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/me")
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use null
json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel
}
} catch (e: IOException) {
null
}
}
/**
* POST /api/v1/chat streams the assistant reply. Emits the cumulative *visible*
* text after each network chunk (sentinels stripped), so the caller just assigns
* it to the message content. The full byte buffer is re-decoded each chunk to keep
* UTF-8 multi-byte sequences that span chunk boundaries intact.
*/
fun chat(baseUrl: String, token: String, model: String, messages: List<WireMessage>): Flow<String> = flow {
val payload = json.encodeToString(ChatRequest(model, messages)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/chat")
.post(payload)
.header("Authorization", "Bearer $token")
.build()
val resp = client.newCall(req).execute()
if (!resp.isSuccessful) {
val code = resp.code
resp.close()
throw ChatHttpException(code)
}
resp.body!!.byteStream().use { input ->
val raw = ByteArrayOutputStream()
val buf = ByteArray(4096)
while (true) {
val n = input.read(buf)
if (n == -1) break
raw.write(buf, 0, n)
emit(StreamConsumer.visibleText(raw.toByteArray().decodeToString()))
}
}
}.flowOn(Dispatchers.IO)
}

View file

@ -0,0 +1,58 @@
package dev.kaizen.app.net
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Hardware-backed encrypted persistence for the bearer token + server config.
*
* Uses `EncryptedSharedPreferences` (AES-256-GCM, key held in the Android KeyStore /
* StrongBox on the S25 Ultra) so the `kzn_` token is never readable in plain text,
* even on a rooted device. This is LATER.md §1A graduating now because a real
* credential is held biometrics/FLAG_SECURE binding stay deferred.
*/
class SecureStore(context: Context) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context.applicationContext,
"kaizen_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun load(): ServerConfig? {
val baseUrl = prefs.getString(KEY_BASE_URL, null) ?: return null
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val model = prefs.getString(KEY_MODEL, null) ?: DEFAULT_MODEL
val email = prefs.getString(KEY_EMAIL, null) ?: ""
return ServerConfig(baseUrl, token, model, email)
}
fun save(config: ServerConfig) {
prefs.edit()
.putString(KEY_BASE_URL, config.baseUrl)
.putString(KEY_TOKEN, config.token)
.putString(KEY_MODEL, config.model)
.putString(KEY_EMAIL, config.email)
.apply()
}
fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val KEY_BASE_URL = "base_url"
const val KEY_TOKEN = "token"
const val KEY_MODEL = "model"
const val KEY_EMAIL = "email"
}
}

View file

@ -0,0 +1,27 @@
package dev.kaizen.app.net
/**
* The app is deployment-agnostic: one binary that points at EITHER the official
* server OR any self-hosted instance, via one uniform `Authorization: Bearer`
* contract. SaaS vs self-host is only "whose base URL".
*
* @param baseUrl normalized origin (no trailing slash), e.g. "https://ask.kryptomrx.de"
* @param token the `kzn_...` personal access token (stored encrypted, never logged)
* @param model composite model id sent to /api/v1/chat, e.g. "vertex:gemini-2.5-flash"
* @param email the signed-in email (for display in the sidebar/settings)
*/
data class ServerConfig(
val baseUrl: String,
val token: String,
val model: String,
val email: String,
)
/** Default origin pre-filled on the login screen (editable — self-host points it elsewhere). */
const val DEFAULT_BASE_URL = "https://ask.kryptomrx.de"
/** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */
const val DEFAULT_MODEL = "vertex:gemini-2.5-flash"
/** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */
fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/')

View file

@ -0,0 +1,45 @@
package dev.kaizen.app.net
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Single source of truth for "are we signed in, and against which instance".
* Restores the persisted [ServerConfig] on construction, so a returning user lands
* straight in the chat without re-authenticating. [config] is Compose snapshot
* state MainActivity reads it to route between LoginScreen and ChatScreen.
*
* Held at Activity scope (instantiated directly like SettingsViewModel) no DI yet.
*/
class SessionViewModel(private val store: SecureStore) {
var config by mutableStateOf(store.load())
private set
val isLoggedIn: Boolean get() = config != null
/**
* 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
* encrypted and [config] flips, which routes the UI into the chat.
*/
suspend fun login(baseUrlInput: String, email: String, password: String, deviceName: String): LoginResult {
val baseUrl = normalizeBaseUrl(baseUrlInput)
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> {
val model = KaizenApi.fetchDefaultModel(baseUrl, result.token) ?: DEFAULT_MODEL
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg)
config = cfg
result
}
else -> result
}
}
fun logout() {
store.clear()
config = null
}
}

Binary file not shown.

Binary file not shown.

View file

@ -8,9 +8,15 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@ -29,4 +35,5 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }