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:
parent
8a527451d7
commit
44c17b6f9f
16 changed files with 635 additions and 73 deletions
6
LATER.md
6
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).
|
||||
|
|
|
|||
46
README.md
46
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
237
app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt
Normal file
237
app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,34 +90,50 @@ 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 ->
|
||||
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
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep pinned to the bottom as the stream grows
|
||||
val lastLength = messages.lastOrNull()?.content?.length ?: 0
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
125
app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
Normal file
125
app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
Normal 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)
|
||||
}
|
||||
58
app/src/main/java/dev/kaizen/app/net/SecureStore.kt
Normal file
58
app/src/main/java/dev/kaizen/app/net/SecureStore.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/dev/kaizen/app/net/ServerConfig.kt
Normal file
27
app/src/main/java/dev/kaizen/app/net/ServerConfig.kt
Normal 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('/')
|
||||
45
app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
Normal file
45
app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt
Normal file
BIN
app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt
Normal file
Binary file not shown.
BIN
app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt
Normal file
BIN
app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt
Normal file
Binary file not shown.
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue