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
50dc098iko
app/src/main/java/soy/iko/opencode/data/model/Message.kt+3
@@ -19,6 +19,7 @@ sealed interface MessageInfo {
19 val time: TimeInfo?
19 val time: TimeInfo?
20}
20}
21
21
22+@Immutable
22@Serializable
23@Serializable
23@SerialName("user")
24@SerialName("user")
24data class UserMessage(
25data class UserMessage(
@@ -27,6 +28,7 @@ data class UserMessage(
27 override val time: TimeInfo? = null,
28 override val time: TimeInfo? = null,
28) : MessageInfo
29) : MessageInfo
29
30
31+@Immutable
30@Serializable
32@Serializable
31@SerialName("assistant")
33@SerialName("assistant")
32data class AssistantMessage(
34data class AssistantMessage(
@@ -45,6 +47,7 @@ data class AssistantMessage(
45}
47}
46
48
47/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
49/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
50+@Immutable
48@Serializable
51@Serializable
49@SerialName("__unknown")
52@SerialName("__unknown")
50data class UnknownMessage(
53data class UnknownMessage(
app/src/main/java/soy/iko/opencode/data/model/Part.kt+8
@@ -1,5 +1,6 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.Serializable
5
6
@@ -18,6 +19,7 @@ sealed interface Part {
18 val sessionID: String?
19 val sessionID: String?
19}
20}
20
21
22+@Immutable
21@Serializable
23@Serializable
22@SerialName("text")
24@SerialName("text")
23data class TextPart(
25data class TextPart(
@@ -30,6 +32,7 @@ data class TextPart(
30 val time: TimeInfo? = null,
32 val time: TimeInfo? = null,
31) : Part
33) : Part
32
34
35+@Immutable
33@Serializable
36@Serializable
34@SerialName("reasoning")
37@SerialName("reasoning")
35data class ReasoningPart(
38data class ReasoningPart(
@@ -40,6 +43,7 @@ data class ReasoningPart(
40 val time: TimeInfo? = null,
43 val time: TimeInfo? = null,
41) : Part
44) : Part
42
45
46+@Immutable
43@Serializable
47@Serializable
44@SerialName("tool")
48@SerialName("tool")
45data class ToolPart(
49data class ToolPart(
@@ -51,6 +55,7 @@ data class ToolPart(
51 val state: ToolState = ToolPending,
55 val state: ToolState = ToolPending,
52) : Part
56) : Part
53
57
58+@Immutable
54@Serializable
59@Serializable
55@SerialName("file")
60@SerialName("file")
56data class FilePart(
61data class FilePart(
@@ -63,6 +68,7 @@ data class FilePart(
63 val source: String? = null,
68 val source: String? = null,
64) : Part
69) : Part
65
70
71+@Immutable
66@Serializable
72@Serializable
67@SerialName("step-start")
73@SerialName("step-start")
68data class StepStartPart(
74data class StepStartPart(
@@ -71,6 +77,7 @@ data class StepStartPart(
71 override val sessionID: String? = null,
77 override val sessionID: String? = null,
72) : Part
78) : Part
73
79
80+@Immutable
74@Serializable
81@Serializable
75@SerialName("step-finish")
82@SerialName("step-finish")
76data class StepFinishPart(
83data class StepFinishPart(
@@ -82,6 +89,7 @@ data class StepFinishPart(
82) : Part
89) : Part
83
90
84/** Fallback for an unrecognized `type`. */
91/** Fallback for an unrecognized `type`. */
92+@Immutable
85@Serializable
93@Serializable
86@SerialName("__unknown")
94@SerialName("__unknown")
87data class UnknownPart(
95data class UnknownPart(
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4import kotlinx.serialization.json.JsonElement
5import kotlinx.serialization.json.JsonElement
5import kotlinx.serialization.json.JsonObject
6import kotlinx.serialization.json.JsonObject
@@ -11,6 +12,7 @@ import kotlinx.serialization.json.jsonArray
11 * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12 * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12 * it's kept as a [JsonElement] and flattened for display.
13 * it's kept as a [JsonElement] and flattened for display.
13 */
14 */
15+@Immutable
14@Serializable
16@Serializable
15data class Permission(
17data class Permission(
16 val id: String,
18 val id: String,
app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+4
@@ -1,5 +1,6 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.Serializable
5import kotlinx.serialization.json.JsonElement
6import kotlinx.serialization.json.JsonElement
@@ -18,6 +19,7 @@ sealed interface ToolState
18@SerialName("pending")
19@SerialName("pending")
19data object ToolPending : ToolState
20data object ToolPending : ToolState
20
21
22+@Immutable
21@Serializable
23@Serializable
22@SerialName("running")
24@SerialName("running")
23data class ToolRunning(
25data class ToolRunning(
@@ -26,6 +28,7 @@ data class ToolRunning(
26 val time: TimeInfo? = null,
28 val time: TimeInfo? = null,
27) : ToolState
29) : ToolState
28
30
31+@Immutable
29@Serializable
32@Serializable
30@SerialName("completed")
33@SerialName("completed")
31data class ToolCompleted(
34data class ToolCompleted(
@@ -35,6 +38,7 @@ data class ToolCompleted(
35 val time: TimeInfo? = null,
38 val time: TimeInfo? = null,
36) : ToolState
39) : ToolState
37
40
41+@Immutable
38@Serializable
42@Serializable
39@SerialName("error")
43@SerialName("error")
40data class ToolError(
44data class ToolError(
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+1−7
@@ -1,6 +1,7 @@
1package soy.iko.opencode.data.network
1package soy.iko.opencode.data.network
2
2
3import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.util.safeExceptionSummary
4import android.util.Log
5import android.util.Log
5import io.ktor.client.HttpClient
6import io.ktor.client.HttpClient
6import io.ktor.client.plugins.timeout
7import io.ktor.client.plugins.timeout
@@ -68,13 +69,6 @@ class EventStreamClient(
68 e is io.ktor.client.plugins.ClientRequestException &&
69 e is io.ktor.client.plugins.ClientRequestException &&
69 (e.response.status.value == 401 || e.response.status.value == 403)
70 (e.response.status.value == 401 || e.response.status.value == 403)
70
71
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−
78 /** Request an immediate reconnect, skipping any in-progress backoff. */
72 /** Request an immediate reconnect, skipping any in-progress backoff. */
79 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
73 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
80
74
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+4−4
@@ -52,7 +52,7 @@ class OpencodeApiClient(private val client: HttpClient) {
52
52
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
54 suspend fun ping() {
54 suspend fun ping() {
55− client.get("global/health")
55+ withRetry { client.get("global/health").body<String>() }
56 }
56 }
57
57
58 suspend fun listSessions(): List<Session> = withRetry {
58 suspend fun listSessions(): List<Session> = withRetry {
@@ -74,7 +74,7 @@ class OpencodeApiClient(private val client: HttpClient) {
74 }
74 }
75
75
76 suspend fun deleteSession(id: String) {
76 suspend fun deleteSession(id: String) {
77− withRetry { client.delete("session/${encode(id)}") }
77+ withRetry { client.delete("session/${encode(id)}").body<String>() }
78 }
78 }
79
79
80 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
80 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
@@ -108,7 +108,7 @@ class OpencodeApiClient(private val client: HttpClient) {
108 }
108 }
109
109
110 suspend fun abort(sessionId: String) {
110 suspend fun abort(sessionId: String) {
111− withRetry { client.post("session/${encode(sessionId)}/abort") }
111+ withRetry { client.post("session/${encode(sessionId)}/abort").body<String>() }
112 }
112 }
113
113
114 /** Invoke a slash-command by name via `POST /session/:id/command`. */
114 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -179,7 +179,7 @@ class OpencodeApiClient(private val client: HttpClient) {
179 client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
179 client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
180 contentType(ContentType.Application.Json)
180 contentType(ContentType.Application.Json)
181 setBody(PermissionReplyBody(response.wire))
181 setBody(PermissionReplyBody(response.wire))
182− }
182+ }.body<String>()
183 }
183 }
184
184
185 // --- Files ---
185 // --- Files ---
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+7−1
@@ -26,8 +26,9 @@ class DraftStore(context: Context, scope: CoroutineScope) {
26
26
27 private val appContext = context.applicationContext
27 private val appContext = context.applicationContext
28 private val prefs by lazy {
28 private val prefs by lazy {
29+ val sp = appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
29 prefsInitialized.set(true)
30 prefsInitialized.set(true)
30− appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
31+ sp
31 }
32 }
32
33
33 /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
34 /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
@@ -146,4 +147,9 @@ class DraftStore(context: Context, scope: CoroutineScope) {
146 }
147 }
147 }
148 }
148 }
149 }
150+
151+ /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
152+ fun shutdown() {
153+ flushExecutor.shutdown()
154+ }
149}
155}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+3−2
@@ -15,6 +15,7 @@ import soy.iko.opencode.data.network.EventStreamClient
15import soy.iko.opencode.data.network.NetworkConfig
15import soy.iko.opencode.data.network.NetworkConfig
16import soy.iko.opencode.data.network.OpencodeApiClient
16import soy.iko.opencode.data.network.OpencodeApiClient
17import soy.iko.opencode.util.runCatchingCancellable
17import soy.iko.opencode.util.runCatchingCancellable
18+import soy.iko.opencode.util.safeExceptionSummary
18import android.util.Log
19import android.util.Log
19import kotlinx.coroutines.flow.Flow
20import kotlinx.coroutines.flow.Flow
20import kotlinx.coroutines.flow.channelFlow
21import kotlinx.coroutines.flow.channelFlow
@@ -89,7 +90,7 @@ class SessionRepository(
89 if (state == EventStreamClient.ConnectionState.Connected) {
90 if (state == EventStreamClient.ConnectionState.Connected) {
90 if (wasConnected) {
91 if (wasConnected) {
91 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
92 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)}") }
93 .getOrDefault(emptyList())
94 .getOrDefault(emptyList())
94 lock.withLock { store.seed(fresh, prune = true) }
95 lock.withLock { store.seed(fresh, prune = true) }
95 publish()
96 publish()
@@ -103,7 +104,7 @@ class SessionRepository(
103 }
104 }
104
105
105 val initial = runCatchingCancellable { api.listMessages(sessionId) }
106 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)}") }
107 .getOrDefault(emptyList())
108 .getOrDefault(emptyList())
108 lock.withLock { store.seed(initial) }
109 lock.withLock { store.seed(initial) }
109 publish()
110 publish()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+3−1
@@ -18,6 +18,7 @@ import soy.iko.opencode.data.repo.classifyError
18import soy.iko.opencode.data.repo.responseStatusCode
18import soy.iko.opencode.data.repo.responseStatusCode
19import soy.iko.opencode.data.network.NetworkConfig
19import soy.iko.opencode.data.network.NetworkConfig
20import soy.iko.opencode.notification.NotificationChannels
20import soy.iko.opencode.notification.NotificationChannels
21+import soy.iko.opencode.util.safeExceptionSummary
21import soy.iko.opencode.notification.SessionNotifications
22import soy.iko.opencode.notification.SessionNotifications
22import soy.iko.opencode.R
23import soy.iko.opencode.R
23import soy.iko.opencode.util.runCatchingCancellable
24import soy.iko.opencode.util.runCatchingCancellable
@@ -173,6 +174,7 @@ class AppContainer(context: Context) {
173 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
174 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
174 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
175 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
175 soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
176 soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
177+ draftStore.shutdown()
176 }
178 }
177
179
178 /**
180 /**
@@ -221,7 +223,7 @@ class AppContainer(context: Context) {
221 activeRuns.add(sid)
223 activeRuns.add(sid)
222 }
224 }
223 }
225 }
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)}") }
225 if (!isActive) break
227 if (!isActive) break
226 delay(NetworkConfig.observerRetryDelayMs)
228 delay(NetworkConfig.observerRetryDelayMs)
227 }
229 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1−3
@@ -76,7 +76,6 @@ import androidx.compose.ui.platform.testTag
76import androidx.compose.ui.res.stringResource
76import androidx.compose.ui.res.stringResource
77import androidx.compose.ui.semantics.Role
77import androidx.compose.ui.semantics.Role
78import androidx.compose.ui.semantics.contentDescription
78import androidx.compose.ui.semantics.contentDescription
79−import androidx.compose.ui.semantics.role
80import androidx.compose.ui.semantics.semantics
79import androidx.compose.ui.semantics.semantics
81import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.text.input.ImeAction
82import androidx.compose.ui.unit.dp
81import androidx.compose.ui.unit.dp
@@ -225,8 +224,7 @@ fun ChatScreen(
225 TopAppBar(
224 TopAppBar(
226 title = {
225 title = {
227 Column(modifier = Modifier
226 Column(modifier = Modifier
228− .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
227+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
229− .semantics { role = Role.Button }
230 ) {
228 ) {
231 Text(sessionTitle ?: stringResource(R.string.session))
229 Text(sessionTitle ?: stringResource(R.string.session))
232 Text(
230 Text(
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−3
@@ -37,7 +37,6 @@ import androidx.compose.ui.draw.clip
37import androidx.compose.ui.platform.LocalContext
37import androidx.compose.ui.platform.LocalContext
38import androidx.compose.ui.semantics.Role
38import androidx.compose.ui.semantics.Role
39import androidx.compose.ui.semantics.contentDescription
39import androidx.compose.ui.semantics.contentDescription
40−import androidx.compose.ui.semantics.role
41import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.semantics.semantics
42import androidx.compose.ui.semantics.stateDescription
41import androidx.compose.ui.semantics.stateDescription
43import androidx.compose.ui.text.font.FontFamily
42import androidx.compose.ui.text.font.FontFamily
@@ -103,10 +102,9 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
103 modifier = Modifier
102 modifier = Modifier
104 .fillMaxWidth()
103 .fillMaxWidth()
105 .defaultMinSize(minHeight = 48.dp)
104 .defaultMinSize(minHeight = 48.dp)
106− .clickable { expanded = !expanded }
105+ .clickable(role = Role.Button) { expanded = !expanded }
107 .padding(vertical = 4.dp)
106 .padding(vertical = 4.dp)
108 .semantics {
107 .semantics {
109− role = Role.Button
110 stateDescription = if (expanded) expandedState else collapsedState
108 stateDescription = if (expanded) expandedState else collapsedState
111 },
109 },
112 verticalAlignment = Alignment.CenterVertically,
110 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
7import androidx.compose.foundation.shape.RoundedCornerShape
7import androidx.compose.foundation.shape.RoundedCornerShape
8import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.Immutable
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.remember
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
@@ -32,6 +33,7 @@ import soy.iko.opencode.data.network.HttpClientFactory
32 * Carries the bits needed to load an image off the opencode server: the base URL (for
33 * Carries the bits needed to load an image off the opencode server: the base URL (for
33 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
34 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
34 */
35 */
36+@Immutable
35data class ImageLoadContext(
37data class ImageLoadContext(
36 val baseUrl: String,
38 val baseUrl: String,
37 val basicAuthHeader: String?,
39 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
63import androidx.compose.ui.platform.testTag
63import androidx.compose.ui.platform.testTag
64import androidx.compose.ui.semantics.Role
64import androidx.compose.ui.semantics.Role
65import androidx.compose.ui.semantics.contentDescription
65import androidx.compose.ui.semantics.contentDescription
66−import androidx.compose.ui.semantics.role
67import androidx.compose.ui.semantics.semantics
66import androidx.compose.ui.semantics.semantics
68import androidx.compose.ui.text.input.ImeAction
67import androidx.compose.ui.text.input.ImeAction
69import androidx.compose.ui.text.style.TextOverflow
68import androidx.compose.ui.text.style.TextOverflow
@@ -124,8 +123,7 @@ fun SessionListScreen(
124 Box {
123 Box {
125 Row(
124 Row(
126 modifier = Modifier
125 modifier = Modifier
127− .clickable { showServerMenu = true }
128− .semantics { role = Role.Button },
126+ .clickable(role = Role.Button) { showServerMenu = true },
129 verticalAlignment = Alignment.CenterVertically,
127 verticalAlignment = Alignment.CenterVertically,
130 ) {
128 ) {
131 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
129 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 {
15} catch (e: Exception) {
15} catch (e: Exception) {
16 Result.failure(e)
16 Result.failure(e)
17}
17}
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+}