git.iko.soy/opencode-android
on master help sign in
Fix connection lifecycle races, leaked scopes, stale VCS status, and accessibility across 12 files
commit710ef2d835eeb5b7107c49dff882e0b938679662 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 20:32 (3 months ago) parentf2ce9a1
12 files changed+62−24
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+4 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+3−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2−9 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+7−5 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+16−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+3−1 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1−1
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+4
@@ -63,6 +63,10 @@ class EventStreamClient(
6363
6464 private fun stream(): Flow<BusEvent> = channelFlow {
6565 val scope = this
66+ // Drain any stale reconnect signal from a previous collection cycle so it
67+ // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
68+ // stopped the upstream while triggerReconnect() queued a signal).
69+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
6670 var backoffMs = NetworkConfig.sseInitialBackoffMs
6771 while (isActive) {
6872 _state.value = ConnectionState.Connecting
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1
@@ -166,7 +166,7 @@ internal suspend fun <T> withRetryInternal(
166166 return block()
167167 } catch (c: kotlinx.coroutines.CancellationException) {
168168 throw c
169− } catch (t: Throwable) {
169+ } catch (t: Exception) {
170170 lastError = t
171171 if (t is ClientRequestException) throw t
172172 if (attempt < maxAttempts) {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−2
@@ -70,7 +70,10 @@ class AppContainer(context: Context) {
7070 private val _pendingShare = MutableStateFlow<String?>(null)
7171 val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
7272 fun setPendingShare(text: String) { _pendingShare.value = text }
73− fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
73+ fun consumePendingShare(): String? {
74+ val current = _pendingShare.value ?: return null
75+ return if (_pendingShare.compareAndSet(current, null)) current else null
76+ }
7477
7578 /**
7679 * A session id to open from an external trigger (a notification tap or a deep link).
@@ -79,7 +82,10 @@ class AppContainer(context: Context) {
7982 private val _pendingOpenSession = MutableStateFlow<String?>(null)
8083 val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
8184 fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
82− fun consumePendingOpenSession(): String? = _pendingOpenSession.value.also { _pendingOpenSession.value = null }
85+ fun consumePendingOpenSession(): String? {
86+ val current = _pendingOpenSession.value ?: return null
87+ return if (_pendingOpenSession.compareAndSet(current, null)) current else null
88+ }
8389
8490 /**
8591 * The session the user is currently viewing (or null when not in a chat). Drives the
@@ -134,6 +140,8 @@ class AppContainer(context: Context) {
134140
135141 /** Release resources held for the process lifetime (network callback, app scope). */
136142 fun shutdown() {
143+ _activeConnection.value?.close()
144+ _activeConnection.value = null
137145 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
138146 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
139147 appScope.cancel()
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+3−1
@@ -128,7 +128,7 @@ class ChatViewModel(
128128 is PermissionUpdated ->
129129 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
130130 is PermissionReplied ->
131− if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
131+ if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
132132 is SessionUpdated ->
133133 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
134134 is SessionDeleted ->
@@ -271,6 +271,8 @@ class ChatViewModel(
271271 runCatching {
272272 val conn = container.connect(recent)
273273 conn.api.ping()
274+ }.onSuccess {
275+ _loading.value = false
274276 }.onFailure {
275277 container.disconnect()
276278 _error.value = container.friendlyError(it)
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2−9
@@ -26,10 +26,6 @@ import androidx.compose.ui.Alignment
2626import androidx.compose.ui.Modifier
2727import androidx.compose.ui.draw.clip
2828import androidx.compose.ui.platform.LocalContext
29−import androidx.compose.ui.semantics.Role
30−import androidx.compose.ui.semantics.contentDescription
31−import androidx.compose.ui.semantics.role
32−import androidx.compose.ui.semantics.semantics
3329import androidx.compose.ui.text.TextStyle
3430import androidx.compose.ui.text.font.FontFamily
3531import androidx.compose.ui.unit.dp
@@ -57,11 +53,8 @@ fun MarkdownText(
5753 val context = LocalContext.current
5854 Markdown(
5955 content = markdown,
60− modifier = modifier.semantics(mergeDescendants = true) {
61− contentDescription = markdown.take(500)
62− role = Role.Button
63− }.combinedClickable(
64− onClick = {},
56+ modifier = modifier.combinedClickable(
57+ onClick = { copyToClipboard(context, markdown) },
6558 onLongClick = { copyToClipboard(context, markdown) },
6659 ),
6760 components = markdownComponents(
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+7−5
@@ -71,11 +71,13 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
7171fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
7272 val context = LocalContext.current
7373 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
74− val request = ImageRequest.Builder(context)
75− .data(model)
76− .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
77− .crossfade(true)
78− .build()
74+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
75+ ImageRequest.Builder(context)
76+ .data(model)
77+ .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
78+ .crossfade(true)
79+ .build()
80+ }
7981 AsyncImage(
8082 model = request,
8183 contentDescription = part.filename ?: stringResource(R.string.image),
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+16−1
@@ -7,11 +7,13 @@ import soy.iko.opencode.data.model.FileStatusEntry
77import soy.iko.opencode.data.network.NetworkConfig
88import soy.iko.opencode.di.AppContainer
99import soy.iko.opencode.R
10+import kotlinx.coroutines.ExperimentalCoroutinesApi
1011import kotlinx.coroutines.Job
1112import kotlinx.coroutines.delay
1213import kotlinx.coroutines.flow.MutableStateFlow
1314import kotlinx.coroutines.flow.StateFlow
1415import kotlinx.coroutines.flow.asStateFlow
16+import kotlinx.coroutines.flow.collectLatest
1517import kotlinx.coroutines.launch
1618
1719data class FileBrowserState(
@@ -27,6 +29,7 @@ data class FileBrowserState(
2729 val isSearching: Boolean get() = query.isNotBlank()
2830}
2931
32+@OptIn(ExperimentalCoroutinesApi::class)
3033class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
3134
3235 private val api get() = container.activeConnection.value?.api
@@ -42,7 +45,19 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
4245 private var searchJob: Job? = null
4346 private var openJob: Job? = null
4447
45− init { open(""); loadStatus() }
48+ init {
49+ open("")
50+ // Observe the active connection so VCS status loads (or reloads) when a connection
51+ // becomes available — including when the view opens during a reconnect window where
52+ // activeConnection.value was momentarily null. Without this, loadStatus() would be
53+ // called once from init with a null api and never retried.
54+ viewModelScope.launch {
55+ container.activeConnection.collectLatest { conn ->
56+ if (conn == null) return@collectLatest
57+ loadStatus()
58+ }
59+ }
60+ }
4661
4762 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
4863 private fun loadStatus() {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−1
@@ -1,6 +1,9 @@
11package soy.iko.opencode.ui.server
22
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
35import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.fillMaxSize
47import androidx.compose.foundation.layout.fillMaxWidth
58import androidx.compose.foundation.layout.imePadding
69import androidx.compose.foundation.layout.padding
@@ -12,6 +15,7 @@ import androidx.compose.foundation.verticalScroll
1215import androidx.compose.material.icons.Icons
1316import androidx.compose.material.icons.automirrored.filled.ArrowBack
1417import androidx.compose.material3.Button
18+import androidx.compose.material3.CircularProgressIndicator
1519import androidx.compose.material3.ExperimentalMaterial3Api
1620import androidx.compose.material3.Icon
1721import androidx.compose.material3.IconButton
@@ -21,11 +25,11 @@ import androidx.compose.material3.Text
2125import androidx.compose.material3.TopAppBar
2226import androidx.compose.runtime.Composable
2327import androidx.compose.runtime.getValue
28+import androidx.compose.ui.Alignment
2429import androidx.compose.ui.Modifier
2530import androidx.compose.ui.text.input.ImeAction
2631import androidx.compose.ui.text.input.KeyboardType
2732import androidx.compose.ui.text.input.PasswordVisualTransformation
28−import androidx.compose.foundation.layout.Arrangement
2933import androidx.compose.ui.res.stringResource
3034import androidx.compose.ui.unit.dp
3135import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -57,6 +61,11 @@ fun ServerEditScreen(
5761 )
5862 },
5963 ) { padding ->
64+ if (!state.loaded) {
65+ Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
66+ CircularProgressIndicator()
67+ }
68+ } else {
6069 Column(
6170 modifier = Modifier
6271 .padding(padding)
@@ -113,5 +122,6 @@ fun ServerEditScreen(
113122 Text(stringResource(R.string.save))
114123 }
115124 }
125+ }
116126 }
117127}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−1
@@ -108,7 +108,9 @@ fun ServerListScreen(
108108 } else {
109109 LazyColumn(
110110 modifier = Modifier.fillMaxSize().padding(padding),
111− contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
111+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
112+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
113+ ),
112114 verticalArrangement = Arrangement.spacedBy(12.dp),
113115 ) {
114116 items(profiles, key = { it.id }) { profile ->
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1
@@ -265,7 +265,7 @@ fun SessionListScreen(
265265 }
266266
267267 pendingRename?.let { session ->
268− var title by remember { mutableStateOf(session.title ?: "") }
268+ var title by remember(session.id) { mutableStateOf(session.title ?: "") }
269269 RenameSessionDialog(
270270 title = title,
271271 onTitleChange = { title = it },
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+3−1
@@ -110,6 +110,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
110110 viewModelScope.launch {
111111 container.activeConnection.collectLatest { conn ->
112112 if (conn == null) return@collectLatest
113+ _serverLabel.value = conn.profile.displayLabel
113114 conn.events.events.collect { event ->
114115 when (event) {
115116 is SessionUpdated -> {
@@ -188,6 +189,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
188189 val api = conn.api
189190 livePreviewJobs.remove(sessionId)?.cancel()
190191 livePreviewJobs[sessionId] = viewModelScope.launch {
192+ val job = coroutineContext[kotlinx.coroutines.Job]
191193 try {
192194 val preview = runCatching {
193195 api.listMessages(sessionId).lastOrNull()?.let { msg ->
@@ -196,7 +198,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
196198 }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@launch
197199 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
198200 } finally {
199− livePreviewJobs.remove(sessionId)
201+ if (job != null) livePreviewJobs.remove(sessionId, job)
200202 }
201203 }
202204 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1−1
@@ -58,6 +58,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
5858 val logger = remember { CrashLogger.get(context) }
5959 val reports by logger.reports.collectAsStateWithLifecycle()
6060 var viewing by remember { mutableStateOf<String?>(null) }
61+ val shareScope = rememberCoroutineScope()
6162 val shareLabel = stringResource(R.string.share)
6263
6364 Scaffold(
@@ -125,7 +126,6 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
125126 color = MaterialTheme.colorScheme.onSurfaceVariant,
126127 )
127128 }
128− val shareScope = rememberCoroutineScope()
129129 IconButton(onClick = {
130130 shareScope.launch {
131131 val content = withContext(Dispatchers.IO) {