diff --git a/LATER.md b/LATER.md
index cd2d4e2..5442d4e 100644
--- a/LATER.md
+++ b/LATER.md
@@ -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).
diff --git a/README.md b/README.md
index 2068fd0..371081e 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a37f2ab..5687449 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f7e8391..3b944ea 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
(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,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt
index 93e3a24..aa21883 100644
--- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt
+++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt
@@ -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 = 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 =
- text.split(" ").mapIndexed { i, word -> if (i == 0) word else " $word" }
diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
index 0a321ba..27ca6a9 100644
--- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
+++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
@@ -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."
+}
diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt
index 4a05065..9b5cc84 100644
--- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt
+++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt
@@ -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
+ )
+ }
}
}
}
diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
new file mode 100644
index 0000000..21ab25f
--- /dev/null
+++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
@@ -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)
+
+/** 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(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(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): Flow = 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)
+}
diff --git a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt
new file mode 100644
index 0000000..23ff56d
--- /dev/null
+++ b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt b/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt
new file mode 100644
index 0000000..0499ad4
--- /dev/null
+++ b/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt
@@ -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('/')
diff --git a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
new file mode 100644
index 0000000..01cf916
--- /dev/null
+++ b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt
new file mode 100644
index 0000000..d740c7d
Binary files /dev/null and b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt differ
diff --git a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt
new file mode 100644
index 0000000..c9bd324
Binary files /dev/null and b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt differ
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c360018..160cffe 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }