From 827a2f307cbb49c645b74517ce7397949b24db67 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Fri, 19 Jun 2026 15:34:59 +0200 Subject: [PATCH] fix: streaming performance, error handling, and API diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eliminate recomposition hell: isolate auto-scroll via derivedStateOf + snapshotFlow - Preserve partial response on connection drop instead of replacing with error - O(1) incremental stream parser (StreamConsumer.Incremental) replacing O(N²) re-scan - Parallel conversation creation via async/await to reduce TTFT - TCP/TLS prewarm on app resume via LifecycleResumeEffect - Surface API failures with Log.w and UI error banner instead of silent emptyList() - Fix nullable KaizenModel.name crash in ModelSheet --- app/build.gradle.kts | 1 + .../java/dev/kaizen/app/chat/ChatScreen.kt | 102 ++++++++++++++---- .../java/dev/kaizen/app/chat/ModelSheet.kt | 4 +- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 80 ++++++++++---- .../java/dev/kaizen/app/net/StreamConsumer.kt | Bin 2741 -> 5075 bytes .../java/dev/kaizen/app/StreamConsumerTest.kt | Bin 2423 -> 6600 bytes gradle/libs.versions.toml | 1 + 7 files changed, 148 insertions(+), 40 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5687449..9897a4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) // Networking + JSON for the real backend (POST /api/v1/auth/token, /chat, /me) implementation(libs.okhttp) implementation(libs.kotlinx.serialization.json) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 6033749..3f1c6f4 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -34,12 +34,14 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,7 +49,10 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.material3.Text +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatSpeed @@ -59,6 +64,8 @@ import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.WireMessage import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import kotlinx.coroutines.async import kotlinx.coroutines.launch // Screen enumeration for modular state-driven navigation (Zero Technical Debt) @@ -100,20 +107,40 @@ fun ChatScreen( var conversationId by remember { mutableStateOf(null) } var conversations by remember { mutableStateOf>(emptyList()) } + var loadError by remember { mutableStateOf(null) } + LaunchedEffect(session.config?.baseUrl, session.config?.token) { val cfg = session.config ?: return@LaunchedEffect - // Fetch fresh models from server and cache them locally if successful + loadError = null val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) - if (fetchedModels.isNotEmpty()) { + if (fetchedModels != null) { models = fetchedModels - session.store.saveModelsCache(fetchedModels) + if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels) } - conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) + val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) + if (fetchedConvos != null) { + conversations = fetchedConvos + } + if (fetchedModels == null || fetchedConvos == null) { + loadError = "Verbindung zum Server fehlgeschlagen" + } + } + + // Warm the HTTP/2 connection pool on every app resume (covers background → foreground + // after OkHttp's 5-minute keep-alive has expired). On first composition, the + // LaunchedEffect above already warms the pool via fetchModels/fetchConversations. + LifecycleResumeEffect(session.config?.baseUrl) { + session.config?.let { cfg -> + scope.launch { KaizenApi.prewarm(cfg.baseUrl) } + } + onPauseOrDispose { } } fun refreshConversations() { val cfg = session.config ?: return - scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) } + scope.launch { + KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)?.let { conversations = it } + } } /** Start a fresh chat — drops the active conversation, the next send creates a new one. */ @@ -168,17 +195,19 @@ fun ChatScreen( haptics.thinkingStart() var sawContent = false try { - // Ensure a server conversation exists before the exchange is persisted. - val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also { - conversationId = it - } + // Launch conversation creation in parallel — the chat stream does not + // need a conversation id, only the post-stream persistence does. This + // eliminates a full network RTT from the TTFT on new chats. + val convIdDeferred = if (conversationId == null) { + async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } + } else null KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible -> val idx = messages.indexOfLast { it.id == assistantId } if (idx < 0) return@collect if (!sawContent && visible.isNotEmpty()) { sawContent = true - haptics.responseStart() // the answer begins (reasoning may have streamed first) + haptics.responseStart() } messages[idx] = messages[idx].copy( thinking = if (sawContent) false else messages[idx].thinking, @@ -189,6 +218,9 @@ fun ChatScreen( val finalContent = if (idx >= 0) messages[idx].content else "" if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) + // Await the conversation id — the create ran in parallel with the stream + val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId + // Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation. if (convId != null && finalContent.isNotEmpty()) { val saved = KaizenApi.saveMessages( @@ -203,11 +235,15 @@ fun ChatScreen( } } 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), - ) + if (idx >= 0) { + val existing = messages[idx].content + val errorSuffix = "\n\n⚠ ${chatErrorText(e)}" + messages[idx] = messages[idx].copy( + streaming = false, + thinking = false, + content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e), + ) + } } finally { isStreaming = false haptics.responseEnd() @@ -215,10 +251,17 @@ fun ChatScreen( } } - // Keep pinned to the bottom as the stream grows - val lastLength = messages.lastOrNull()?.content?.length ?: 0 - LaunchedEffect(messages.size, lastLength) { - if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex) + // Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow. + // Only the snapshot read triggers work — the root composable does NOT recompose on + // every streamed character, eliminating the recomposition storm that previously + // re-drew the entire screen (top bar, input, background) at 60+ fps. + val autoScrollKey by remember { + derivedStateOf { messages.size to (messages.lastOrNull()?.content?.length ?: 0) } + } + LaunchedEffect(Unit) { + snapshotFlow { autoScrollKey }.collect { + if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex) + } } // Single source of truth Screen routing @@ -341,6 +384,27 @@ fun ChatScreen( ) } + // Error banner when initial data load fails + if (loadError != null) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 62.dp, start = 24.dp, end = 24.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFDC2626).copy(alpha = 0.15f)) + .border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), RoundedCornerShape(12.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + loadError!!, + color = Color(0xFFDC2626), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } + // 3. Floating Bottom Control Dock (responsive centering and clear bottom margins) Column( modifier = Modifier diff --git a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt index 7b44a74..4e7127e 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt @@ -69,7 +69,7 @@ private fun groupFor(m: KaizenModel): String = when { private fun buildGroups(models: List, favorites: Set, query: String): List { val q = query.trim().lowercase() val filtered = if (q.isEmpty()) models - else models.filter { it.name.lowercase().contains(q) || it.id.lowercase().contains(q) } + else models.filter { it.name?.lowercase()?.contains(q) == true || it.id.lowercase().contains(q) } val groups = mutableListOf() val favs = filtered.filter { it.id in favorites } @@ -267,7 +267,7 @@ private fun ModelRow( // Model Name & Clean Technical ID Column(modifier = Modifier.weight(1f)) { Text( - model.name, + model.name ?: model.id, color = cs.onBackground, fontSize = 15.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 1aa466f..e6ae8e9 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -13,8 +13,9 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import java.io.ByteArrayOutputStream +import android.util.Log import java.io.IOException +import java.io.InputStreamReader import java.util.concurrent.TimeUnit // --- Wire DTOs (additive v1 contract — ignoreUnknownKeys tolerates new fields) --- @@ -95,6 +96,8 @@ class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code") */ object KaizenApi { + private const val TAG = "KaizenApi" + private val client = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream @@ -142,8 +145,8 @@ object KaizenApi { } } - /** GET /api/v1/models — the full model catalog for the picker (best-effort, empty on any failure). */ - suspend fun fetchModels(baseUrl: String, token: String): List = + /** GET /api/v1/models — the full model catalog for the picker. Returns null on failure (with logged reason). */ + suspend fun fetchModels(baseUrl: String, token: String): List? = withContext(Dispatchers.IO) { val req = Request.Builder() .url("$baseUrl/api/v1/models") @@ -151,16 +154,20 @@ object KaizenApi { .build() try { client.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@use emptyList() + if (!resp.isSuccessful) { + Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl") + return@use null + } json.decodeFromString(resp.body!!.string()).models } } catch (e: Exception) { - emptyList() + Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}") + null } } - /** GET /api/v1/conversations — the sidebar list (best-effort, empty on any failure). */ - suspend fun fetchConversations(baseUrl: String, token: String): List = + /** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */ + suspend fun fetchConversations(baseUrl: String, token: String): List? = withContext(Dispatchers.IO) { val req = Request.Builder() .url("$baseUrl/api/v1/conversations") @@ -168,11 +175,15 @@ object KaizenApi { .build() try { client.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@use emptyList() + if (!resp.isSuccessful) { + Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl") + return@use null + } json.decodeFromString(resp.body!!.string()).conversations } } catch (e: Exception) { - emptyList() + Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}") + null } } @@ -187,10 +198,14 @@ object KaizenApi { .build() try { client.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@use null + if (!resp.isSuccessful) { + Log.w(TAG, "createConversation: HTTP ${resp.code}") + return@use null + } json.decodeFromString(resp.body!!.string()).id } } catch (e: Exception) { + Log.w(TAG, "createConversation: ${e.javaClass.simpleName}: ${e.message}") null } } @@ -204,10 +219,14 @@ object KaizenApi { .build() try { client.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@use emptyList() + if (!resp.isSuccessful) { + Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}") + return@use emptyList() + } json.decodeFromString(resp.body!!.string()).messages } } catch (e: Exception) { + Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}") emptyList() } } @@ -222,8 +241,12 @@ object KaizenApi { .header("Authorization", "Bearer $token") .build() try { - client.newCall(req).execute().use { resp -> resp.isSuccessful } + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) Log.w(TAG, "saveMessages($id): HTTP ${resp.code}") + resp.isSuccessful + } } catch (e: Exception) { + Log.w(TAG, "saveMessages($id): ${e.javaClass.simpleName}: ${e.message}") false } } @@ -246,8 +269,12 @@ object KaizenApi { /** * 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. + * it to the message content. + * + * Uses [InputStreamReader] for correct UTF-8 multi-byte boundary handling and + * [StreamConsumer.Incremental] for O(chunk-size) parsing — each character is + * visited exactly once across the entire stream, avoiding the O(N²) cost of + * re-decoding and re-scanning the full buffer on every chunk. * * [speed] maps to the additive `reasoningEffort`/`preferThroughput` v1 fields. */ @@ -280,14 +307,29 @@ object KaizenApi { } resp.body!!.byteStream().use { input -> - val raw = ByteArrayOutputStream() - val buf = ByteArray(4096) + val reader = InputStreamReader(input, Charsets.UTF_8) + val consumer = StreamConsumer.Incremental() + val charBuf = CharArray(4096) while (true) { - val n = input.read(buf) + val n = reader.read(charBuf) if (n == -1) break - raw.write(buf, 0, n) - emit(StreamConsumer.visibleText(raw.toByteArray().decodeToString())) + emit(consumer.append(charBuf, 0, n)) } } }.flowOn(Dispatchers.IO) + + /** + * Warm the OkHttp HTTP/2 connection pool against [baseUrl]. Fire-and-forget + * GET to the unauth `/api/v1/meta` endpoint — eliminates the cold TCP/TLS + * handshake (~150-300ms on mobile) from the first real request after app + * start or background resume. + */ + suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) { + try { + val req = Request.Builder().url("$baseUrl/api/v1/meta").build() + client.newCall(req).execute().close() + } catch (_: Exception) { + // best-effort — a failed prewarm just means the first real request pays the cold cost + } + } } diff --git a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt index d740c7df26c1987422547ed3f285ae937834e216..91324b411b98fb8fca66c1555af108149f6238f2 100644 GIT binary patch literal 5075 zcmbtYZEhRM5&f@Ilm%oxR7i@_Izgb=yRZfA!iXUzqVyx+5^!_aB-OR zvd&1c8G4UvVdR45((R-S#&hA)H5I~GX(*Xayzw=>l^bb>TV>TellStooApTK3l6}Z zRGu2SL4qRt>f!f|&8)}q&rb@Yw!%r;;@HFF?aw##hDJlDuaQ{NBYvHCt?q8#PR6(I zZ-0j6MPs>VH5otLUEfS#bJ^G&__`mDKfvVsw#i|{zdhXC{T*hnx@L$Nf%$7`fCoy( zC}iRZm0N0atoBCQ0=1MC2=w27{)+_tMY{}G71}t`%UIGm7$Nh;OG_U!HTU412kZtw zI^f~#$06MVkVjG-j^j81nhl-p zUBL&tAsT%ijVibpA#z42Twq0|1%=O3Z5@5GIOMcv6cxK@ zuEYhr@JJx`%>Vj|ehqIf$(l609ff+%5P^f-?{w8#z!roPTU1@38yOfH(6rs0R}>nV z36^SOAPF=t9gUOQ9u?q8DzdDP0H_0VRii9+tzAuH-`mmx`pSE|d#@^*aZx$KI7!!K z{y4DEt+sm~&1s0jkRCYHqGU3HVB6RN_!y)>&p&aJUBRI*CnsEeuYmmKjNW;Hf&dxe zWzHwy^~(B=Tb5ZyB2CLp3Hr0TmCGe}q|qC)so~>@7@S>ytu%k8M_D*unle|89GAii zUR^2FF8Vq5{+4A`g7%@ON}cZ=lpYvU46&kf|h|oT^#l__>t>1QDq5ZRb$ZkP32WSzxk( zqHjPC>&DB3%{dSG86SKn_8=DRFDpUV|JqNIUXukUK3uBIfd*d)Xufzug9~bs^9CE0 zkIgOE(<%&Kmvh2_9KZD96?_)9oHKCwDjD@T^VVG|#MsQ=?>SsEjyq<`*D;S=sY&x= z3xusOku{)4UA)~|Ws(xl17&ZuyV(@(2_y8o@4WeRG-JeA6!#%zcH~(kQLOjNU-FmT z3UV#jj%(M8oaCxtMDzUDn#sBGUZYD*hh8Wp#Y(F;RcE^cVszJ~TUQqaTMmGXO2CEb64G4tE z%1zG7B0uA+>!)VoW=SC?MpL(%eU*_2wh}7 z6D^MTeJ#5D!yg;S+Xr}}z6%Pzf9BW$1Kh1^G!(R6ip*k1tzM09K$)WyVGH@D-2$3l z8`Pd^-i0#$Bmsv2Q7{OZP7$G+Id({QUdY3oN=r;-EIZZ22*Or72k$0gDY2t?NEvbz z9a{$;2zJ^$R&ki!ItK+A8>}}CPs5I2Q<3L#QDNm14S4ZeR-sC~IZ1m(Os2QSh^Ibo zu(T2@&HLQ-u|VZ3xAq@uQEF)tJ7aa=I@QWhA$?TpRu z7x)mrm~EI#j2kqqVZ;q)Rpa#V9ACtr>*yS2Z*bfE?dFa+&Xf($q8%1R$TGEV53Bzj>FIsT!_lJU3L3sFOyYoI34<8 zud2;TGnw)F20}1ZjJwbB@5Q-UYhbtkk1~K4GLhpSK~z0jH4~W=Y!l>A zAwB1@E?|MF9v2||m<4;3U`M=>7Mh)>8iYH9v?|UB_D~Ga281buDGr{HoVe zJ|4HzQ#_~lIcm2K;Y1il2dnDf;vhY?52=T(^T41fY;;{o9Zq$O8^iGK`Q-WJe>_v# A1ONa4 delta 1019 zcmZWoPiqrF6gRcD7|&irCMyLIt%&EZRz|p8GQUHam27W|^678$seH5G8(- z(wpDHs|WGySr9M2nMsOZE_ulte)Ib`U+{doKIORHFzye!cXtb-+;lXYOU8iOwh zj--=AMQ|XD_N4s*{CNMFkD)VK>QNt@X{;dE2XBmWfI5f9;fUJM1GEIrTUpnXXRt|i zK-XftR#@VV0qH@YhT$!OMoYiu$$82lmzIQQ21Z8`+izY>|192I1pa*bwYm%!+F_ht zZ}Hqp^W!itq`3_9#Wa6ttxjOE9e)gZz4*=4wobf7;a$YfrK=T=F$kUMgu9Klr$_@u+0gj1|_aHdQd1S4$~tBC-|l5 QY^Gyy`hEFzYvEn{FKUxhl>h($ diff --git a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt index c9bd3247116795028eb35b6a0cf4b5d0dbd4e593..0de9144cee3e284a33ea087d0105dfb4dca6eb02 100644 GIT binary patch literal 6600 zcmc&(-EP}96u$RU5Q2f(MJlgpt_BoXnr7&*KWxn~40U195^Zy#NtL9MxQ2l~L?35& zdy_uN&Y>iWk|kSCvMvZ5LpnS>zu)=DBs99Fb4Frz6JAsPfyt02Nw3G3iPE(l?d7-39mrNIeO3%Z;Qps(2ZVb~lr0&n<`;-c^D|E$-?e#`ap7h8QaGN|!6>%@I84k!-5SE~zz(s>wxvr(Zn)u7;Z$07XWffiHYKrQ zCM4*EAyZ`+1ZYt(6#5+L^67i-@AeaiF-M9-?Cu)8yKG$NlreS}}~5(8J{dEfs_yLIbvz{kNi&rVOqfr2m3&o)7e z-NnNFR>Lm)jNO(X7z*X>ES*4mTCo2Ime@9{`|It>P0ZvqBa%i7|LH5vNNytG&?#r9 zVVfsSqI*-(*(pFG2`Gu)Oz`{f*qdE^n(Kf5o-*M(^c^57pV#t~r|GmcOZln?CCMMW<3&S##rna8!^bWxJ*(DpTJ}^SE(V#zc5X_cRaUP=bpygo4eK?@RZ;H;g8eY2^+b@j_tPX0>Xaipc0IOYRr zg2z&}d;oG(gcBsnl19=#h%k#Tox@Vd^DqM1hmeZU(kVJwM)wdOKNej{5Ujah;ym`_ zIkja|1-9&Fyk%i^-1+otA4>TyZwZk{r5>+P0ZV;9eSC9>*6wg^r6X$X(m4BSEBw9O z>(+Yl2IrRZRqd`b!sCP6jnW-aS8ZW!pnyTyC~nqd826%*nFBPj)KHz3B~Lu6&yJ>s z_YBMqy(|4_jl`qS&2pb=ymkya$x$v_(0DQf-;4P)K{l9k%SRe4E)Ln5uK#~hQLmm4 zoRiw(kqBYJl0dYw*{SLSU@tfpK5vm|q>r*PxaIqBHQIV$6v|E;(nb(c5!5%3{#tUA%5 zk;)5%!iQZ(Sy$s#QOsBZXCJ(QwCxJcPWPyQmtD&)KWw@C+Q}>xB0CW61Pou{nW70f zN{4;g*T98*kSr$s`|>`?M07{fAUcL{_4GQ_*c=iMt0(7<7KGQ@Bn+2eH NPep|bb;7af{R_Ymm*fBd delta 240 zcmYL@u}%U(6h)cYEkG*`CYHB01PV$tCX}F9(AsQvpEB9u4cU2WC`|kX9b3aMnCwsR z9X5P|Ewd!X>fGez-t)G@-_!3)6~5F&qZM}QLPLcU9ihXhK@zAz8s6L8`X;$o=Ys(h zD$2ZL!;dlNj)hq2P5P0H<8|i1^kNMY)+G3O`-ChAtE>`}Sxn}4Gc2FHfSusMn!Kb6 zj^Xo)i2Grap4C_FV>OLix2_-y*ZA@}C&AG$K?SOeN9kpp%8+vU#v@;``yc I^Q>Qw_I#{P7ytkO diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 160cffe..ecae944 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }