Compare commits

..

4 commits

Author SHA1 Message Date
Bruno Deanoz
06757d6f69 Update CLAUDE.md with ChatScreenViewModel architecture 2026-06-24 10:10:43 +02:00
Bruno Deanoz
40db869717 Extract ChatScreenViewModel from God Composable
Move all business logic and state (~35 variables, ~15 functions) from
the 1540-line ChatScreen @Composable into ChatScreenViewModel.

ChatScreen is now a thin rendering layer (~430 lines) that reads VM
state and wires UI events to VM methods. Android-framework-only concerns
(ActivityResultLaunchers, DrawerState, LazyListState, keyboard insets)
stay in the Composable.

Changes:
- New ChatScreenViewModel.kt: messages, streaming, conversation mgmt,
  sidebar actions, file upload, voice recording, TTS, live call, unlock
- ChatScreen.kt: pure UI, delegates all logic to vm.*
- Haptics.kt: add createHaptics(Context) factory for non-Composable use
- SessionViewModel: store nullable for test construction
- MainActivity: instantiates ChatScreenViewModel

Tests: 12 new unit tests for ChatScreenViewModel (state transitions,
newChat reset, mode toggles, edit/delete, password dialog lifecycle).
All 90 tests pass.
2026-06-24 10:09:40 +02:00
Bruno Deanoz
89fe5fd2ae Update CLAUDE.md with security + performance audit results 2026-06-24 09:40:26 +02:00
Bruno Deanoz
3b73e15376 Security hardening + performance optimization pass
Security:
- Fix JSON injection in deleteMessage (raw string → serialized DTO)
- Separate OkHttpClient for streaming (30s timeout on normal requests,
  infinite only for chat stream)
- Disable backup (EncryptedSharedPrefs without KeyStore → crash on restore)
- Block cleartext traffic explicitly
- Strip server URLs from log output

Performance:
- Share OkHttp connection pool across KaizenApi + LiveClient
- Eliminate duplicate fetchMe/fetchDefaultModel call on login
- Parallelize prefetchMissing (was sequential per conversation)
- Stream file uploads directly from ContentResolver (no full readBytes)
- Cache assistant message index during streaming (O(1) vs O(n) per chunk)
- Build chat history before adding assistant placeholder (no filter needed)

Bugs:
- Fix send() race condition (isStreaming set before async work)
- Move voice recording file read to IO dispatcher
2026-06-24 09:38:54 +02:00
12 changed files with 1560 additions and 1237 deletions

View file

@ -57,11 +57,12 @@ ui/
### Key components ### Key components
- **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Streaming chat returns `Flow<String>` with incremental sentinel-stripping. - **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Three clients sharing one connection pool: `baseClient` (30s timeout, also used by LiveClient), `streamClient` (infinite timeout, chat only), `imageClient` (same as baseClient). Streaming chat returns `Flow<StreamState>` via `streamClient`. `uploadStream()` accepts InputStream for zero-copy file uploads.
- **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream. - **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream.
- **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`. - **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`.
- **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreen`. - **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreenViewModel`.
- **ChatScreen** (`chat/ChatScreen.kt`) — main composable, orchestrates sidebar, messages, streaming, file uploads. - **ChatScreenViewModel** (`chat/ChatScreenViewModel.kt`) — owns all chat state and business logic: messages, streaming, conversation management, sidebar actions, file upload, voice recording, TTS, live call, biometric unlock. Extends `ViewModel` with `viewModelScope`. Dependencies: `Application?`, `SessionViewModel`, `ChatViewModel?`, `SettingsViewModel`. Nullable deps for JVM test construction.
- **ChatScreen** (`chat/ChatScreen.kt`) — thin rendering layer (~430 lines). Reads state from `ChatScreenViewModel`, wires UI events to VM methods. Owns Android-framework-only concerns: `ActivityResultLauncher` (camera/gallery/file), `DrawerState`, `LazyListState`, keyboard insets, `LifecycleResumeEffect`.
### Design System v2 (`ui/`) — added 2026-06-21 ### Design System v2 (`ui/`) — added 2026-06-21
@ -293,6 +294,8 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset | | `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator | | `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
| — | **Branch navigation (tree support)**`ChatTree` data structure mirrors web frontend's tree. Edit creates a branch (sibling node with same parentId) instead of deleting messages. Regenerate creates a new assistant branch. `BranchNavigator` UI (chevron arrows + "1/3" counter) on messages with siblings. `activeRootId`/`activeChild` synced to server via PATCH. `StoredMessage.parentId` parsed from API. `patchConversation` handles JSON object values for `activeChild` | | — | **Branch navigation (tree support)**`ChatTree` data structure mirrors web frontend's tree. Edit creates a branch (sibling node with same parentId) instead of deleting messages. Regenerate creates a new assistant branch. `BranchNavigator` UI (chevron arrows + "1/3" counter) on messages with siblings. `activeRootId`/`activeChild` synced to server via PATCH. `StoredMessage.parentId` parsed from API. `patchConversation` handles JSON object values for `activeChild` |
| `3b73e15` | **Security hardening + performance pass** — JSON injection fix (deleteMessage), separate stream OkHttpClient (30s timeout on normal requests), disable backup, block cleartext, strip URLs from logs. Shared connection pool (LiveClient reuses KaizenApi.baseClient), eliminate duplicate fetchMe, parallel prefetchMissing, streaming file uploads (no readBytes), O(1) message index during streaming, send() race condition fix, voice recording file read on IO thread |
| `40db869` | **ChatScreenViewModel extraction** — moved ~35 state variables + ~15 business-logic functions from the 1540-line God Composable into `ChatScreenViewModel`. ChatScreen is now ~430 lines (pure UI). `createHaptics()` factory for non-Composable use. `SessionViewModel.store` nullable for JVM test construction. 12 new unit tests |
**Earlier UI/feel work (Phase 0 prototype → feel-first):** **Earlier UI/feel work (Phase 0 prototype → feel-first):**
- Liquid glass styling, floating panels, glassmorphic sidebar - Liquid glass styling, floating panels, glassmorphic sidebar
@ -407,7 +410,13 @@ All done:
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change - [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
- [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited) - [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited)
- [x] **Device management**`GET/DELETE /api/v1/auth/tokens` (list + revoke, can't revoke self) - [x] **Device management**`GET/DELETE /api/v1/auth/tokens` (list + revoke, can't revoke self)
- [x] **JSON injection fix**`deleteMessage` was using raw string interpolation for JSON body, now uses `kotlinx.serialization`
- [x] **Separate stream client** — main `OkHttpClient` has 30s `readTimeout` (was infinite for ALL requests). Streaming-only `streamClient` keeps `readTimeout(0)`. Prevents normal API calls from hanging forever
- [x] **Disable backup**`android:allowBackup="false"`. EncryptedSharedPreferences without KeyStore on restore → crash
- [x] **Block cleartext traffic**`android:usesCleartextTraffic="false"` explicit in manifest
- [x] **Strip server URLs from logs**`Log.w` calls no longer include `$baseUrl`, prevents leaking instance URLs in release logcat
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B - [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
- [ ] **Certificate pinning** for default server (`ask.kryptomrx.de`)
### 4. Features for parity with the web ### 4. Features for parity with the web
@ -480,6 +489,7 @@ composeIcons = "1.0.0" # com.composables:icons-lucide-android (Lucide
**Unit tests** (`app/src/test/`): JVM-only, no emulator needed. **Unit tests** (`app/src/test/`): JVM-only, no emulator needed.
- `ChatScreenViewModelTest` — 12 tests: initial state, newChat reset, send guards (no config, empty input, while streaming), input binding, mode/reasoning/sampling toggles, screen navigation, password dialog lifecycle, edit message, delete message, file removal, attach menu, TTS voice
- `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default - `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default
- `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools/usage parsing, char-by-char edge cases - `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools/usage parsing, char-by-char edge cases
- `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix) - `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix)

View file

@ -16,7 +16,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application <application
android:allowBackup="true" android:allowBackup="false"
android:usesCleartextTraffic="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.chat.ChatScreenViewModel
import dev.kaizen.app.chat.ChatViewModel import dev.kaizen.app.chat.ChatViewModel
import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.SessionViewModel
@ -21,10 +22,7 @@ import dev.kaizen.app.ui.theme.KaizenTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Enable High Dynamic Range (HDR) window color mode on Android 14+ (API 34+)
// and Wide Color Gamut (Display P3) on Android 8.0+ (API 26+)
// This leverages the hardware capabilities of high-end AMOLED/OLED displays (S25 Ultra, Pixel 10).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
window.colorMode = ActivityInfo.COLOR_MODE_HDR window.colorMode = ActivityInfo.COLOR_MODE_HDR
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -35,8 +33,13 @@ class MainActivity : ComponentActivity() {
val settingsViewModel = SettingsViewModel(secureStore) val settingsViewModel = SettingsViewModel(secureStore)
val sessionViewModel = SessionViewModel(secureStore) val sessionViewModel = SessionViewModel(secureStore)
val chatViewModel = ChatViewModel(applicationContext) val chatViewModel = ChatViewModel(applicationContext)
val chatScreenViewModel = ChatScreenViewModel(
app = application,
session = sessionViewModel,
chat = chatViewModel,
settings = settingsViewModel,
)
// auto() picks light/dark system-bar icons based on the system theme
enableEdgeToEdge( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@ -50,10 +53,8 @@ class MainActivity : ComponentActivity() {
AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme() AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme()
} }
KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) { KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) {
// Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) { if (sessionViewModel.isLoggedIn) {
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel) ChatScreen(vm = chatScreenViewModel, settingsViewModel = settingsViewModel)
} else { } else {
LoginScreen(session = sessionViewModel) LoginScreen(session = sessionViewModel)
} }

View file

@ -774,7 +774,7 @@ data class PendingFile(
val id: String = java.util.UUID.randomUUID().toString(), val id: String = java.util.UUID.randomUUID().toString(),
val name: String, val name: String,
val mimeType: String, val mimeType: String,
val bytes: ByteArray, val bytes: ByteArray = ByteArray(0),
val uploaded: Attachment? = null, val uploaded: Attachment? = null,
val error: String? = null, val error: String? = null,
val uploading: Boolean = true, val uploading: Boolean = true,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
package dev.kaizen.app.db package dev.kaizen.app.db
import dev.kaizen.app.net.KaizenApi import dev.kaizen.app.net.KaizenApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class MessageRepository(private val dao: MessageDao) { class MessageRepository(private val dao: MessageDao) {
@ -24,11 +26,14 @@ class MessageRepository(private val dao: MessageDao) {
suspend fun prefetchMissing(baseUrl: String, token: String, conversationIds: List<String>) { suspend fun prefetchMissing(baseUrl: String, token: String, conversationIds: List<String>) {
val cached = dao.cachedConversationIds().toSet() val cached = dao.cachedConversationIds().toSet()
for (id in conversationIds) { val missing = conversationIds.filter { it !in cached }
if (id in cached) continue if (missing.isEmpty()) return
try { coroutineScope {
fetchAndCache(baseUrl, token, id) for (id in missing) {
} catch (_: Exception) { } launch {
try { fetchAndCache(baseUrl, token, id) } catch (_: Exception) { }
}
}
} }
} }
} }

View file

@ -116,17 +116,18 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
/** Builds the right Vibrator for the API level and keeps it across recompositions. */ fun createHaptics(context: Context): Haptics {
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
return Haptics(vib)
}
@Composable @Composable
fun rememberHaptics(): Haptics { fun rememberHaptics(): Haptics {
val context = LocalContext.current val context = LocalContext.current
return remember { return remember { createHaptics(context) }
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
Haptics(vib)
}
} }

View file

@ -9,6 +9,7 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import dev.kaizen.app.net.KaizenApi
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
@ -57,10 +58,9 @@ class LiveClient(
val conversationId: String?, val conversationId: String?,
) )
private val client = OkHttpClient.Builder() private val client = KaizenApi.baseClient.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS) .readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(15, TimeUnit.SECONDS) .pingInterval(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.build() .build()
// ─── Connect ────────────────────────────────────────────────────────── // ─── Connect ──────────────────────────────────────────────────────────

View file

@ -13,9 +13,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import android.util.Log import android.util.Log
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -80,6 +84,7 @@ data class ConversationSummary(
val isCurrent: Boolean = false, val isCurrent: Boolean = false,
) )
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList()) @Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
@Serializable private data class DeleteMessageRequest(val messageId: String)
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String) @Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */ /** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@ -176,15 +181,19 @@ object KaizenApi {
private const val TAG = "KaizenApi" private const val TAG = "KaizenApi"
private val client = OkHttpClient.Builder() val baseClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
.build()
val imageClient: OkHttpClient = client.newBuilder()
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
private val client = baseClient
private val streamClient: OkHttpClient = client.newBuilder()
.readTimeout(0, TimeUnit.SECONDS)
.build()
val imageClient: OkHttpClient = client
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMedia = "application/json".toMediaType() private val jsonMedia = "application/json".toMediaType()
@ -227,22 +236,6 @@ object KaizenApi {
} }
} }
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: Exception) {
null
}
}
/** GET /api/v1/models — the full model catalog for the picker. */ /** GET /api/v1/models — the full model catalog for the picker. */
suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> = suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -258,7 +251,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)" 403 -> " (Zugriff verweigert)"
else -> "" else -> ""
} }
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl") Log.w(TAG, "fetchModels: HTTP ${resp.code}")
return@use FetchResult.Fail(reason) return@use FetchResult.Fail(reason)
} }
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models) FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
@ -284,7 +277,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)" 403 -> " (Zugriff verweigert)"
else -> "" else -> ""
} }
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl") Log.w(TAG, "fetchConversations: HTTP ${resp.code}")
return@use FetchResult.Fail(reason) return@use FetchResult.Fail(reason)
} }
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations) FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
@ -459,7 +452,7 @@ object KaizenApi {
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */ /** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean = suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia) val body = json.encodeToString(DeleteMessageRequest(messageId)).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages") .url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body) .delete(body)
@ -539,7 +532,7 @@ object KaizenApi {
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
val resp = client.newCall(req).execute() val resp = streamClient.newCall(req).execute()
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
val code = resp.code val code = resp.code
resp.close() resp.close()
@ -604,6 +597,50 @@ object KaizenApi {
} }
} }
suspend fun uploadStream(
baseUrl: String,
token: String,
fileName: String,
mimeType: String,
inputStream: InputStream,
contentLength: Long = -1L,
): FetchResult<Attachment> = withContext(Dispatchers.IO) {
val mediaType = mimeType.toMediaType()
val streamBody = object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = contentLength
override fun writeTo(sink: BufferedSink) {
inputStream.source().use { source -> sink.writeAll(source) }
}
}
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, streamBody)
.build()
val req = Request.Builder()
.url("$baseUrl/api/v1/upload")
.post(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
val reason = "upload: HTTP ${resp.code}" + when (resp.code) {
401 -> " (Token ungültig)"
413 -> " (Datei zu groß)"
else -> ""
}
Log.w(TAG, "uploadFile: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString<Attachment>(resp.body!!.string()))
}
} catch (e: Exception) {
Log.w(TAG, "uploadStream: ${e.javaClass.simpleName}: ${e.message}")
FetchResult.Fail(diagnoseFetchError("upload", e))
}
}
@Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null) @Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
@Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null) @Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null)

View file

@ -14,13 +14,12 @@ import androidx.compose.runtime.setValue
*/ */
data class VersionSkew(val serverVersion: Int, val expectedVersion: Int) data class VersionSkew(val serverVersion: Int, val expectedVersion: Int)
class SessionViewModel(val store: SecureStore) { class SessionViewModel(val store: SecureStore? = null) {
var config by mutableStateOf(store.load()) var config by mutableStateOf(store?.load())
private set private set
/** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */ var favorites by mutableStateOf(store?.loadFavorites() ?: emptySet())
var favorites by mutableStateOf(store.loadFavorites())
private set private set
var versionSkew by mutableStateOf<VersionSkew?>(null) var versionSkew by mutableStateOf<VersionSkew?>(null)
@ -39,9 +38,10 @@ class SessionViewModel(val store: SecureStore) {
val baseUrl = normalizeBaseUrl(baseUrlInput) val baseUrl = normalizeBaseUrl(baseUrlInput)
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) { return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> { is LoginResult.Success -> {
val model = KaizenApi.fetchDefaultModel(baseUrl, result.token) ?: DEFAULT_MODEL val me = KaizenApi.fetchMe(baseUrl, result.token)
val model = me?.defaultModel ?: DEFAULT_MODEL
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim()) val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg) store?.save(cfg)
config = cfg config = cfg
checkServerVersion(baseUrl) checkServerVersion(baseUrl)
result result
@ -64,7 +64,7 @@ class SessionViewModel(val store: SecureStore) {
val cfg = config ?: return val cfg = config ?: return
if (cfg.model == modelId) return if (cfg.model == modelId) return
val updated = cfg.copy(model = modelId) val updated = cfg.copy(model = modelId)
store.save(updated) store?.save(updated)
config = updated config = updated
} }
@ -73,19 +73,19 @@ class SessionViewModel(val store: SecureStore) {
val cfg = config ?: return val cfg = config ?: return
if (cfg.speed == speed) return if (cfg.speed == speed) return
val updated = cfg.copy(speed = speed) val updated = cfg.copy(speed = speed)
store.save(updated) store?.save(updated)
config = updated config = updated
} }
/** Toggle a model id in/out of favorites (persisted). */ /** Toggle a model id in/out of favorites (persisted). */
fun toggleFavorite(modelId: String) { fun toggleFavorite(modelId: String) {
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) } val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
store.saveFavorites(next) store?.saveFavorites(next)
favorites = next favorites = next
} }
fun logout() { fun logout() {
store.clear() store?.clear()
config = null config = null
favorites = emptySet() favorites = emptySet()
} }

View file

@ -0,0 +1,217 @@
package dev.kaizen.app
import dev.kaizen.app.chat.AppScreen
import dev.kaizen.app.chat.ChatScreenViewModel
import dev.kaizen.app.chat.Message
import dev.kaizen.app.chat.PendingFile
import dev.kaizen.app.chat.ReasoningPreset
import dev.kaizen.app.chat.Role
import dev.kaizen.app.chat.SamplingParam
import dev.kaizen.app.chat.SamplingPrefs
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatScreenViewModelTest {
private fun createVm(): ChatScreenViewModel = ChatScreenViewModel(
session = SessionViewModel(),
settings = SettingsViewModel(),
)
@Test
fun initialState() {
val vm = createVm()
assertTrue(vm.messages.isEmpty())
assertFalse(vm.isStreaming)
assertEquals("", vm.input)
assertNull(vm.conversationId)
assertFalse(vm.viewingLockedChat)
assertNull(vm.chatModel)
assertEquals(0, vm.usedTokens)
assertNull(vm.activeMode)
assertEquals(ReasoningPreset.Standard, vm.reasoningPreset)
assertEquals("google", vm.webSearchProvider)
assertFalse(vm.isRecording)
assertEquals(0f, vm.recordingAmplitude)
assertNull(vm.playingTtsForId)
assertEquals("Kore", vm.ttsVoice)
assertTrue(vm.pendingFiles.isEmpty())
assertFalse(vm.showAttachMenu)
assertEquals(AppScreen.Chat, vm.currentScreen)
assertFalse(vm.liveCallActive)
assertNull(vm.loadError)
assertNull(vm.passwordUnlockTarget)
}
@Test
fun newChatResetsState() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "hello"))
vm.messages.add(Message(2, Role.Assistant, "hi"))
vm.usedTokens = 500
vm.chatModel = "vertex:gemini-2.5-flash"
vm.newChat()
assertTrue(vm.messages.isEmpty())
assertNull(vm.conversationId)
assertFalse(vm.viewingLockedChat)
assertEquals(0, vm.usedTokens)
assertNull(vm.chatModel)
}
@Test
fun sendWithoutConfigIsNoOp() {
val vm = createVm()
val sizeBefore = vm.messages.size
vm.send("hello")
assertEquals(sizeBefore, vm.messages.size)
}
@Test
fun sendEmptyIsNoOp() {
val vm = createVm()
vm.send("")
assertTrue(vm.messages.isEmpty())
}
@Test
fun inputBindsTwoWay() {
val vm = createVm()
assertEquals("", vm.input)
vm.input = "test prompt"
assertEquals("test prompt", vm.input)
}
@Test
fun modeToggle() {
val vm = createVm()
assertNull(vm.activeMode)
vm.activeMode = R.string.mode_search
assertEquals(R.string.mode_search, vm.activeMode)
vm.activeMode = null
assertNull(vm.activeMode)
}
@Test
fun reasoningPresetCycle() {
val vm = createVm()
assertEquals(ReasoningPreset.Standard, vm.reasoningPreset)
vm.reasoningPreset = ReasoningPreset.High
assertEquals(ReasoningPreset.High, vm.reasoningPreset)
assertEquals("high", vm.reasoningPreset.effort)
assertFalse(vm.reasoningPreset.throughput)
}
@Test
fun samplingPrefsUpdate() {
val vm = createVm()
assertFalse(vm.samplingPrefs.temperature.enabled)
vm.samplingPrefs = SamplingPrefs(
temperature = SamplingParam(true, 0.7f),
topP = SamplingParam(false, 1f),
topK = SamplingParam(true, 20f),
)
assertTrue(vm.samplingPrefs.temperature.enabled)
assertEquals(0.7f, vm.samplingPrefs.temperature.value)
assertTrue(vm.samplingPrefs.topK.enabled)
}
@Test
fun screenNavigation() {
val vm = createVm()
assertEquals(AppScreen.Chat, vm.currentScreen)
vm.currentScreen = AppScreen.Settings
assertEquals(AppScreen.Settings, vm.currentScreen)
vm.currentScreen = AppScreen.Chat
assertEquals(AppScreen.Chat, vm.currentScreen)
}
@Test
fun webSearchProviderChange() {
val vm = createVm()
assertEquals("google", vm.webSearchProvider)
vm.webSearchProvider = "enterprise"
assertEquals("enterprise", vm.webSearchProvider)
}
@Test
fun passwordDialogLifecycle() {
val vm = createVm()
val fakeSummary = ConversationSummary(id = "test-123", title = "Test")
vm.showPasswordFallback(fakeSummary, forToggle = true)
assertEquals(fakeSummary, vm.passwordUnlockTarget)
assertTrue(vm.passwordUnlockForToggle)
assertNull(vm.passwordUnlockError)
vm.dismissPasswordDialog()
assertNull(vm.passwordUnlockTarget)
assertNull(vm.passwordUnlockError)
assertFalse(vm.passwordUnlockForToggle)
}
@Test
fun editMessageSetsInputAndClearsMessages() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "original text", wireId = "wire-1"))
vm.messages.add(Message(2, Role.Assistant, "response", wireId = "wire-2"))
vm.editMessage("wire-1", "original text")
assertEquals("original text", vm.input)
assertTrue(vm.messages.isEmpty())
}
@Test
fun clearLockedOnPauseWhenNotViewing() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "secret"))
vm.clearLockedOnPause()
assertEquals(1, vm.messages.size)
}
@Test
fun removeFile() {
val vm = createVm()
val pf = PendingFile(name = "test.jpg", mimeType = "image/jpeg")
vm.pendingFiles.add(pf)
assertEquals(1, vm.pendingFiles.size)
vm.removeFile(pf.id)
assertTrue(vm.pendingFiles.isEmpty())
}
@Test
fun attachMenuToggle() {
val vm = createVm()
assertFalse(vm.showAttachMenu)
vm.showAttachMenu = true
assertTrue(vm.showAttachMenu)
vm.showAttachMenu = false
assertFalse(vm.showAttachMenu)
}
@Test
fun ttsVoiceDefault() {
val vm = createVm()
assertEquals("Kore", vm.ttsVoice)
vm.ttsVoice = "Aoede"
assertEquals("Aoede", vm.ttsVoice)
}
@Test
fun deleteMessageRemovesFromList() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "hello", wireId = "w1"))
vm.messages.add(Message(2, Role.Assistant, "hi", wireId = "w2"))
assertEquals(2, vm.messages.size)
vm.deleteMessage("w1")
assertEquals(1, vm.messages.size)
assertEquals("w2", vm.messages.first().wireId)
}
}