From 44c17b6f9f5c2bdd46ec3f8415320469e8109aeb Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Fri, 19 Jun 2026 01:04:55 +0200 Subject: [PATCH] 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). --- LATER.md | 6 +- README.md | 46 ++-- app/build.gradle.kts | 6 + app/src/main/AndroidManifest.xml | 3 + .../main/java/dev/kaizen/app/MainActivity.kt | 15 +- .../java/dev/kaizen/app/auth/LoginScreen.kt | 237 ++++++++++++++++++ .../java/dev/kaizen/app/chat/ChatModels.kt | 36 --- .../java/dev/kaizen/app/chat/ChatScreen.kt | 69 +++-- .../main/java/dev/kaizen/app/chat/Sidebar.kt | 28 +++ .../main/java/dev/kaizen/app/net/KaizenApi.kt | 125 +++++++++ .../java/dev/kaizen/app/net/SecureStore.kt | 58 +++++ .../java/dev/kaizen/app/net/ServerConfig.kt | 27 ++ .../dev/kaizen/app/net/SessionViewModel.kt | 45 ++++ .../java/dev/kaizen/app/net/StreamConsumer.kt | Bin 0 -> 2741 bytes .../java/dev/kaizen/app/StreamConsumerTest.kt | Bin 0 -> 2423 bytes gradle/libs.versions.toml | 7 + 16 files changed, 635 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt create mode 100644 app/src/main/java/dev/kaizen/app/net/KaizenApi.kt create mode 100644 app/src/main/java/dev/kaizen/app/net/SecureStore.kt create mode 100644 app/src/main/java/dev/kaizen/app/net/ServerConfig.kt create mode 100644 app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt create mode 100644 app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt create mode 100644 app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt 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 0000000000000000000000000000000000000000..d740c7df26c1987422547ed3f285ae937834e216 GIT binary patch literal 2741 zcmZ`*+in{-5bf-s#CSz+NUmhnFwl?a z7xqhfhI^Cac3-qw4rhjE&YWQ(l3OvCl*(0nE7YG-$D$}=Eq$*y+}rEX9(^dSq(WGy z^qg|Bk(M%J$uA_`3`L=atMM>d2!BIXy25BD$$2ZqlK%PYZxZy2@ww7e80*PoypO%k z5LoJT*U?R`X2HR*+BQhwqg+ZGdv_Dl1>zLetW=7?wPWSrH_L_R4@iq0u9aU9wt8!F za#DMxW$vh-m=q3-(%$XkyQDT8$MFK+5hyx@4|YQ|x*Lt?^8C%aDgNN`Rwx``PNf4< z!^AjGpPbRH^_b44Z_ZCoem;3ew6>yfKtjL*oA$|@`8;gr+>jQ%9K7n*TPFu}etLO! zG(G2#YdIS@DQvPJXG)t$_c-Enx;Q;OW=lM|6e*{tU&e*yW&dc}*|t^Sq)fAU5uf!~l*% zB@p{MtiGVn)yn}nn^do(3eRgq;2<9s0>oiA8ijUbbiN53TPdezIk;BGy9u<~0=Pzq zkv1r}&}rqFy$Vp5DN+} z``>?!;cnP7vrkx{^T6+;nx}N%V-O3gR>DhKiM*!IghsJ9M+;&5QGj_kq_3XXcI27R zxU+}t*H3I4j!fvFvm}D;H&1K>MJDuo+Ynj6_S+}66%!`(;;}Jozv~6#papz}J(}L3 zBNDKR6(CivfF=m8P($#{nq^qb$~-5LB;~S1uL9RunPuo}Iv1Hlxq@eSOa$yKio-@g zJuSJZ>t^S-KI$L^x*=JV`Zfv-K;<;J$Uj zrqj@u>>#4ohp_Uc)wHuyfz6u%J@^#_gBjiOokZ@X)j*2X46f_ZeT-fA%3^FL+Zy61- zsUOC|&0to+^;4;Gw0XZX;E~c-$QY*zX}MVGR6+WpElAm$%GmG+P7Mtf!7Yz0G&)58-zcxw$N9^>^tlPCyL zH8x^83skS{ty~JlQ=RhQ>C$>3D=DrP2sZ%d6&93sxEVn8@s^ln zkxNK$H~XlsTbZc`ByFUxB!qQ`FBM!pl+!Obl@d1xxW!zkz45id@CC>#koY|YeI+~< zYxZytQMk&4kwq@ST+xqJqnQh=9*lDFpE}U!M=K<+w`ji-wowvDMoZWALP2)>tTC~2 zC&o{gh2LP-JbM<*uSVD0BaHu-O~n1E^QLVZ7Vdu3yN}7nv|&4@Sp=s+r$R;0D_e+% t@&;XA3t-3HyfhfTsqmuBm&sWRV#v9p9YXv_<1TbZ=sG0Z*Zh0v{R>T}P)7g& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c9bd3247116795028eb35b6a0cf4b5d0dbd4e593 GIT binary patch literal 2423 zcmbtWU2fY(5N?`0@e~6>Kw`t88plro0>pJ)7-{Puu!|xn3t>f$#I2UQ^zKlyA_(Xq zdYruFCOJuFSCk@3mSaGA5wZMsX1@9PhLy-hVu+CAI2j4~5p^P}DvD%T8A}_-iAGAU z$f79EjCOU2b_q0gnEYF7Ny({mXekll@BF3~%58(L(NPq24-X?agtJ0ekr7(?QIIqm zh~GLG7znP&Zqh{{FIRKq@Nvl1a!Ob!xokaI-@<=*YM&Sp0SU%u+G#r zL&wPDZ^&Tx$1N-wqZs&d9k__$@fP-`MmuHjNgK>DJXw%?6YA(W3~CLP5>j7tntGM5 zzPm20sfPu#oCVV!x84@d;tQdafxl2ue6*3nq)ENE*8(0K1Y`5NA_xh-YWk%o-I?Ns z!MqiLFN75nrWK!RnsApVop5iK#xjjxy*NHfW5W+GPv-HCxR)32kD9&Q7woFS94Pls zh_uJ;NgV%gSWzo#Uw2sD;O7Ro3UG~9XSM>)bJQaQEwW<$^cRWHZ~)1@dUBlXqIwtf z-da7FHIJ%h8S!*`{`pTo9WBy3I1TORPkmJDgnnhok;9EbHg!DSS;pqpUR87`Y*xJY zlX9x_tHmVtgvIal-i2i4%7x?ZCzigXGYiu;Pvc2ANaN0|nd|xA+?usj+>>d1o5u5h z+70_%o}0e*BCJs4EKAp{Bj;nTo%3Xu{kyY#UUrxTO)CsEsE~TZW|&Q?J2U@!zW&~k zu$0D3O|uTKn5TNJA0N`Q$zEKg&P-V;B>a7YbuTQ8OXGPATda9fFAZp%C|+7HMGE`L zFEUNV~mrhW{`*DwZz(5|E1Ze7P)-IHgNz1S}`R$MzS@Jgy+sq2!?~Nd)djGV~qpwq%3?%>n literal 0 HcmV?d00001 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" }