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
2f1a863iko
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+35−22
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.SharingStarted
28import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.StateFlow
29import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.asStateFlow
30import kotlinx.coroutines.flow.catch
30import kotlinx.coroutines.flow.catch
31+import kotlinx.coroutines.CancellationException
31import kotlinx.coroutines.flow.collectLatest
32import kotlinx.coroutines.flow.collectLatest
32import kotlinx.coroutines.flow.debounce
33import kotlinx.coroutines.flow.debounce
33import kotlinx.coroutines.flow.drop
34import kotlinx.coroutines.flow.drop
@@ -266,25 +267,31 @@ class ChatViewModel(
266 _error.value = null
267 _error.value = null
267 _failedDraft.value = null
268 _failedDraft.value = null
268 _selectedAgent.value = null
269 _selectedAgent.value = null
270+ try {
269− conn.events.events.collect { event ->
271+ conn.events.events.collect { event ->
270− if (SessionRepository.isIdle(event, sessionId)) _running.value = false
272+ if (SessionRepository.isIdle(event, sessionId)) _running.value = false
271− if (SessionRepository.isError(event, sessionId)) {
273+ if (SessionRepository.isError(event, sessionId)) {
272− _running.value = false
274+ _running.value = false
273− _error.value = container.string(R.string.error_agent_reported)
275+ _error.value = container.string(R.string.error_agent_reported)
274− }
276+ }
275− when (event) {
277+ when (event) {
276− is PermissionUpdated ->
278+ is PermissionUpdated ->
277− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
279+ if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
278− is PermissionReplied ->
280+ is PermissionReplied ->
279− if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
281+ if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
280− is SessionUpdated ->
282+ is SessionUpdated ->
281− if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
283+ if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
282− is SessionDeleted ->
284+ is SessionDeleted ->
283− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
285+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
284− _sessionDeleted.value = true
286+ _sessionDeleted.value = true
285− }
287+ }
286− else -> {}
288+ else -> {}
289+ }
287 }
290 }
291+ } catch (e: CancellationException) {
292+ throw e
293+ } catch (e: Exception) {
294+ Log.w("ChatViewModel", "SSE event collector error", e)
288 }
295 }
289 }
296 }
290 }
297 }
@@ -335,11 +342,17 @@ class ChatViewModel(
335 viewModelScope.launch {
342 viewModelScope.launch {
336 container.activeConnection.collectLatest { conn ->
343 container.activeConnection.collectLatest { conn ->
337 if (conn == null) return@collectLatest
344 if (conn == null) return@collectLatest
345+ try {
338− conn.events.state.collect { state ->
346+ conn.events.state.collect { state ->
339− if ((state == EventStreamClient.ConnectionState.Disconnected ||
347+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
340− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
348+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
341− _running.value = false
349+ _running.value = false
350+ }
342 }
351 }
352+ } catch (e: CancellationException) {
353+ throw e
354+ } catch (e: Exception) {
355+ Log.w("ChatViewModel", "SSE state collector error", e)
343 }
356 }
344 }
357 }
345 }
358 }
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 {
159internal fun copyToClipboard(context: Context, label: String, text: String = label) {
159internal fun copyToClipboard(context: Context, label: String, text: String = label) {
160 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
160 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
161 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
161 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() }
163}
170}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−1
@@ -14,6 +14,8 @@ import androidx.compose.ui.draw.clip
14import androidx.compose.ui.layout.ContentScale
14import androidx.compose.ui.layout.ContentScale
15import androidx.compose.ui.platform.LocalContext
15import androidx.compose.ui.platform.LocalContext
16import androidx.compose.ui.res.stringResource
16import androidx.compose.ui.res.stringResource
17+import androidx.compose.ui.semantics.contentDescription
18+import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.unit.dp
19import androidx.compose.ui.unit.dp
18import coil.compose.SubcomposeAsyncImage
20import coil.compose.SubcomposeAsyncImage
19import coil.request.ImageRequest
21import coil.request.ImageRequest
@@ -111,7 +113,10 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
111 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
113 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
112 contentAlignment = Alignment.Center,
114 contentAlignment = Alignment.Center,
113 ) {
115 ) {
116+ val loadingLabel = stringResource(R.string.loading)
114− CircularProgressIndicator()
117+ CircularProgressIndicator(
118+ modifier = Modifier.semantics { contentDescription = loadingLabel },
119+ )
115 }
120 }
116 },
121 },
117 error = {
122 error = {
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+2−2
@@ -1,7 +1,7 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3import android.content.Intent
3import android.content.Intent
4−import android.widget.Toast
4+import soy.iko.opencode.ui.components.showToast
5import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
@@ -80,7 +80,7 @@ fun FileViewScreen(
80 }
80 }
81 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
81 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
82 .onFailure {
82 .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))
84 }
84 }
85 }) {
85 }) {
86 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
86 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() {
441 .padding(horizontal = 20.dp),
441 .padding(horizontal = 20.dp),
442 contentAlignment = Alignment.CenterEnd,
442 contentAlignment = Alignment.CenterEnd,
443 ) {
443 ) {
444+ val deleteLabel = stringResource(R.string.delete)
444 Icon(
445 Icon(
445 Icons.Filled.Delete,
446 Icons.Filled.Delete,
446− contentDescription = null,
447+ contentDescription = deleteLabel,
447 tint = MaterialTheme.colorScheme.onErrorContainer,
448 tint = MaterialTheme.colorScheme.onErrorContainer,
448 )
449 )
449 }
450 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+24−12
@@ -13,6 +13,8 @@ import soy.iko.opencode.data.network.NetworkConfig
13import soy.iko.opencode.di.AppContainer
13import soy.iko.opencode.di.AppContainer
14import soy.iko.opencode.R
14import soy.iko.opencode.R
15import soy.iko.opencode.util.runCatchingCancellable
15import soy.iko.opencode.util.runCatchingCancellable
16+import android.util.Log
17+import kotlinx.coroutines.CancellationException
16import kotlinx.coroutines.ExperimentalCoroutinesApi
18import kotlinx.coroutines.ExperimentalCoroutinesApi
17import kotlinx.coroutines.Job
19import kotlinx.coroutines.Job
18import kotlinx.coroutines.flow.MutableStateFlow
20import kotlinx.coroutines.flow.MutableStateFlow
@@ -121,18 +123,19 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
121 // initial load (which may have run before auto-reconnect finished and
123 // initial load (which may have run before auto-reconnect finished and
122 // found no connection) doesn't leave a stale "not connected" error.
124 // found no connection) doesn't leave a stale "not connected" error.
123 refresh()
125 refresh()
126+ try {
124− conn.events.events.collect { event ->
127+ conn.events.events.collect { event ->
125− when (event) {
128+ when (event) {
126− is SessionUpdated -> {
129+ is SessionUpdated -> {
127− val session = event.properties.info
130+ val session = event.properties.info
128− _state.update { s ->
131+ _state.update { s ->
129− val updated = (s.sessions.filterNot { it.id == session.id } + session)
132+ val updated = (s.sessions.filterNot { it.id == session.id } + session)
130− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
133+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
131− s.copy(sessions = updated)
134+ s.copy(sessions = updated)
135+ }
136+ loadPreview(session.id)
132 }
137 }
133− loadPreview(session.id)
134− }
135− is SessionDeleted -> {
138+ is SessionDeleted -> {
136 val id = event.properties.info?.id ?: event.properties.sessionID
139 val id = event.properties.info?.id ?: event.properties.sessionID
137 if (id != null) {
140 if (id != null) {
138 synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
141 synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
@@ -145,8 +148,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
145 }
148 }
146 }
149 }
147 }
150 }
148− else -> {}
151+ else -> {}
152+ }
149 }
153 }
154+ } catch (e: CancellationException) {
155+ throw e
156+ } catch (e: Exception) {
157+ Log.w("SessionListVM", "SSE event collector error", e)
150 }
158 }
151 }
159 }
152 }
160 }
@@ -209,6 +217,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
209 try {
217 try {
210 val preview = fetchSessionPreview(api, sessionId) ?: return@launch
218 val preview = fetchSessionPreview(api, sessionId) ?: return@launch
211 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
219 _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)
212 } finally {
224 } finally {
213 if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
225 if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
214 }
226 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+14−3
@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.selection.selectable
15import androidx.compose.foundation.selection.selectable
16+import androidx.compose.foundation.selection.toggleable
15import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
19import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -100,7 +102,14 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
100
102
101 Spacer(Modifier.size(8.dp))
103 Spacer(Modifier.size(8.dp))
102 Row(
104 Row(
105+ modifier = Modifier
106+ .fillMaxWidth()
107+ .toggleable(
108+ value = dynamicColor,
109+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
110+ role = Role.Switch,
111+ )
103− modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
112+ .padding(vertical = 8.dp),
104 verticalAlignment = Alignment.CenterVertically,
113 verticalAlignment = Alignment.CenterVertically,
105 ) {
114 ) {
106 Column(modifier = Modifier.weight(1f)) {
115 Column(modifier = Modifier.weight(1f)) {
@@ -113,7 +122,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
113 }
122 }
114 Switch(
123 Switch(
115 checked = dynamicColor,
124 checked = dynamicColor,
116− onCheckedChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
125+ onCheckedChange = null,
117 )
126 )
118 }
127 }
119
128
@@ -142,6 +151,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
142 Row(
151 Row(
143 modifier = Modifier
152 modifier = Modifier
144 .fillMaxWidth()
153 .fillMaxWidth()
154+ .defaultMinSize(minHeight = 48.dp)
145 .padding(top = 8.dp)
155 .padding(top = 8.dp)
146 .clickable(role = Role.Button) { onManageServers() }
156 .clickable(role = Role.Button) { onManageServers() }
147 .padding(vertical = 8.dp),
157 .padding(vertical = 8.dp),
@@ -176,6 +186,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
176 Row(
186 Row(
177 modifier = Modifier
187 modifier = Modifier
178 .fillMaxWidth()
188 .fillMaxWidth()
189+ .defaultMinSize(minHeight = 48.dp)
179 .clickable(role = Role.Button) { onOpenDiagnostics() }
190 .clickable(role = Role.Button) { onOpenDiagnostics() }
180 .padding(vertical = 8.dp),
191 .padding(vertical = 8.dp),
181 verticalAlignment = Alignment.CenterVertically,
192 verticalAlignment = Alignment.CenterVertically,
@@ -198,7 +209,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
198 horizontalArrangement = Arrangement.spacedBy(8.dp),
209 horizontalArrangement = Arrangement.spacedBy(8.dp),
199 verticalAlignment = Alignment.CenterVertically,
210 verticalAlignment = Alignment.CenterVertically,
200 ) {
211 ) {
201− RadioButton(selected = selected, onClick = onSelect)
212+ RadioButton(selected = selected, onClick = null)
202 Text(
213 Text(
203 when (mode) {
214 when (mode) {
204 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
215 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)