git.iko.soy/opencode-android
on master help sign in
Fix unguarded SSE collector crashes, Settings a11y (Switch toggleable, RadioButton duplicate handler, touch target size), Toast stacking, RemoteImage loading a11y, and swipe-delete icon description
commit2f1a863e7ce61a2f3760c49b0cdc0b3a4aa369f7 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 10:59 (3 months ago) parent2db74cb
7 files changed+91−42
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+35−22 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+8−1 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+24−12 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+14−3
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+35−22
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.SharingStarted
2828import kotlinx.coroutines.flow.StateFlow
2929import kotlinx.coroutines.flow.asStateFlow
3030import kotlinx.coroutines.flow.catch
31+import kotlinx.coroutines.CancellationException
3132import kotlinx.coroutines.flow.collectLatest
3233import kotlinx.coroutines.flow.debounce
3334import kotlinx.coroutines.flow.drop
@@ -266,25 +267,31 @@ class ChatViewModel(
266267 _error.value = null
267268 _failedDraft.value = null
268269 _selectedAgent.value = null
269− conn.events.events.collect { event ->
270− if (SessionRepository.isIdle(event, sessionId)) _running.value = false
271− if (SessionRepository.isError(event, sessionId)) {
272− _running.value = false
273− _error.value = container.string(R.string.error_agent_reported)
274− }
275− when (event) {
276− is PermissionUpdated ->
277− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
278− is PermissionReplied ->
279− if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
280− is SessionUpdated ->
281− if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
282− is SessionDeleted ->
283− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
284− _sessionDeleted.value = true
285− }
286− else -> {}
270+ try {
271+ conn.events.events.collect { event ->
272+ if (SessionRepository.isIdle(event, sessionId)) _running.value = false
273+ if (SessionRepository.isError(event, sessionId)) {
274+ _running.value = false
275+ _error.value = container.string(R.string.error_agent_reported)
276+ }
277+ when (event) {
278+ is PermissionUpdated ->
279+ if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
280+ is PermissionReplied ->
281+ if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
282+ is SessionUpdated ->
283+ if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
284+ is SessionDeleted ->
285+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
286+ _sessionDeleted.value = true
287+ }
288+ else -> {}
289+ }
287290 }
291+ } catch (e: CancellationException) {
292+ throw e
293+ } catch (e: Exception) {
294+ Log.w("ChatViewModel", "SSE event collector error", e)
288295 }
289296 }
290297 }
@@ -335,11 +342,17 @@ class ChatViewModel(
335342 viewModelScope.launch {
336343 container.activeConnection.collectLatest { conn ->
337344 if (conn == null) return@collectLatest
338− conn.events.state.collect { state ->
339− if ((state == EventStreamClient.ConnectionState.Disconnected ||
340− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
341− _running.value = false
345+ try {
346+ conn.events.state.collect { state ->
347+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
348+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
349+ _running.value = false
350+ }
342351 }
352+ } catch (e: CancellationException) {
353+ throw e
354+ } catch (e: Exception) {
355+ Log.w("ChatViewModel", "SSE state collector error", e)
343356 }
344357 }
345358 }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+8−1
@@ -159,5 +159,12 @@ private fun extractCode(content: String, node: ASTNode): String {
159159internal fun copyToClipboard(context: Context, label: String, text: String = label) {
160160 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
161161 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
162− Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
162+ showToast(context, context.getString(R.string.copied))
163+}
164+
165+private var lastToast: Toast? = null
166+
167+internal fun showToast(context: Context, message: String) {
168+ lastToast?.cancel()
169+ lastToast = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() }
163170}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−1
@@ -14,6 +14,8 @@ import androidx.compose.ui.draw.clip
1414import androidx.compose.ui.layout.ContentScale
1515import androidx.compose.ui.platform.LocalContext
1616import androidx.compose.ui.res.stringResource
17+import androidx.compose.ui.semantics.contentDescription
18+import androidx.compose.ui.semantics.semantics
1719import androidx.compose.ui.unit.dp
1820import coil.compose.SubcomposeAsyncImage
1921import coil.request.ImageRequest
@@ -111,7 +113,10 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
111113 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
112114 contentAlignment = Alignment.Center,
113115 ) {
114− CircularProgressIndicator()
116+ val loadingLabel = stringResource(R.string.loading)
117+ CircularProgressIndicator(
118+ modifier = Modifier.semantics { contentDescription = loadingLabel },
119+ )
115120 }
116121 },
117122 error = {
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+2−2
@@ -1,7 +1,7 @@
11package soy.iko.opencode.ui.file
22
33import android.content.Intent
4−import android.widget.Toast
4+import soy.iko.opencode.ui.components.showToast
55import androidx.compose.foundation.horizontalScroll
66import androidx.compose.foundation.layout.Box
77import androidx.compose.foundation.layout.Row
@@ -80,7 +80,7 @@ fun FileViewScreen(
8080 }
8181 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
8282 .onFailure {
83− Toast.makeText(context, context.getString(R.string.no_share_app), Toast.LENGTH_SHORT).show()
83+ showToast(context, context.getString(R.string.no_share_app))
8484 }
8585 }) {
8686 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−1
@@ -441,9 +441,10 @@ private fun SwipeDeleteBackground() {
441441 .padding(horizontal = 20.dp),
442442 contentAlignment = Alignment.CenterEnd,
443443 ) {
444+ val deleteLabel = stringResource(R.string.delete)
444445 Icon(
445446 Icons.Filled.Delete,
446− contentDescription = null,
447+ contentDescription = deleteLabel,
447448 tint = MaterialTheme.colorScheme.onErrorContainer,
448449 )
449450 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+24−12
@@ -13,6 +13,8 @@ import soy.iko.opencode.data.network.NetworkConfig
1313import soy.iko.opencode.di.AppContainer
1414import soy.iko.opencode.R
1515import soy.iko.opencode.util.runCatchingCancellable
16+import android.util.Log
17+import kotlinx.coroutines.CancellationException
1618import kotlinx.coroutines.ExperimentalCoroutinesApi
1719import kotlinx.coroutines.Job
1820import kotlinx.coroutines.flow.MutableStateFlow
@@ -121,18 +123,19 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
121123 // initial load (which may have run before auto-reconnect finished and
122124 // found no connection) doesn't leave a stale "not connected" error.
123125 refresh()
124− conn.events.events.collect { event ->
125− when (event) {
126− is SessionUpdated -> {
127− val session = event.properties.info
128− _state.update { s ->
129− val updated = (s.sessions.filterNot { it.id == session.id } + session)
130− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
131− s.copy(sessions = updated)
126+ try {
127+ conn.events.events.collect { event ->
128+ when (event) {
129+ is SessionUpdated -> {
130+ val session = event.properties.info
131+ _state.update { s ->
132+ val updated = (s.sessions.filterNot { it.id == session.id } + session)
133+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
134+ s.copy(sessions = updated)
135+ }
136+ loadPreview(session.id)
132137 }
133− loadPreview(session.id)
134− }
135− is SessionDeleted -> {
138+ is SessionDeleted -> {
136139 val id = event.properties.info?.id ?: event.properties.sessionID
137140 if (id != null) {
138141 synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
@@ -145,8 +148,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
145148 }
146149 }
147150 }
148− else -> {}
151+ else -> {}
152+ }
149153 }
154+ } catch (e: CancellationException) {
155+ throw e
156+ } catch (e: Exception) {
157+ Log.w("SessionListVM", "SSE event collector error", e)
150158 }
151159 }
152160 }
@@ -209,6 +217,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
209217 try {
210218 val preview = fetchSessionPreview(api, sessionId) ?: return@launch
211219 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
220+ } catch (e: CancellationException) {
221+ throw e
222+ } catch (e: Exception) {
223+ Log.w("SessionListVM", "Preview load failed for $sessionId", e)
212224 } finally {
213225 if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
214226 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+14−3
@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.Arrangement
77import androidx.compose.foundation.layout.Column
88import androidx.compose.foundation.layout.Row
99import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.defaultMinSize
1011import androidx.compose.foundation.layout.fillMaxWidth
1112import androidx.compose.foundation.layout.padding
1213import androidx.compose.foundation.layout.size
1314import androidx.compose.foundation.layout.widthIn
1415import androidx.compose.foundation.selection.selectable
16+import androidx.compose.foundation.selection.toggleable
1517import androidx.compose.material.icons.Icons
1618import androidx.compose.material.icons.automirrored.filled.ArrowBack
1719import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -100,7 +102,14 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
100102
101103 Spacer(Modifier.size(8.dp))
102104 Row(
103− modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
105+ modifier = Modifier
106+ .fillMaxWidth()
107+ .toggleable(
108+ value = dynamicColor,
109+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
110+ role = Role.Switch,
111+ )
112+ .padding(vertical = 8.dp),
104113 verticalAlignment = Alignment.CenterVertically,
105114 ) {
106115 Column(modifier = Modifier.weight(1f)) {
@@ -113,7 +122,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
113122 }
114123 Switch(
115124 checked = dynamicColor,
116− onCheckedChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
125+ onCheckedChange = null,
117126 )
118127 }
119128
@@ -142,6 +151,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
142151 Row(
143152 modifier = Modifier
144153 .fillMaxWidth()
154+ .defaultMinSize(minHeight = 48.dp)
145155 .padding(top = 8.dp)
146156 .clickable(role = Role.Button) { onManageServers() }
147157 .padding(vertical = 8.dp),
@@ -176,6 +186,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
176186 Row(
177187 modifier = Modifier
178188 .fillMaxWidth()
189+ .defaultMinSize(minHeight = 48.dp)
179190 .clickable(role = Role.Button) { onOpenDiagnostics() }
180191 .padding(vertical = 8.dp),
181192 verticalAlignment = Alignment.CenterVertically,
@@ -198,7 +209,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
198209 horizontalArrangement = Arrangement.spacedBy(8.dp),
199210 verticalAlignment = Alignment.CenterVertically,
200211 ) {
201− RadioButton(selected = selected, onClick = onSelect)
212+ RadioButton(selected = selected, onClick = null)
202213 Text(
203214 when (mode) {
204215 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)