git.iko.soy/opencode-android
on master help sign in
Fix HTTP response body leaks, DraftStore race and executor leak, scrub URLs from logs, add @Immutable for Compose stability, and consolidate clickable role semantics
commit50dc098fd3b00c782dc2e519c403684f7ee3500c authoriko <mail@iko.soy> dateTue 30 Jun 2026, 11:10 (3 months ago) parent2f1a863
14 files changed+51−24
app/src/main/java/soy/iko/opencode/data/model/Message.kt+3 app/src/main/java/soy/iko/opencode/data/model/Part.kt+8 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+2 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+4 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+1−7 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+4−4 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+7−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+3−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+3−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1−3 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−3 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−3 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+11
app/src/main/java/soy/iko/opencode/data/model/Message.kt+3
@@ -19,6 +19,7 @@ sealed interface MessageInfo {
1919 val time: TimeInfo?
2020}
2121
22+@Immutable
2223@Serializable
2324@SerialName("user")
2425data class UserMessage(
@@ -27,6 +28,7 @@ data class UserMessage(
2728 override val time: TimeInfo? = null,
2829) : MessageInfo
2930
31+@Immutable
3032@Serializable
3133@SerialName("assistant")
3234data class AssistantMessage(
@@ -45,6 +47,7 @@ data class AssistantMessage(
4547}
4648
4749/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
50+@Immutable
4851@Serializable
4952@SerialName("__unknown")
5053data class UnknownMessage(
app/src/main/java/soy/iko/opencode/data/model/Part.kt+8
@@ -1,5 +1,6 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.SerialName
45import kotlinx.serialization.Serializable
56
@@ -18,6 +19,7 @@ sealed interface Part {
1819 val sessionID: String?
1920}
2021
22+@Immutable
2123@Serializable
2224@SerialName("text")
2325data class TextPart(
@@ -30,6 +32,7 @@ data class TextPart(
3032 val time: TimeInfo? = null,
3133) : Part
3234
35+@Immutable
3336@Serializable
3437@SerialName("reasoning")
3538data class ReasoningPart(
@@ -40,6 +43,7 @@ data class ReasoningPart(
4043 val time: TimeInfo? = null,
4144) : Part
4245
46+@Immutable
4347@Serializable
4448@SerialName("tool")
4549data class ToolPart(
@@ -51,6 +55,7 @@ data class ToolPart(
5155 val state: ToolState = ToolPending,
5256) : Part
5357
58+@Immutable
5459@Serializable
5560@SerialName("file")
5661data class FilePart(
@@ -63,6 +68,7 @@ data class FilePart(
6368 val source: String? = null,
6469) : Part
6570
71+@Immutable
6672@Serializable
6773@SerialName("step-start")
6874data class StepStartPart(
@@ -71,6 +77,7 @@ data class StepStartPart(
7177 override val sessionID: String? = null,
7278) : Part
7379
80+@Immutable
7481@Serializable
7582@SerialName("step-finish")
7683data class StepFinishPart(
@@ -82,6 +89,7 @@ data class StepFinishPart(
8289) : Part
8390
8491/** Fallback for an unrecognized `type`. */
92+@Immutable
8593@Serializable
8694@SerialName("__unknown")
8795data class UnknownPart(
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.Serializable
45import kotlinx.serialization.json.JsonElement
56import kotlinx.serialization.json.JsonObject
@@ -11,6 +12,7 @@ import kotlinx.serialization.json.jsonArray
1112 * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
1213 * it's kept as a [JsonElement] and flattened for display.
1314 */
15+@Immutable
1416@Serializable
1517data class Permission(
1618 val id: String,
app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+4
@@ -1,5 +1,6 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.SerialName
45import kotlinx.serialization.Serializable
56import kotlinx.serialization.json.JsonElement
@@ -18,6 +19,7 @@ sealed interface ToolState
1819@SerialName("pending")
1920data object ToolPending : ToolState
2021
22+@Immutable
2123@Serializable
2224@SerialName("running")
2325data class ToolRunning(
@@ -26,6 +28,7 @@ data class ToolRunning(
2628 val time: TimeInfo? = null,
2729) : ToolState
2830
31+@Immutable
2932@Serializable
3033@SerialName("completed")
3134data class ToolCompleted(
@@ -35,6 +38,7 @@ data class ToolCompleted(
3538 val time: TimeInfo? = null,
3639) : ToolState
3740
41+@Immutable
3842@Serializable
3943@SerialName("error")
4044data class ToolError(
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+1−7
@@ -1,6 +1,7 @@
11package soy.iko.opencode.data.network
22
33import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.util.safeExceptionSummary
45import android.util.Log
56import io.ktor.client.HttpClient
67import io.ktor.client.plugins.timeout
@@ -68,13 +69,6 @@ class EventStreamClient(
6869 e is io.ktor.client.plugins.ClientRequestException &&
6970 (e.response.status.value == 401 || e.response.status.value == 403)
7071
71− /** A log-safe exception summary that avoids leaking the request URL (which
72− * ClientRequestException embeds in its message and may contain auth or paths). */
73− private fun safeExceptionSummary(e: Throwable): String {
74− val status = (e as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
75− return if (status != null) "${e.javaClass.simpleName}($status)" else e.javaClass.simpleName
76− }
77−
7872 /** Request an immediate reconnect, skipping any in-progress backoff. */
7973 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
8074
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+4−4
@@ -52,7 +52,7 @@ class OpencodeApiClient(private val client: HttpClient) {
5252
5353 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
5454 suspend fun ping() {
55− client.get("global/health")
55+ withRetry { client.get("global/health").body<String>() }
5656 }
5757
5858 suspend fun listSessions(): List<Session> = withRetry {
@@ -74,7 +74,7 @@ class OpencodeApiClient(private val client: HttpClient) {
7474 }
7575
7676 suspend fun deleteSession(id: String) {
77− withRetry { client.delete("session/${encode(id)}") }
77+ withRetry { client.delete("session/${encode(id)}").body<String>() }
7878 }
7979
8080 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
@@ -108,7 +108,7 @@ class OpencodeApiClient(private val client: HttpClient) {
108108 }
109109
110110 suspend fun abort(sessionId: String) {
111− withRetry { client.post("session/${encode(sessionId)}/abort") }
111+ withRetry { client.post("session/${encode(sessionId)}/abort").body<String>() }
112112 }
113113
114114 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -179,7 +179,7 @@ class OpencodeApiClient(private val client: HttpClient) {
179179 client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
180180 contentType(ContentType.Application.Json)
181181 setBody(PermissionReplyBody(response.wire))
182− }
182+ }.body<String>()
183183 }
184184
185185 // --- Files ---
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+7−1
@@ -26,8 +26,9 @@ class DraftStore(context: Context, scope: CoroutineScope) {
2626
2727 private val appContext = context.applicationContext
2828 private val prefs by lazy {
29+ val sp = appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
2930 prefsInitialized.set(true)
30− appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
31+ sp
3132 }
3233
3334 /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
@@ -146,4 +147,9 @@ class DraftStore(context: Context, scope: CoroutineScope) {
146147 }
147148 }
148149 }
150+
151+ /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
152+ fun shutdown() {
153+ flushExecutor.shutdown()
154+ }
149155}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+3−2
@@ -15,6 +15,7 @@ import soy.iko.opencode.data.network.EventStreamClient
1515import soy.iko.opencode.data.network.NetworkConfig
1616import soy.iko.opencode.data.network.OpencodeApiClient
1717import soy.iko.opencode.util.runCatchingCancellable
18+import soy.iko.opencode.util.safeExceptionSummary
1819import android.util.Log
1920import kotlinx.coroutines.flow.Flow
2021import kotlinx.coroutines.flow.channelFlow
@@ -89,7 +90,7 @@ class SessionRepository(
8990 if (state == EventStreamClient.ConnectionState.Connected) {
9091 if (wasConnected) {
9192 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
92− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE", it) }
93+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
9394 .getOrDefault(emptyList())
9495 lock.withLock { store.seed(fresh, prune = true) }
9596 publish()
@@ -103,7 +104,7 @@ class SessionRepository(
103104 }
104105
105106 val initial = runCatchingCancellable { api.listMessages(sessionId) }
106− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
107+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
107108 .getOrDefault(emptyList())
108109 lock.withLock { store.seed(initial) }
109110 publish()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+3−1
@@ -18,6 +18,7 @@ import soy.iko.opencode.data.repo.classifyError
1818import soy.iko.opencode.data.repo.responseStatusCode
1919import soy.iko.opencode.data.network.NetworkConfig
2020import soy.iko.opencode.notification.NotificationChannels
21+import soy.iko.opencode.util.safeExceptionSummary
2122import soy.iko.opencode.notification.SessionNotifications
2223import soy.iko.opencode.R
2324import soy.iko.opencode.util.runCatchingCancellable
@@ -173,6 +174,7 @@ class AppContainer(context: Context) {
173174 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
174175 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
175176 soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
177+ draftStore.shutdown()
176178 }
177179
178180 /**
@@ -221,7 +223,7 @@ class AppContainer(context: Context) {
221223 activeRuns.add(sid)
222224 }
223225 }
224− } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry", it) }
226+ } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
225227 if (!isActive) break
226228 delay(NetworkConfig.observerRetryDelayMs)
227229 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1−3
@@ -76,7 +76,6 @@ import androidx.compose.ui.platform.testTag
7676import androidx.compose.ui.res.stringResource
7777import androidx.compose.ui.semantics.Role
7878import androidx.compose.ui.semantics.contentDescription
79−import androidx.compose.ui.semantics.role
8079import androidx.compose.ui.semantics.semantics
8180import androidx.compose.ui.text.input.ImeAction
8281import androidx.compose.ui.unit.dp
@@ -225,8 +224,7 @@ fun ChatScreen(
225224 TopAppBar(
226225 title = {
227226 Column(modifier = Modifier
228− .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
229− .semantics { role = Role.Button }
227+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
230228 ) {
231229 Text(sessionTitle ?: stringResource(R.string.session))
232230 Text(
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−3
@@ -37,7 +37,6 @@ import androidx.compose.ui.draw.clip
3737import androidx.compose.ui.platform.LocalContext
3838import androidx.compose.ui.semantics.Role
3939import androidx.compose.ui.semantics.contentDescription
40−import androidx.compose.ui.semantics.role
4140import androidx.compose.ui.semantics.semantics
4241import androidx.compose.ui.semantics.stateDescription
4342import androidx.compose.ui.text.font.FontFamily
@@ -103,10 +102,9 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
103102 modifier = Modifier
104103 .fillMaxWidth()
105104 .defaultMinSize(minHeight = 48.dp)
106− .clickable { expanded = !expanded }
105+ .clickable(role = Role.Button) { expanded = !expanded }
107106 .padding(vertical = 4.dp)
108107 .semantics {
109− role = Role.Button
110108 stateDescription = if (expanded) expandedState else collapsedState
111109 },
112110 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+2
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.heightIn
77import androidx.compose.foundation.shape.RoundedCornerShape
88import androidx.compose.material3.MaterialTheme
99import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.Immutable
1011import androidx.compose.runtime.remember
1112import androidx.compose.ui.Alignment
1213import androidx.compose.ui.Modifier
@@ -32,6 +33,7 @@ import soy.iko.opencode.data.network.HttpClientFactory
3233 * Carries the bits needed to load an image off the opencode server: the base URL (for
3334 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
3435 */
36+@Immutable
3537data class ImageLoadContext(
3638 val baseUrl: String,
3739 val basicAuthHeader: String?,
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−3
@@ -63,7 +63,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
6363import androidx.compose.ui.platform.testTag
6464import androidx.compose.ui.semantics.Role
6565import androidx.compose.ui.semantics.contentDescription
66−import androidx.compose.ui.semantics.role
6766import androidx.compose.ui.semantics.semantics
6867import androidx.compose.ui.text.input.ImeAction
6968import androidx.compose.ui.text.style.TextOverflow
@@ -124,8 +123,7 @@ fun SessionListScreen(
124123 Box {
125124 Row(
126125 modifier = Modifier
127− .clickable { showServerMenu = true }
128− .semantics { role = Role.Button },
126+ .clickable(role = Role.Button) { showServerMenu = true },
129127 verticalAlignment = Alignment.CenterVertically,
130128 ) {
131129 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
app/src/main/java/soy/iko/opencode/util/Coroutines.kt+11
@@ -15,3 +15,14 @@ inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
1515} catch (e: Exception) {
1616 Result.failure(e)
1717}
18+
19+/**
20+ * A log-safe exception summary that avoids leaking the request URL (which
21+ * [ClientRequestException] embeds in its message and may contain auth or paths).
22+ * Use this instead of logging the full exception object when it may originate
23+ * from a Ktor HTTP call.
24+ */
25+fun safeExceptionSummary(e: Throwable): String {
26+ val status = (e as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
27+ return if (status != null) "${e.javaClass.simpleName}($status)" else e.javaClass.simpleName
28+}