git.iko.soy/opencode-android
on master help sign in
Fix connection lifecycle races, leaked scopes, stale VCS status, and accessibility across 12 files
710ef2diko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+4
@@ -63,6 +63,10 @@ class EventStreamClient(
63
63
64 private fun stream(): Flow<BusEvent> = channelFlow {
64 private fun stream(): Flow<BusEvent> = channelFlow {
65 val scope = this
65 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 */ }
66 var backoffMs = NetworkConfig.sseInitialBackoffMs
70 var backoffMs = NetworkConfig.sseInitialBackoffMs
67 while (isActive) {
71 while (isActive) {
68 _state.value = ConnectionState.Connecting
72 _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(
166 return block()
166 return block()
167 } catch (c: kotlinx.coroutines.CancellationException) {
167 } catch (c: kotlinx.coroutines.CancellationException) {
168 throw c
168 throw c
169− } catch (t: Throwable) {
169+ } catch (t: Exception) {
170 lastError = t
170 lastError = t
171 if (t is ClientRequestException) throw t
171 if (t is ClientRequestException) throw t
172 if (attempt < maxAttempts) {
172 if (attempt < maxAttempts) {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−2
@@ -70,7 +70,10 @@ class AppContainer(context: Context) {
70 private val _pendingShare = MutableStateFlow<String?>(null)
70 private val _pendingShare = MutableStateFlow<String?>(null)
71 val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
71 val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
72 fun setPendingShare(text: String) { _pendingShare.value = text }
72 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+ }
74
77
75 /**
78 /**
76 * A session id to open from an external trigger (a notification tap or a deep link).
79 * A session id to open from an external trigger (a notification tap or a deep link).
@@ -79,7 +82,10 @@ class AppContainer(context: Context) {
79 private val _pendingOpenSession = MutableStateFlow<String?>(null)
82 private val _pendingOpenSession = MutableStateFlow<String?>(null)
80 val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
83 val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
81 fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
84 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+ }
83
89
84 /**
90 /**
85 * The session the user is currently viewing (or null when not in a chat). Drives the
91 * The session the user is currently viewing (or null when not in a chat). Drives the
@@ -134,6 +140,8 @@ class AppContainer(context: Context) {
134
140
135 /** Release resources held for the process lifetime (network callback, app scope). */
141 /** Release resources held for the process lifetime (network callback, app scope). */
136 fun shutdown() {
142 fun shutdown() {
143+ _activeConnection.value?.close()
144+ _activeConnection.value = null
137 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
145 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
138 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
146 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
139 appScope.cancel()
147 appScope.cancel()
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+3−1
@@ -128,7 +128,7 @@ class ChatViewModel(
128 is PermissionUpdated ->
128 is PermissionUpdated ->
129 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
129 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
130 is PermissionReplied ->
130 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
132 is SessionUpdated ->
132 is SessionUpdated ->
133 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
133 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
134 is SessionDeleted ->
134 is SessionDeleted ->
@@ -271,6 +271,8 @@ class ChatViewModel(
271 runCatching {
271 runCatching {
272 val conn = container.connect(recent)
272 val conn = container.connect(recent)
273 conn.api.ping()
273 conn.api.ping()
274+ }.onSuccess {
275+ _loading.value = false
274 }.onFailure {
276 }.onFailure {
275 container.disconnect()
277 container.disconnect()
276 _error.value = container.friendlyError(it)
278 _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
26import androidx.compose.ui.Modifier
26import androidx.compose.ui.Modifier
27import androidx.compose.ui.draw.clip
27import androidx.compose.ui.draw.clip
28import androidx.compose.ui.platform.LocalContext
28import 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
33import androidx.compose.ui.text.TextStyle
29import androidx.compose.ui.text.TextStyle
34import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.text.font.FontFamily
35import androidx.compose.ui.unit.dp
31import androidx.compose.ui.unit.dp
@@ -57,11 +53,8 @@ fun MarkdownText(
57 val context = LocalContext.current
53 val context = LocalContext.current
58 Markdown(
54 Markdown(
59 content = markdown,
55 content = markdown,
60− modifier = modifier.semantics(mergeDescendants = true) {
61− contentDescription = markdown.take(500)
62− role = Role.Button
63− }.combinedClickable(
56+ modifier = modifier.combinedClickable(
64− onClick = {},
57+ onClick = { copyToClipboard(context, markdown) },
65 onLongClick = { copyToClipboard(context, markdown) },
58 onLongClick = { copyToClipboard(context, markdown) },
66 ),
59 ),
67 components = markdownComponents(
60 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? {
71fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
71fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
72 val context = LocalContext.current
72 val context = LocalContext.current
73 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
73 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
74+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
74− val request = ImageRequest.Builder(context)
75+ ImageRequest.Builder(context)
75− .data(model)
76+ .data(model)
76− .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
77+ .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
77− .crossfade(true)
78+ .crossfade(true)
78− .build()
79+ .build()
80+ }
79 AsyncImage(
81 AsyncImage(
80 model = request,
82 model = request,
81 contentDescription = part.filename ?: stringResource(R.string.image),
83 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
7import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.R
9import soy.iko.opencode.R
10+import kotlinx.coroutines.ExperimentalCoroutinesApi
10import kotlinx.coroutines.Job
11import kotlinx.coroutines.Job
11import kotlinx.coroutines.delay
12import kotlinx.coroutines.delay
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.asStateFlow
16+import kotlinx.coroutines.flow.collectLatest
15import kotlinx.coroutines.launch
17import kotlinx.coroutines.launch
16
18
17data class FileBrowserState(
19data class FileBrowserState(
@@ -27,6 +29,7 @@ data class FileBrowserState(
27 val isSearching: Boolean get() = query.isNotBlank()
29 val isSearching: Boolean get() = query.isNotBlank()
28}
30}
29
31
32+@OptIn(ExperimentalCoroutinesApi::class)
30class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
33class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
31
34
32 private val api get() = container.activeConnection.value?.api
35 private val api get() = container.activeConnection.value?.api
@@ -42,7 +45,19 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
42 private var searchJob: Job? = null
45 private var searchJob: Job? = null
43 private var openJob: Job? = null
46 private var openJob: Job? = null
44
47
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+ }
46
61
47 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
62 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
48 private fun loadStatus() {
63 private fun loadStatus() {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−1
@@ -1,6 +1,9 @@
1package soy.iko.opencode.ui.server
1package soy.iko.opencode.ui.server
2
2
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
3import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.fillMaxSize
4import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.imePadding
8import androidx.compose.foundation.layout.imePadding
6import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
@@ -12,6 +15,7 @@ import androidx.compose.foundation.verticalScroll
12import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
14import androidx.compose.material3.Button
17import androidx.compose.material3.Button
18+import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.ExperimentalMaterial3Api
19import androidx.compose.material3.ExperimentalMaterial3Api
16import androidx.compose.material3.Icon
20import androidx.compose.material3.Icon
17import androidx.compose.material3.IconButton
21import androidx.compose.material3.IconButton
@@ -21,11 +25,11 @@ import androidx.compose.material3.Text
21import androidx.compose.material3.TopAppBar
25import androidx.compose.material3.TopAppBar
22import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
23import androidx.compose.runtime.getValue
27import androidx.compose.runtime.getValue
28+import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
25import androidx.compose.ui.text.input.ImeAction
30import androidx.compose.ui.text.input.ImeAction
26import androidx.compose.ui.text.input.KeyboardType
31import androidx.compose.ui.text.input.KeyboardType
27import androidx.compose.ui.text.input.PasswordVisualTransformation
32import androidx.compose.ui.text.input.PasswordVisualTransformation
28−import androidx.compose.foundation.layout.Arrangement
29import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.res.stringResource
30import androidx.compose.ui.unit.dp
34import androidx.compose.ui.unit.dp
31import androidx.lifecycle.compose.collectAsStateWithLifecycle
35import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -57,6 +61,11 @@ fun ServerEditScreen(
57 )
61 )
58 },
62 },
59 ) { padding ->
63 ) { padding ->
64+ if (!state.loaded) {
65+ Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
66+ CircularProgressIndicator()
67+ }
68+ } else {
60 Column(
69 Column(
61 modifier = Modifier
70 modifier = Modifier
62 .padding(padding)
71 .padding(padding)
@@ -113,5 +122,6 @@ fun ServerEditScreen(
113 Text(stringResource(R.string.save))
122 Text(stringResource(R.string.save))
114 }
123 }
115 }
124 }
125+ }
116 }
126 }
117}
127}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−1
@@ -108,7 +108,9 @@ fun ServerListScreen(
108 } else {
108 } else {
109 LazyColumn(
109 LazyColumn(
110 modifier = Modifier.fillMaxSize().padding(padding),
110 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+ ),
112 verticalArrangement = Arrangement.spacedBy(12.dp),
114 verticalArrangement = Arrangement.spacedBy(12.dp),
113 ) {
115 ) {
114 items(profiles, key = { it.id }) { profile ->
116 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(
265 }
265 }
266
266
267 pendingRename?.let { session ->
267 pendingRename?.let { session ->
268− var title by remember { mutableStateOf(session.title ?: "") }
268+ var title by remember(session.id) { mutableStateOf(session.title ?: "") }
269 RenameSessionDialog(
269 RenameSessionDialog(
270 title = title,
270 title = title,
271 onTitleChange = { title = it },
271 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() {
110 viewModelScope.launch {
110 viewModelScope.launch {
111 container.activeConnection.collectLatest { conn ->
111 container.activeConnection.collectLatest { conn ->
112 if (conn == null) return@collectLatest
112 if (conn == null) return@collectLatest
113+ _serverLabel.value = conn.profile.displayLabel
113 conn.events.events.collect { event ->
114 conn.events.events.collect { event ->
114 when (event) {
115 when (event) {
115 is SessionUpdated -> {
116 is SessionUpdated -> {
@@ -188,6 +189,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
188 val api = conn.api
189 val api = conn.api
189 livePreviewJobs.remove(sessionId)?.cancel()
190 livePreviewJobs.remove(sessionId)?.cancel()
190 livePreviewJobs[sessionId] = viewModelScope.launch {
191 livePreviewJobs[sessionId] = viewModelScope.launch {
192+ val job = coroutineContext[kotlinx.coroutines.Job]
191 try {
193 try {
192 val preview = runCatching {
194 val preview = runCatching {
193 api.listMessages(sessionId).lastOrNull()?.let { msg ->
195 api.listMessages(sessionId).lastOrNull()?.let { msg ->
@@ -196,7 +198,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
196 }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@launch
198 }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@launch
197 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
199 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
198 } finally {
200 } finally {
199− livePreviewJobs.remove(sessionId)
201+ if (job != null) livePreviewJobs.remove(sessionId, job)
200 }
202 }
201 }
203 }
202 }
204 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1−1
@@ -58,6 +58,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
58 val logger = remember { CrashLogger.get(context) }
58 val logger = remember { CrashLogger.get(context) }
59 val reports by logger.reports.collectAsStateWithLifecycle()
59 val reports by logger.reports.collectAsStateWithLifecycle()
60 var viewing by remember { mutableStateOf<String?>(null) }
60 var viewing by remember { mutableStateOf<String?>(null) }
61+ val shareScope = rememberCoroutineScope()
61 val shareLabel = stringResource(R.string.share)
62 val shareLabel = stringResource(R.string.share)
62
63
63 Scaffold(
64 Scaffold(
@@ -125,7 +126,6 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
125 color = MaterialTheme.colorScheme.onSurfaceVariant,
126 color = MaterialTheme.colorScheme.onSurfaceVariant,
126 )
127 )
127 }
128 }
128− val shareScope = rememberCoroutineScope()
129 IconButton(onClick = {
129 IconButton(onClick = {
130 shareScope.launch {
130 shareScope.launch {
131 val content = withContext(Dispatchers.IO) {
131 val content = withContext(Dispatchers.IO) {