git.iko.soy/opencode-android
on master help sign in
Plug URL leak in error messages, add SSE half-open watchdog, merge streamed parts during initial load, and harden retry/locale/signing
commitbcd5f2d689a66d28a741b28bd87797c37bec7dea authoriko <mail@iko.soy> dateMon 29 Jun 2026, 14:44 (3 months ago) parent6f4f52c
13 files changed+379−24
app/build.gradle.kts+15 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+38−10 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+9−2 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+20−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+21−8 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+5−2 app/src/main/res/values/strings.xml+2 app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt+42 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+102 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+66 app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.kt+56 gradle/libs.versions.toml+1
app/build.gradle.kts+15
@@ -33,6 +33,19 @@ android {
3333 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3434 }
3535
36+ // Release builds are signed when keystore credentials are supplied via env vars
37+ // (so CI can publish an installable APK). When the env vars are absent, the
38+ // release build falls back to unsigned — same behavior as before — so local
39+ // builds keep working without any keystore setup.
40+ val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
41+ signingConfigs.create("release") {
42+ storeFile = file(path)
43+ storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
44+ keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
45+ keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
46+ }
47+ }
48+
3649 buildTypes {
3750 release {
3851 isMinifyEnabled = true
@@ -41,6 +54,7 @@ android {
4154 getDefaultProguardFile("proguard-android-optimize.txt"),
4255 "proguard-rules.pro",
4356 )
57+ releaseSigning?.let { signingConfig = it }
4458 }
4559 }
4660
@@ -98,4 +112,5 @@ dependencies {
98112
99113 testImplementation(libs.junit)
100114 testImplementation(libs.kotlinx.coroutines.test)
115+ testImplementation(libs.ktor.client.mock)
101116}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+38−10
@@ -4,10 +4,11 @@ import soy.iko.opencode.data.model.BusEvent
44import android.util.Log
55import io.ktor.client.HttpClient
66import io.ktor.client.plugins.sse.sse
7+import io.ktor.sse.ServerSentEvent
78import kotlinx.coroutines.CancellationException
89import kotlinx.coroutines.CoroutineScope
910import kotlinx.coroutines.channels.Channel
10−import kotlinx.coroutines.delay
11+import kotlinx.coroutines.channels.ChannelResult
1112import kotlinx.coroutines.flow.Flow
1213import kotlinx.coroutines.flow.MutableStateFlow
1314import kotlinx.coroutines.flow.SharedFlow
@@ -15,10 +16,12 @@ import kotlinx.coroutines.flow.StateFlow
1516import kotlinx.coroutines.flow.SharingStarted
1617import kotlinx.coroutines.flow.asStateFlow
1718import kotlinx.coroutines.flow.channelFlow
19+import kotlinx.coroutines.flow.produceIn
1820import kotlinx.coroutines.flow.shareIn
1921import kotlinx.coroutines.isActive
2022import kotlinx.coroutines.selects.onTimeout
2123import kotlinx.coroutines.selects.select
24+import java.io.IOException
2225
2326/**
2427 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
@@ -52,22 +55,45 @@ class EventStreamClient(
5255 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
5356
5457 private fun stream(): Flow<BusEvent> = channelFlow {
58+ val scope = this
5559 var backoffMs = INITIAL_BACKOFF_MS
5660 while (isActive) {
5761 _state.value = ConnectionState.Connecting
5862 try {
5963 client.sse("event") {
6064 backoffMs = INITIAL_BACKOFF_MS
61− incoming.collect { sse ->
62− val data = sse.data ?: return@collect
63− val event = runCatching { OpencodeJson.decodeFromString(BusEvent.serializer(), data) }
64− .getOrNull() ?: return@collect
65− // Any successfully decoded event means the stream is live;
66− // don't wait for a ServerConnected event to show "Connected".
67− if (_state.value != ConnectionState.Connected) {
68− _state.value = ConnectionState.Connected
65+ // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
66+ // element can be raced against an idle timeout via select.
67+ val events = incoming.produceIn(scope)
68+ try {
69+ while (isActive) {
70+ // The OkHttp engine is configured with readTimeout=0 (the SSE
71+ // stream must never time out on read), so a socket the peer never
72+ // closed would otherwise hang until the OS TCP keepalive kicks in
73+ // — minutes to hours on stock Linux. If nothing arrives within
74+ // IDLE_TIMEOUT_MS, treat the connection as half-open and drop it
75+ // so the outer loop reconnects.
76+ val result: ChannelResult<ServerSentEvent>? = select {
77+ events.onReceiveCatching { it }
78+ onTimeout(IDLE_TIMEOUT_MS) { null }
79+ }
80+ // Timeout: drop and reconnect.
81+ if (result == null) throw IOException("SSE idle timeout, reconnecting")
82+ // Stream closed cleanly: exit to reconnect without a warning log.
83+ val sse = result.getOrNull() ?: break
84+ val data = sse.data ?: continue
85+ val event = runCatching {
86+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
87+ }.getOrNull() ?: continue
88+ // Any successfully decoded event means the stream is live;
89+ // don't wait for a ServerConnected event to show "Connected".
90+ if (_state.value != ConnectionState.Connected) {
91+ _state.value = ConnectionState.Connected
92+ }
93+ send(event)
6994 }
70− send(event)
95+ } finally {
96+ events.cancel()
7197 }
7298 }
7399 } catch (c: CancellationException) {
@@ -91,5 +117,7 @@ class EventStreamClient(
91117 private const val TAG = "EventStream"
92118 const val INITIAL_BACKOFF_MS = 500L
93119 const val MAX_BACKOFF_MS = 10_000L
120+ /** Max gap between events before a silent/half-open connection is dropped. */
121+ const val IDLE_TIMEOUT_MS = 90_000L
94122 }
95123}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+9−2
@@ -141,14 +141,21 @@ class OpencodeApiClient(private val client: HttpClient) {
141141 }
142142}
143143
144+private suspend fun <T> withRetry(
145+ maxAttempts: Int = 3,
146+ initialDelayMs: Long = 500L,
147+ block: suspend () -> T,
148+): T = withRetryInternal(maxAttempts, initialDelayMs, block)
149+
144150/**
145151 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
146152 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
147153 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
148154 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
149− * server errors are retried.
155+ * server errors are retried. Exposed as `internal` so the retry/skip rules are
156+ * unit-testable without an HTTP server.
150157 */
151−private suspend fun <T> withRetry(
158+internal suspend fun <T> withRetryInternal(
152159 maxAttempts: Int = 3,
153160 initialDelayMs: Long = 500L,
154161 block: suspend () -> T,
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+20−1
@@ -99,7 +99,26 @@ internal class MessageStore {
9999 fun snapshot(): List<MessageWithParts> = messages.values.toList()
100100
101101 fun seed(initial: List<MessageWithParts>) {
102− for (m in initial) messages[m.info.id] = m
102+ for (m in initial) {
103+ val existing = messages[m.info.id]
104+ if (existing == null) {
105+ messages[m.info.id] = m
106+ } else {
107+ // A part streamed in between subscribe and this initial load may already
108+ // have populated this message (see observeMessages). Merge instead of
109+ // overwriting so that newer streamed part isn't discarded: take the
110+ // snapshot's part order, swap in the streamed version where ids overlap,
111+ // append any streamed-only parts, and adopt the authoritative REST info.
112+ val streamedById = existing.parts.associateBy { it.id }
113+ val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
114+ val ordered = m.parts.map { p -> streamedById[p.id] ?: p }.toMutableList()
115+ for (p in existing.parts) {
116+ if (p.id !in snapshotIds) ordered.add(p)
117+ }
118+ val info = if (existing.info is UnknownMessage) m.info else existing.info
119+ messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
120+ }
121+ }
103122 }
104123
105124 /** Returns true if the state changed (and a new snapshot should be published). */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+21−8
@@ -13,6 +13,7 @@ import soy.iko.opencode.data.repo.ErrorKind
1313import soy.iko.opencode.data.repo.ProfileStore
1414import soy.iko.opencode.data.repo.SettingsStore
1515import soy.iko.opencode.data.repo.classifyError
16+import soy.iko.opencode.data.repo.responseStatusCode
1617import soy.iko.opencode.R
1718import kotlinx.coroutines.CoroutineScope
1819import kotlinx.coroutines.Dispatchers
@@ -91,15 +92,18 @@ class AppContainer(context: Context) {
9192 * the message reflects what actually went wrong without leaking internal URLs/state.
9293 */
9394 fun friendlyError(t: Throwable): String {
94− val msg = t.message.orEmpty()
9595 val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
9696 return when (classifyError(t)) {
9797 ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
9898 string(R.string.error_not_reachable, baseUrl)
9999 ErrorKind.TIMEOUT -> string(R.string.error_timeout)
100100 ErrorKind.SERVER -> string(R.string.error_server)
101− ErrorKind.CLIENT -> msg.ifBlank { string(R.string.error_generic) }
102− ErrorKind.UNKNOWN -> msg.ifBlank { string(R.string.error_generic) }
101+ // Show only the HTTP status (e.g. 401, 404) — never the request URL,
102+ // which a ClientRequestException carries in its message and which we
103+ // promised not to leak (it can include auth or internal paths).
104+ ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
105+ ?: string(R.string.error_generic)
106+ ErrorKind.UNKNOWN -> string(R.string.error_generic)
103107 }
104108 }
105109
@@ -132,11 +136,7 @@ class AppContainer(context: Context) {
132136 }
133137
134138 /** Extract the session id an event pertains to, for message-activity events. */
135− private fun sessionOf(event: BusEvent): String? = when (event) {
136− is MessageUpdated -> event.properties.info.sessionID
137− is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
138− else -> null
139− }
139+ private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
140140
141141 /**
142142 * On cold start, transparently reconnect to the most recently used server so a
@@ -184,3 +184,16 @@ class AppContainer(context: Context) {
184184 _activeConnection.value = null
185185 }
186186}
187+
188+/**
189+ * Extract the session id a message-activity event pertains to, or null for events that
190+ * don't describe message activity. This drives the unread badge: any session that
191+ * receives activity while it isn't the currently-viewed one gets badged. Extracted as
192+ * a top-level `internal` function so the rule is unit-testable without an Android
193+ * [Context] (the surrounding [AppContainer] needs one).
194+ */
195+internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
196+ is MessageUpdated -> event.properties.info.sessionID
197+ is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
198+ else -> null
199+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−1
@@ -114,6 +114,7 @@ fun ChatScreen(
114114 val scope = rememberCoroutineScope()
115115 val shareLauncher = LocalContext.current
116116 val shareLabel = stringResource(R.string.share)
117+ val defaultShareSubject = stringResource(R.string.share_subject)
117118
118119 // Connection profile (with auth) for rendering inline image attachments.
119120 val imageContext = remember(container.activeConnection.value?.profile) {
@@ -223,7 +224,7 @@ fun ChatScreen(
223224 val md = buildConversationMarkdown(messages, sessionTitle)
224225 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
225226 type = "text/markdown"
226− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: "opencode session")
227+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
227228 putExtra(android.content.Intent.EXTRA_TEXT, md)
228229 }
229230 shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel))
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+5−2
@@ -103,9 +103,12 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
103103}
104104
105105private fun formatTokens(tokens: Tokens): String {
106− val dec = java.text.DecimalFormat("#,###")
106+ val dec = java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
107107 return "${dec.format(tokens.input)} in · ${dec.format(tokens.output)} out"
108108}
109109
110110private fun formatCost(cost: Double): String =
111− if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost)
111+ // Locale.US so the formatting is stable regardless of device locale (avoids
112+ // non-ASCII digits or comma decimal separators in a dollar amount).
113+ if (cost < 0.01) String.format(java.util.Locale.US, "$%.4f", cost)
114+ else String.format(java.util.Locale.US, "$%.2f", cost)
app/src/main/res/values/strings.xml+2
@@ -20,6 +20,7 @@
2020 <string name="settings">Settings</string>
2121 <string name="files">Files</string>
2222 <string name="share">Share</string>
23+ <string name="share_subject">opencode session</string>
2324 <string name="send">Send</string>
2425 <string name="stop">Stop</string>
2526 <string name="rename">Rename</string>
@@ -87,6 +88,7 @@
8788
8889 <!-- Errors (view models) -->
8990 <string name="error_generic">Something went wrong.</string>
91+ <string name="error_client_status">Request failed (%1$d).</string>
9092 <string name="error_failed_to_send">Failed to send</string>
9193 <string name="error_agent_reported">The agent reported an error.</string>
9294 <string name="error_failed_command">Failed to run command</string>
app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktnew file+42
@@ -0,0 +1,42 @@
1+package soy.iko.opencode.data.network
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Test
5+
6+class NormalizeBaseUrlTest {
7+
8+ @Test
9+ fun addsHttpSchemeWhenAbsent() {
10+ assertEquals("http://192.168.1.10:4096/", HttpClientFactory.normalizeBaseUrl("192.168.1.10:4096"))
11+ }
12+
13+ @Test
14+ fun preservesHttpScheme() {
15+ assertEquals("http://localhost:4096/", HttpClientFactory.normalizeBaseUrl("http://localhost:4096"))
16+ }
17+
18+ @Test
19+ fun preservesHttpsScheme() {
20+ assertEquals("https://example.com/", HttpClientFactory.normalizeBaseUrl("https://example.com"))
21+ }
22+
23+ @Test
24+ fun appendsTrailingSlash() {
25+ assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl("host"))
26+ }
27+
28+ @Test
29+ fun keepsSingleTrailingSlash() {
30+ assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl("http://host/"))
31+ }
32+
33+ @Test
34+ fun trimsSurroundingWhitespace() {
35+ assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl(" http://host "))
36+ }
37+
38+ @Test
39+ fun keepsPathAfterHost() {
40+ assertEquals("http://host:4096/opencode/", HttpClientFactory.normalizeBaseUrl("host:4096/opencode"))
41+ }
42+}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktnew file+102
@@ -0,0 +1,102 @@
1+package soy.iko.opencode.data.network
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.mock.MockEngine
5+import io.ktor.client.engine.mock.respond
6+import io.ktor.client.plugins.ClientRequestException
7+import io.ktor.client.plugins.ServerResponseException
8+import io.ktor.client.request.get
9+import io.ktor.http.HttpStatusCode
10+import io.ktor.http.headersOf
11+import kotlinx.coroutines.CancellationException
12+import kotlinx.coroutines.test.runTest
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertTrue
15+import org.junit.Test
16+import java.io.IOException
17+
18+class WithRetryTest {
19+
20+ /** Produce the real exception Ktor throws for a status when expectSuccess = true. */
21+ private suspend fun httpError(status: HttpStatusCode): Throwable {
22+ val client = HttpClient(MockEngine { respond("", status, headersOf("Content-Length", "0")) }) {
23+ expectSuccess = true
24+ }
25+ return client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
26+ }
27+
28+ @Test
29+ fun returnsValueOnFirstAttempt() = runTest {
30+ var calls = 0
31+ val result = withRetryInternal { calls++; "ok" }
32+ assertEquals("ok", result)
33+ assertEquals(1, calls)
34+ }
35+
36+ @Test
37+ fun retriesTransientFailureThenSucceeds() = runTest {
38+ var calls = 0
39+ val result = withRetryInternal(initialDelayMs = 0) {
40+ calls++
41+ if (calls < 3) throw IOException("boom")
42+ "ok"
43+ }
44+ assertEquals("ok", result)
45+ assertEquals(3, calls)
46+ }
47+
48+ @Test
49+ fun throwsAfterExhaustingAttempts() = runTest {
50+ var calls = 0
51+ val error = runCatching {
52+ withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
53+ calls++
54+ throw IOException("always")
55+ }
56+ }.exceptionOrNull()!!
57+ assertTrue("expected IOException, got $error", error is IOException)
58+ assertEquals(3, calls)
59+ }
60+
61+ @Test
62+ fun clientErrorIsNotRetried() = runTest {
63+ val notFound = httpError(HttpStatusCode.NotFound)
64+ assertTrue("expected ClientRequestException, got $notFound", notFound is ClientRequestException)
65+ var calls = 0
66+ val error = runCatching {
67+ withRetryInternal(initialDelayMs = 0) {
68+ calls++
69+ throw notFound
70+ }
71+ }.exceptionOrNull()!!
72+ assertEquals("4xx must not be retried", 1, calls)
73+ assertTrue(error is ClientRequestException)
74+ }
75+
76+ @Test
77+ fun serverErrorIsRetried() = runTest {
78+ val serverError = httpError(HttpStatusCode.InternalServerError)
79+ assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
80+ var calls = 0
81+ runCatching {
82+ withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
83+ calls++
84+ throw serverError
85+ }
86+ }
87+ assertEquals("5xx must be retried", 3, calls)
88+ }
89+
90+ @Test
91+ fun cancellationIsRethrownImmediately() = runTest {
92+ var calls = 0
93+ val error = runCatching {
94+ withRetryInternal(initialDelayMs = 0) {
95+ calls++
96+ throw CancellationException("cancel")
97+ }
98+ }.exceptionOrNull()
99+ assertEquals("cancellation must not be retried", 1, calls)
100+ assertTrue("expected CancellationException, got $error", error is CancellationException)
101+ }
102+}
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+66
@@ -13,6 +13,7 @@ import soy.iko.opencode.data.model.MessageWithParts
1313import soy.iko.opencode.data.model.SessionError
1414import soy.iko.opencode.data.model.SessionIdle
1515import soy.iko.opencode.data.model.TextPart
16+import soy.iko.opencode.data.model.UnknownMessage
1617import soy.iko.opencode.data.model.UserMessage
1718
1819class MessageStoreTest {
@@ -53,6 +54,71 @@ class MessageStoreTest {
5354 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
5455 }
5556
57+ // --- seed merge (race between streamed part and initial load) ---
58+
59+ @Test
60+ fun seedMergesStreamedPartsWithSnapshotInsteadOfOverwriting() {
61+ val store = MessageStore()
62+ // A part streams in before the initial load completes, creating a synthetic
63+ // placeholder message.
64+ store.reduce(session, partUpdated("a1", "t1", "streamed"))
65+ // The REST snapshot for the same message arrives.
66+ store.seed(
67+ listOf(
68+ MessageWithParts(
69+ AssistantMessage(id = "a1", sessionID = session),
70+ parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
71+ ),
72+ ),
73+ )
74+ val msg = store.snapshot().single()
75+ assertTrue("info should be the real one", msg.info is AssistantMessage)
76+ // The streamed part (newer) must win over the snapshot's duplicate.
77+ assertEquals("streamed", (msg.parts.single() as TextPart).text)
78+ }
79+
80+ @Test
81+ fun seedAppendsSnapshotOnlyPartsAfterStreamedOnes() {
82+ val store = MessageStore()
83+ store.reduce(session, partUpdated("a1", "t2", "streamed-t2"))
84+ store.seed(
85+ listOf(
86+ MessageWithParts(
87+ AssistantMessage(id = "a1", sessionID = session),
88+ parts = listOf(
89+ TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snap-t1"),
90+ TextPart(id = "t2", messageID = "a1", sessionID = session, text = "snap-t2"),
91+ ),
92+ ),
93+ ),
94+ )
95+ val msg = store.snapshot().single()
96+ val parts = msg.parts
97+ assertEquals(listOf("t1", "t2"), parts.map { it.id })
98+ assertEquals("snap-t1", (parts[0] as TextPart).text)
99+ assertEquals("streamed-t2", (parts[1] as TextPart).text)
100+ }
101+
102+ @Test
103+ fun seedKeepsEventResolvedInfoOverSyntheticSnapshot() {
104+ val store = MessageStore()
105+ // A MessageUpdated event resolved the real info, then a part streamed in.
106+ store.reduce(session, msgUpdated("a1"))
107+ store.reduce(session, partUpdated("a1", "t1", "streamed"))
108+ // A stale snapshot arrives whose info is synthetic; it must not downgrade.
109+ store.seed(
110+ listOf(
111+ MessageWithParts(
112+ UnknownMessage(id = "a1", sessionID = session),
113+ parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
114+ ),
115+ ),
116+ )
117+ val msg = store.snapshot().single()
118+ assertTrue("event-resolved info must be kept", msg.info is AssistantMessage)
119+ assertEquals("streamed", (msg.parts.first() as TextPart).text)
120+ }
121+
56122 // --- MessageUpdated ---
57123
58124 @Test
app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktnew file+56
@@ -0,0 +1,56 @@
1+package soy.iko.opencode.di
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertNull
5+import org.junit.Test
6+import soy.iko.opencode.data.model.AssistantMessage
7+import soy.iko.opencode.data.model.MessagePartRemoved
8+import soy.iko.opencode.data.model.MessagePartUpdated
9+import soy.iko.opencode.data.model.MessageUpdated
10+import soy.iko.opencode.data.model.SessionIdle
11+import soy.iko.opencode.data.model.TextPart
12+
13+class UnreadTrackingTest {
14+
15+ private val session = "s1"
16+
17+ @Test
18+ fun extractsSessionFromMessageUpdated() {
19+ val event = MessageUpdated(MessageUpdated.Props(AssistantMessage(id = "a1", sessionID = session)))
20+ assertEquals(session, sessionOfEvent(event))
21+ }
22+
23+ @Test
24+ fun extractsSessionFromPartSessionId() {
25+ val event = MessagePartUpdated(
26+ MessagePartUpdated.Props(
27+ part = TextPart(id = "p1", messageID = "m1", sessionID = session, text = "hi"),
28+ sessionID = session,
29+ messageID = "m1",
30+ ),
31+ )
32+ assertEquals(session, sessionOfEvent(event))
33+ }
34+
35+ @Test
36+ fun fallsBackToEnvelopeSessionIdWhenPartSessionIdMissing() {
37+ // Some events omit the session id on the nested part; the envelope value must
38+ // still badge the right session.
39+ val event = MessagePartUpdated(
40+ MessagePartUpdated.Props(
41+ part = TextPart(id = "p1", messageID = "m1", sessionID = null, text = "hi"),
42+ sessionID = session,
43+ messageID = "m1",
44+ ),
45+ )
46+ assertEquals(session, sessionOfEvent(event))
47+ }
48+
49+ @Test
50+ fun returnsNullForNonMessageEvents() {
51+ // Idle/error/etc. events describe session lifecycle, not message activity, so
52+ // they must not badge a session.
53+ assertNull(sessionOfEvent(SessionIdle(SessionIdle.Props(sessionID = session))))
54+ assertNull(sessionOfEvent(MessagePartRemoved(MessagePartRemoved.Props(sessionID = session, messageID = "m1", partID = "p1"))))
55+ }
56+}
gradle/libs.versions.toml+1
@@ -38,6 +38,7 @@ ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-conte
3838ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
3939ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
4040ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
41+ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
4142
4243kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
4344kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }