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.
|
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`.
|
* **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:**
|
* **Mechanism:**
|
||||||
- Uses the **hardware-backed KeyStore** (StrongBox / Secure Enclave on flagship devices like the S25 Ultra or Google Pixel 10).
|
- 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.
|
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) —
|
Der Feel-Prototyp redet jetzt mit dem **echten Kaizen-Backend** — echte, gestreamte
|
||||||
**noch ohne Backend**. Die KI-Antworten sind ein gefakter Stream (`fakeReply`), damit
|
Antworten statt `fakeReply`. Die App ist **deployment-agnostisch**: ein Binary, das auf
|
||||||
das Feel ohne Token-Auth/SSE testbar ist.
|
die offizielle Instanz ODER jeden Self-Host zeigt, über einen einheitlichen
|
||||||
|
`Authorization: Bearer`-Contract. SaaS vs. Self-Host ist nur „welche Base-URL".
|
||||||
|
|
||||||
Drin:
|
Drin:
|
||||||
- Obsidian/Amber-Design + Mesh-/Blob-Hintergrund (`Background.kt`)
|
- **In-App-Login** (`auth/LoginScreen.kt`): Server-URL (Default = offizielle Instanz,
|
||||||
- Liquid-Glass-`KaizenOrb` (Hero + Assistant-Avatar, atmet, schneller beim Streamen)
|
editierbar = die „offiziell/Self-Host"-Adresse) + E-Mail + Passwort →
|
||||||
- Empty-Hero mit Begrüßung + Vorschlags-Kacheln
|
`POST {baseUrl}/api/v1/auth/token` → `kzn_`-Token. Kein Token-Copy-Paste.
|
||||||
- Mock-Streaming Wort für Wort mit natürlichem Rhythmus
|
- **Sichere Token-Ablage** (`net/SecureStore.kt`): `EncryptedSharedPreferences`
|
||||||
- **Haptik** (`haptics/Haptics.kt`): Klick beim Senden, „Rise" wenn die Antwort kommt,
|
(AES-256-GCM, Schlüssel im Android-KeyStore/StrongBox) — der Token liegt nie im
|
||||||
sanftes Absetzen am Ende, Tick bei Kachel-Auswahl — feine Composition-Primitives
|
Klartext, auch nicht auf gerooteten Geräten.
|
||||||
auf API 30+, mit Fallbacks darunter.
|
- **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
|
## Bauen & laufen
|
||||||
|
|
||||||
> **Wichtig:** Auf dem **echten S25 Ultra** laufen lassen, nicht im Emulator —
|
> **Wichtig:** Auf dem **echten S25 Ultra** laufen lassen, nicht im Emulator —
|
||||||
> der Emulator kann keine echte Haptik, und genau die wollen wir fühlen.
|
> der Emulator kann keine echte Haptik, und genau die wollen wir fühlen.
|
||||||
|
|
||||||
1. Projekt in Android Studio öffnen, Gradle syncen lassen.
|
1. Projekt in Android Studio öffnen, **Gradle syncen** (neue Deps: OkHttp,
|
||||||
2. S25 Ultra per **Wireless Debugging** (Einstellungen → Entwickleroptionen) oder USB verbinden.
|
kotlinx-serialization, security-crypto + INTERNET-Permission).
|
||||||
3. Run ▶ auf das Gerät.
|
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
|
## Der Test
|
||||||
|
|
||||||
Eine Woche nutzen. Geht der Finger anfangen hierher statt zu Gemini → wir bauen voll aus
|
Eine Woche nutzen. Geht der Finger anfangen hierher statt zu Gemini → wir bauen voll
|
||||||
(Token-Auth + echtes SSE-Streaming = Phase 1). Wenn nicht → wir wissen billig, woran's liegt.
|
aus (History, Passkey, Medien = Phase 2). Wenn nicht → wir wissen billig, woran's liegt.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -49,6 +50,11 @@ dependencies {
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
<!-- Required for haptic feedback (Vibrator.vibrate) -->
|
<!-- Required for haptic feedback (Vibrator.vibrate) -->
|
||||||
<uses-permission android:name="android.permission.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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import dev.kaizen.app.auth.LoginScreen
|
||||||
import dev.kaizen.app.chat.ChatScreen
|
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.settings.SettingsViewModel
|
||||||
import dev.kaizen.app.ui.theme.KaizenTheme
|
import dev.kaizen.app.ui.theme.KaizenTheme
|
||||||
|
|
||||||
|
|
@ -25,8 +28,10 @@ class MainActivity : ComponentActivity() {
|
||||||
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
|
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()
|
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
|
// auto() picks light/dark system-bar icons based on the system theme
|
||||||
enableEdgeToEdge(
|
enableEdgeToEdge(
|
||||||
|
|
@ -35,7 +40,13 @@ class MainActivity : ComponentActivity() {
|
||||||
)
|
)
|
||||||
setContent {
|
setContent {
|
||||||
KaizenTheme {
|
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.MusicNote
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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 }
|
enum class Role { User, Assistant }
|
||||||
|
|
||||||
|
|
@ -52,35 +48,3 @@ fun greeting(hour: Int): String = when (hour) {
|
||||||
in 18..22 -> "Guten Abend"
|
in 18..22 -> "Guten Abend"
|
||||||
else -> "Hallo"
|
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.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.kaizen.app.haptics.rememberHaptics
|
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.SettingsScreen
|
||||||
import dev.kaizen.app.settings.SettingsViewModel
|
import dev.kaizen.app.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
|
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
|
||||||
|
|
@ -59,6 +62,7 @@ enum class AppScreen { Chat, Settings }
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
|
session: SessionViewModel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val haptics = rememberHaptics()
|
val haptics = rememberHaptics()
|
||||||
|
|
@ -86,34 +90,50 @@ fun ChatScreen(
|
||||||
fun send(text: String) {
|
fun send(text: String) {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
if (trimmed.isEmpty() || isStreaming) return
|
if (trimmed.isEmpty() || isStreaming) return
|
||||||
|
val cfg = session.config ?: return
|
||||||
haptics.click()
|
haptics.click()
|
||||||
messages.add(Message(nextId++, Role.User, trimmed))
|
messages.add(Message(nextId++, Role.User, trimmed))
|
||||||
input = ""
|
input = ""
|
||||||
val assistantId = nextId++
|
val assistantId = nextId++
|
||||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
|
||||||
isStreaming = 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 {
|
scope.launch {
|
||||||
// Thinking phase — variable, like a real model
|
|
||||||
haptics.thinkingStart()
|
haptics.thinkingStart()
|
||||||
delay(thinkingDurationMs())
|
var sawContent = false
|
||||||
var first = true
|
try {
|
||||||
fakeReply(trimmed).collect { chunk ->
|
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history).collect { visible ->
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
if (idx < 0) return@collect
|
if (idx < 0) return@collect
|
||||||
messages[idx] = if (first) {
|
if (!sawContent && visible.isNotEmpty()) {
|
||||||
first = false
|
sawContent = true
|
||||||
haptics.responseStart() // the answer begins
|
haptics.responseStart() // the answer begins (reasoning may have streamed first)
|
||||||
messages[idx].copy(thinking = false, content = chunk)
|
|
||||||
} else {
|
|
||||||
messages[idx].copy(content = messages[idx].content + chunk)
|
|
||||||
}
|
}
|
||||||
|
messages[idx] = messages[idx].copy(
|
||||||
|
thinking = if (sawContent) false else messages[idx].thinking,
|
||||||
|
content = visible,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
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
|
isStreaming = false
|
||||||
haptics.responseEnd()
|
haptics.responseEnd()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keep pinned to the bottom as the stream grows
|
// Keep pinned to the bottom as the stream grows
|
||||||
val lastLength = messages.lastOrNull()?.content?.length ?: 0
|
val lastLength = messages.lastOrNull()?.content?.length ?: 0
|
||||||
|
|
@ -147,6 +167,12 @@ fun ChatScreen(
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
currentScreen = AppScreen.Settings
|
currentScreen = AppScreen.Settings
|
||||||
scope.launch { drawerState.close() }
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Edit
|
||||||
import androidx.compose.material.icons.rounded.Lock
|
import androidx.compose.material.icons.rounded.Lock
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
|
@ -87,6 +88,7 @@ fun KaizenSidebar(
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
|
|
@ -288,6 +290,32 @@ fun KaizenSidebar(
|
||||||
modifier = Modifier.size(20.dp)
|
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"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
kotlinxSerialization = "1.7.3"
|
||||||
|
securityCrypto = "1.1.0-alpha06"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
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