Compare commits

..

No commits in common. "06757d6f698a98251b2dfb3c971c3d22915585c2" and "392e582084c46eafc681884175a0cc11288b5d6c" have entirely different histories.

12 changed files with 1237 additions and 1560 deletions

View file

@ -57,12 +57,11 @@ ui/
### Key components ### Key components
- **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. - **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Streaming chat returns `Flow<String>` with incremental sentinel-stripping.
- **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 `ChatScreenViewModel`. - **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreen`.
- **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`) — main composable, orchestrates sidebar, messages, streaming, file uploads.
- **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
@ -294,8 +293,6 @@ 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
@ -410,13 +407,7 @@ 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
@ -489,7 +480,6 @@ 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,8 +16,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application <application
android:allowBackup="false" android:allowBackup="true"
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,7 +11,6 @@ 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
@ -23,6 +22,9 @@ 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) {
@ -33,13 +35,8 @@ 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),
@ -53,8 +50,10 @@ 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(vm = chatScreenViewModel, settingsViewModel = settingsViewModel) ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel)
} 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 = ByteArray(0), val bytes: ByteArray,
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,9 +1,7 @@
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) {
@ -26,14 +24,11 @@ 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()
val missing = conversationIds.filter { it !in cached } for (id in conversationIds) {
if (missing.isEmpty()) return if (id in cached) continue
coroutineScope { try {
for (id in missing) { fetchAndCache(baseUrl, token, id)
launch { } catch (_: Exception) { }
try { fetchAndCache(baseUrl, token, id) } catch (_: Exception) { }
}
}
} }
} }
} }

View file

@ -116,18 +116,17 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
fun createHaptics(context: Context): Haptics { /** Builds the right Vibrator for the API level and keeps it across recompositions. */
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 { createHaptics(context) } return remember {
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,7 +9,6 @@ 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
@ -58,9 +57,10 @@ class LiveClient(
val conversationId: String?, val conversationId: String?,
) )
private val client = KaizenApi.baseClient.newBuilder() private val client = OkHttpClient.Builder()
.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,13 +13,9 @@ 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
@ -84,7 +80,6 @@ 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). */
@ -181,19 +176,15 @@ object KaizenApi {
private const val TAG = "KaizenApi" private const val TAG = "KaizenApi"
val baseClient: OkHttpClient = OkHttpClient.Builder() private val client = 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()
@ -236,6 +227,22 @@ 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) {
@ -251,7 +258,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)" 403 -> " (Zugriff verweigert)"
else -> "" else -> ""
} }
Log.w(TAG, "fetchModels: HTTP ${resp.code}") Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
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)
@ -277,7 +284,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)" 403 -> " (Zugriff verweigert)"
else -> "" else -> ""
} }
Log.w(TAG, "fetchConversations: HTTP ${resp.code}") Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
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)
@ -452,7 +459,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 = json.encodeToString(DeleteMessageRequest(messageId)).toRequestBody(jsonMedia) val body = """{"messageId":"$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)
@ -532,7 +539,7 @@ object KaizenApi {
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
val resp = streamClient.newCall(req).execute() val resp = client.newCall(req).execute()
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
val code = resp.code val code = resp.code
resp.close() resp.close()
@ -597,50 +604,6 @@ 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,12 +14,13 @@ 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? = null) { class SessionViewModel(val store: SecureStore) {
var config by mutableStateOf(store?.load()) var config by mutableStateOf(store.load())
private set private set
var favorites by mutableStateOf(store?.loadFavorites() ?: emptySet()) /** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */
var favorites by mutableStateOf(store.loadFavorites())
private set private set
var versionSkew by mutableStateOf<VersionSkew?>(null) var versionSkew by mutableStateOf<VersionSkew?>(null)
@ -38,10 +39,9 @@ class SessionViewModel(val store: SecureStore? = null) {
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 me = KaizenApi.fetchMe(baseUrl, result.token) val model = KaizenApi.fetchDefaultModel(baseUrl, result.token) ?: DEFAULT_MODEL
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? = null) {
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? = null) {
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

@ -1,217 +0,0 @@
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)
}
}