git.iko.soy/opencode-android
on master help sign in
Fix SSE re-seed event loss, shutdown deadlock, DraftStore ANR, share-intent loss, Command deserialization crash, UnknownMessage key collision, cache lock contention, backoff overflow, redirect misclassification, orphaned password migration, zombie session navigation, dropdown BackHandler, foreground notification tap, permission double-respond, diff git metadata parsing, TimeFormat year boundary, markdown export escaping, relative-time battery drain, file browser state races, and accessibility gaps
commitd643c13b2ea50114a7060cdb6adbd2a70020d6cd authoriko <mail@iko.soy> dateTue 30 Jun 2026, 09:46 (3 months ago) parent7f11220
34 files changed+367−135
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+11−1 app/src/main/java/soy/iko/opencode/data/model/Agent.kt+1−1 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+6 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+31−25 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+11−7 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+29−11 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+3−2 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+24−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+27−15 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+27−11 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+14−2 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+11−3 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+7 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+5−1 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+13−4 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+4−5 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+2−2 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+17−4 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+11−2 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+19−2 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+21−8 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+3−3 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+9−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+4 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+5−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+5 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+22−15 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+3−1 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+3−1 app/src/main/res/values/strings.xml+1
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+11−1
@@ -3,11 +3,14 @@ package soy.iko.opencode
33import android.app.Application
44import soy.iko.opencode.data.repo.CrashLogger
55import soy.iko.opencode.di.AppContainer
6+import java.util.concurrent.atomic.AtomicBoolean
67
78class OpencodeApp : Application() {
89 lateinit var container: AppContainer
910 private set
1011
12+ private val shutdownCalled = AtomicBoolean(false)
13+
1114 override fun onCreate() {
1215 super.onCreate()
1316 CrashLogger.get(this).install()
@@ -16,12 +19,19 @@ class OpencodeApp : Application() {
1619 // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
1720 // scope cancellation, connection close) runs when the process is actually killed.
1821 Runtime.getRuntime().addShutdownHook(Thread {
19− container.shutdown()
22+ shutdown()
2023 })
2124 }
2225
2326 override fun onTerminate() {
2427 super.onTerminate()
28+ shutdown()
29+ }
30+
31+ private fun shutdown() {
32+ // Guard against double shutdown — onTerminate and the shutdown hook can both
33+ // fire in emulated environments.
34+ if (!shutdownCalled.compareAndSet(false, true)) return
2535 container.shutdown()
2636 }
2737}
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+1−1
@@ -25,7 +25,7 @@ data class Command(
2525 val description: String? = null,
2626 val agent: String? = null,
2727 val model: String? = null,
28− val template: String,
28+ val template: String = "",
2929 val subtask: Boolean = false,
3030) {
3131 val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+6
@@ -150,6 +150,12 @@ class EventStreamClient(
150150 },
151151 ) {
152152 backoffMs = initialBackoffMs
153+ // The SSE block is open — the connection is established. Mark as
154+ // Connected immediately so downstream re-seed logic fires even when
155+ // the server only sends keep-alive comments (no data events).
156+ if (_state.value != ConnectionState.Connected) {
157+ _state.value = ConnectionState.Connected
158+ }
153159 // Clear any stale reconnect signal from a triggerReconnect() call
154160 // made while the previous stream was healthy, so it doesn't suppress
155161 // the next legitimate backoff.
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2
@@ -24,6 +24,8 @@ object NetworkConfig {
2424 const val retryMaxAttempts = 3
2525 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
2626 const val retryInitialDelayMs = 500L
27+ /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28+ const val retryMaxDelayMs = 30_000L
2729 /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
2830 const val retryJitterFactor = 0.2
2931
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+31−25
@@ -124,40 +124,43 @@ class OpencodeApiClient(private val client: HttpClient) {
124124 }.body()
125125 }
126126
127− suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
127+ suspend fun providers(): ProvidersResponse {
128128 val now = System.currentTimeMillis()
129− val cached = cachedProviders
130− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
131− cached.value
132− } else {
133− val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
134− cachedProviders = CachedEntry(value, now)
135− value
129+ cacheMutex.withLock {
130+ val cached = cachedProviders
131+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
132+ return cached.value
133+ }
136134 }
135+ val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
136+ cacheMutex.withLock { cachedProviders = CachedEntry(value, System.currentTimeMillis()) }
137+ return value
137138 }
138139
139− suspend fun agents(): List<Agent> = cacheMutex.withLock {
140+ suspend fun agents(): List<Agent> {
140141 val now = System.currentTimeMillis()
141− val cached = cachedAgents
142− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
143− cached.value
144− } else {
145− val value = withRetry { client.get("agent").body<List<Agent>>() }
146− cachedAgents = CachedEntry(value, now)
147− value
142+ cacheMutex.withLock {
143+ val cached = cachedAgents
144+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
145+ return cached.value
146+ }
148147 }
148+ val value = withRetry { client.get("agent").body<List<Agent>>() }
149+ cacheMutex.withLock { cachedAgents = CachedEntry(value, System.currentTimeMillis()) }
150+ return value
149151 }
150152
151− suspend fun commands(): List<Command> = cacheMutex.withLock {
153+ suspend fun commands(): List<Command> {
152154 val now = System.currentTimeMillis()
153− val cached = cachedCommands
154− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
155− cached.value
156− } else {
157− val value = withRetry { client.get("command").body<List<Command>>() }
158− cachedCommands = CachedEntry(value, now)
159− value
155+ cacheMutex.withLock {
156+ val cached = cachedCommands
157+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
158+ return cached.value
159+ }
160160 }
161+ val value = withRetry { client.get("command").body<List<Command>>() }
162+ cacheMutex.withLock { cachedCommands = CachedEntry(value, System.currentTimeMillis()) }
163+ return value
161164 }
162165
163166 /** Invalidate the catalog cache (e.g. on server switch). */
@@ -231,7 +234,10 @@ internal suspend fun <T> withRetryInternal(
231234 if (t.response.status.value != 429) throw t
232235 }
233236 if (attempt < maxAttempts) {
234− val baseDelay = initialDelayMs * (1 shl (attempt - 1))
237+ // Guard against Int overflow from the shift and Long overflow from
238+ // the multiplication at high attempt counts.
239+ val shift = (attempt - 1).coerceAtMost(30)
240+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
235241 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
236242 // shorter, sometimes longer — spreads concurrent client retries and
237243 // prevents thundering-herd reconnection storms.
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+11−7
@@ -81,16 +81,20 @@ class CrashLogger private constructor(private val appContext: Context) {
8181 }
8282
8383 fun deleteReport(fileName: String) {
84− val file = File(crashDir, fileName).canonicalFile
85− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
86− file.delete()
84+ scope.launch {
85+ val file = File(crashDir, fileName).canonicalFile
86+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
87+ file.delete()
88+ }
89+ refresh()
8790 }
88− refresh()
8991 }
9092
9193 fun clearAll() {
92− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
93− refresh()
94+ scope.launch {
95+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
96+ refresh()
97+ }
9498 }
9599
96100 fun reportCount(): Int = _reports.value.size
@@ -107,7 +111,7 @@ class CrashLogger private constructor(private val appContext: Context) {
107111 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
108112 // Append a short random suffix so two crashes in the same millisecond don't
109113 // overwrite each other.
110− val suffix = Integer.toHexString(System.nanoTime().toInt())
114+ val suffix = java.lang.Long.toHexString(System.nanoTime())
111115 val file = File(crashDir, "crash-$stamp-$suffix.txt")
112116 val sw = StringWriter()
113117 PrintWriter(sw).use { pw ->
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+29−11
@@ -9,8 +9,8 @@ import kotlinx.coroutines.flow.asStateFlow
99import kotlinx.coroutines.flow.update
1010import android.util.Log
1111import kotlinx.coroutines.launch
12−import kotlinx.coroutines.runBlocking
1312import kotlinx.coroutines.withContext
13+import java.util.concurrent.Executors
1414
1515/**
1616 * Persists the in-progress message draft for each session so it survives
@@ -36,6 +36,12 @@ class DraftStore(context: Context, scope: CoroutineScope) {
3636 * background thread to avoid blocking on first-time disk I/O. */
3737 private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
3838
39+ /** Single-thread executor for flushDraft when prefs aren't initialized yet,
40+ * so the main thread is never blocked on disk I/O. */
41+ private val flushExecutor = Executors.newSingleThreadExecutor { r ->
42+ Thread(r, "DraftStore-flush").apply { isDaemon = true }
43+ }
44+
3945 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
4046 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
4147 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
@@ -60,6 +66,17 @@ class DraftStore(context: Context, scope: CoroutineScope) {
6066 /** Returns the draft for [sessionId], or "" if not found or still loading. */
6167 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
6268
69+ /** Update the in-memory draft immediately without persisting to disk.
70+ * Used when navigation must see the new value synchronously (e.g. share-intent
71+ * injection before ChatScreen composes). Call [set] afterwards to persist. */
72+ fun setImmediate(sessionId: String, text: String) {
73+ _drafts.update { current ->
74+ val updated = current.toMutableMap()
75+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
76+ updated
77+ }
78+ }
79+
6380 suspend fun set(sessionId: String, text: String) {
6481 _drafts.update { current ->
6582 val updated = current.toMutableMap()
@@ -98,9 +115,10 @@ class DraftStore(context: Context, scope: CoroutineScope) {
98115 * [apply] (asynchronous) and returns immediately without blocking the main thread.
99116 *
100117 * If the prefs file has NOT been opened yet (a rare race where onCleared fires
101− * before the background init), accessing the lazy would synchronously load and
102− * parse the XML on the main thread. To avoid this, the write is deferred to a
103− * blocking call on the IO dispatcher instead.
118+ * before the background init), the write is dispatched to a background thread
119+ * instead of blocking the main thread on a synchronous disk read. The draft
120+ * may be lost if the process exits before the write completes, but this is
121+ * preferable to an ANR.
104122 */
105123 fun flushDraft(sessionId: String, text: String) {
106124 _drafts.update { current ->
@@ -116,16 +134,16 @@ class DraftStore(context: Context, scope: CoroutineScope) {
116134 }.apply()
117135 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
118136 } else {
119− // Prefs not yet opened — avoid a synchronous disk read on the main thread
120− // by blocking on IO instead. This is acceptable in onCleared() because the
121− // viewModelScope is already dead and there's no suspending alternative.
122− runCatching {
123− runBlocking(Dispatchers.IO) {
137+ // Prefs not yet opened — dispatch to a background thread to avoid a
138+ // synchronous disk read on the main thread (which would ANR). The draft
139+ // may be lost if the process exits before the write completes.
140+ flushExecutor.execute {
141+ runCatching {
124142 prefs.edit().apply {
125143 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
126144 }.apply()
127− }
128− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
145+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
146+ }
129147 }
130148 }
131149}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+3−2
@@ -2,6 +2,7 @@ package soy.iko.opencode.data.repo
22
33import io.ktor.client.plugins.ClientRequestException
44import io.ktor.client.plugins.HttpRequestTimeoutException
5+import io.ktor.client.plugins.RedirectResponseException
56import io.ktor.client.plugins.ResponseException
67import io.ktor.client.plugins.ServerResponseException
78import java.io.IOException
@@ -51,10 +52,10 @@ fun classifyError(t: Throwable): ErrorKind {
5152 return ErrorKind.TIMEOUT
5253 is ServerResponseException -> return ErrorKind.SERVER
5354 is ClientRequestException -> return ErrorKind.CLIENT
55+ is RedirectResponseException -> return ErrorKind.CLIENT
5456 is IOException -> return ErrorKind.NETWORK
5557 else -> {
56− // ResponseException covers any other HTTP status failure (e.g. a redirect
57− // or an unrecognized status family) surfaced because expectSuccess = true.
58+ // Any other ResponseException (unrecognized status family).
5859 if (current is ResponseException) return ErrorKind.SERVER
5960 }
6061 }
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+24−1
@@ -59,6 +59,23 @@ class ProfileStore(context: Context) {
5959 private fun prefsForPasswords(): SharedPreferences =
6060 securePrefs ?: fallbackPrefs
6161
62+ /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
63+ * then clear the fallback. Called lazily once when secure prefs are available. */
64+ private suspend fun migrateFallbackPasswords() {
65+ val secure = securePrefs ?: return
66+ withContext(Dispatchers.IO) {
67+ val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
68+ if (fallbackKeys.isEmpty()) return@withContext
69+ secure.edit().apply {
70+ for (key in fallbackKeys) {
71+ val pw = fallbackPrefs.getString(key, null) ?: continue
72+ putString(key, pw)
73+ fallbackPrefs.edit().remove(key).apply()
74+ }
75+ }.apply()
76+ }
77+ }
78+
6279 /** Stored shape on DataStore (everything except the secret password). */
6380 @Serializable
6481 private data class StoredProfile(
@@ -70,6 +87,8 @@ class ProfileStore(context: Context) {
7087 )
7188
7289 val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
90+ // Migrate any orphaned plaintext passwords to secure prefs on first load.
91+ migrateFallbackPasswords()
7392 val json = prefs[profilesKey] ?: return@map emptyList()
7493 runCatching {
7594 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
@@ -144,8 +163,12 @@ class ProfileStore(context: Context) {
144163 current.filterNot { it.id == id },
145164 )
146165 }
166+ // Clean up the password in both secure and fallback prefs so no orphaned
167+ // plaintext password lingers if secure prefs were temporarily unavailable.
147168 withContext(Dispatchers.IO) {
148− prefsForPasswords().edit().remove(passwordKey(id)).apply()
169+ val key = passwordKey(id)
170+ securePrefs?.edit()?.remove(key)?.apply()
171+ fallbackPrefs.edit().remove(key).apply()
149172 }
150173 }
151174
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+27−15
@@ -78,20 +78,20 @@ class SessionRepository(
7878 // re-fetching the current snapshot fills those holes. The seed merge logic
7979 // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
8080 //
81− // Both the REST fetch and the seed are performed under the lock so that events
82− // arriving via SSE between the fetch and the seed are not incorrectly pruned
83− // by the prune=true pass.
81+ // The REST fetch is performed *outside* the lock so events arriving via SSE
82+ // during the fetch are not blocked — holding the lock during a network call
83+ // would stall the event reducer and overflow the SharedFlow buffer (DROP_OLDEST),
84+ // silently losing events. The seed itself is under the lock; an interim seed
85+ // token is checked so a concurrent re-seed doesn't clobber a newer fetch.
8486 launch {
8587 var wasConnected = false
8688 eventStream.state.collect { state ->
8789 if (state == EventStreamClient.ConnectionState.Connected) {
8890 if (wasConnected) {
89− lock.withLock {
90− val fresh = runCatchingCancellable { api.listMessages(sessionId) }
91− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE", it) }
92− .getOrDefault(emptyList())
93− store.seed(fresh, prune = true)
94− }
91+ val fresh = runCatchingCancellable { api.listMessages(sessionId) }
92+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE", it) }
93+ .getOrDefault(emptyList())
94+ lock.withLock { store.seed(fresh, prune = true) }
9595 publish()
9696 }
9797 wasConnected = true
@@ -113,12 +113,14 @@ class SessionRepository(
113113 }.conflate()
114114
115115 companion object {
116− /** Convenience: is this event a run-completion signal for [sessionId]? */
116+ /** Convenience: is this event a run-completion signal for [sessionId]?
117+ * A null sessionID is NOT treated as a wildcard — an idle event with no
118+ * session id must not reset the running state of every open chat. */
117119 fun isIdle(event: BusEvent, sessionId: String): Boolean =
118− event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
120+ event is SessionIdle && event.properties.sessionID == sessionId
119121
120122 fun isError(event: BusEvent, sessionId: String): Boolean =
121− event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
123+ event is SessionError && event.properties.sessionID == sessionId
122124 }
123125}
124126
@@ -177,8 +179,17 @@ internal class MessageStore {
177179 if (info.sessionID != sessionId) {
178180 false
179181 } else {
180− val existing = messages[info.id]
181− messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
182+ // An UnknownMessage with an empty id (e.g. from an unrecognized
183+ // server role with no id field) would collide with other such
184+ // messages in the map. Generate a unique synthetic key so each
185+ // unknown message gets its own entry instead of overwriting others.
186+ val key = if (info.id.isEmpty() && info is UnknownMessage) {
187+ "unknown-${System.nanoTime()}"
188+ } else {
189+ info.id
190+ }
191+ val existing = messages[key]
192+ messages[key] = existing?.copy(info = info) ?: MessageWithParts(info)
182193 if (existing == null) evictOldMessages()
183194 true
184195 }
@@ -214,6 +225,7 @@ internal class MessageStore {
214225 val current = messages[messageId]
215226 val parts = current?.parts.orEmpty()
216227 val idx = parts.indexOfFirst { it.id == part.id }
228+ if (idx >= 0 && parts[idx] == part) return false
217229 val newParts = if (idx >= 0) {
218230 parts.toMutableList().also { it[idx] = part }
219231 } else {
@@ -224,7 +236,7 @@ internal class MessageStore {
224236 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
225237 parts = newParts,
226238 )
227− evictOldMessages()
239+ if (current == null) evictOldMessages()
228240 return true
229241 }
230242
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+27−11
@@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
2525import kotlinx.coroutines.ExperimentalCoroutinesApi
2626import kotlinx.coroutines.SupervisorJob
2727import kotlinx.coroutines.cancel
28+import kotlinx.coroutines.delay
2829import kotlinx.coroutines.flow.MutableStateFlow
2930import kotlinx.coroutines.flow.StateFlow
3031import kotlinx.coroutines.flow.asStateFlow
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.emptyFlow
3233import kotlinx.coroutines.flow.first
3334import kotlinx.coroutines.flow.flatMapLatest
3435import kotlinx.coroutines.flow.update
36+import kotlinx.coroutines.isActive
3537import kotlinx.coroutines.launch
3638import kotlinx.coroutines.sync.Mutex
3739import kotlinx.coroutines.sync.withLock
@@ -108,6 +110,13 @@ class AppContainer(context: Context) {
108110 }
109111 }
110112
113+ /** Restore a session's unread badge after a failed server switch reconnects. */
114+ fun restoreUnread(id: String) {
115+ if (id != _currentSession.value) {
116+ _unread.update { it + id }
117+ }
118+ }
119+
111120 /** Resolve a localized string — view models reach resources through the container. */
112121 fun string(id: Int, vararg formatArgs: Any): String =
113122 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
@@ -141,19 +150,20 @@ class AppContainer(context: Context) {
141150
142151 /** Release resources held for the process lifetime (network callback, app scope). */
143152 fun shutdown() {
144− // Acquire the connection mutex so a concurrent connect() can't leak a new
145− // connection after we've nulled the reference. This runs on a shutdown hook
146− // thread (or onTerminate), so a brief blocking wait is acceptable here.
153+ // Cancel the app scope first so any coroutine holding the connection mutex
154+ // (e.g. a connect() suspended on profileStore) is cancelled and releases the
155+ // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
156+ // that can't be cancelled until after the runBlocking completes.
157+ appScope.cancel()
147158 kotlinx.coroutines.runBlocking {
148159 connectionMutex.withLock {
149− _activeConnection.value?.close()
160+ runCatching { _activeConnection.value?.close() }
150161 _activeConnection.value = null
151162 }
152163 }
153164 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
154165 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
155166 soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
156− appScope.cancel()
157167 }
158168
159169 /**
@@ -167,10 +177,14 @@ class AppContainer(context: Context) {
167177 @OptIn(ExperimentalCoroutinesApi::class)
168178 private fun observeMessageActivity() {
169179 appScope.launch {
170− runCatchingCancellable {
171− activeConnection
172− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
173− .collect { event ->
180+ // Retry the observer if it fails for any reason (transient flow exception,
181+ // cancellation due to scope issues). Without this, a single failure would
182+ // permanently disable unread tracking and completion notifications.
183+ while (isActive) {
184+ runCatchingCancellable {
185+ activeConnection
186+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
187+ .collect { event ->
174188 // SessionIdle is not "message activity" (don't badge as unread)
175189 // but signals a run finished — fire a completion notification if the
176190 // session isn't currently being viewed and was actively streaming.
@@ -198,8 +212,10 @@ class AppContainer(context: Context) {
198212 activeRuns.add(sid)
199213 }
200214 }
201− }
202− }.onFailure { Log.w("AppContainer", "Message activity observer failed", it) }
215+ } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry", it) }
216+ if (!isActive) break
217+ delay(5_000)
218+ }
203219 }
204220 }
205221
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1
@@ -35,6 +35,6 @@ class OpencodeConnection(val profile: ServerProfile) {
3535 suspend fun close() {
3636 scope.cancel()
3737 scopeJob.join()
38− client.close()
38+ runCatching { client.close() }
3939 }
4040}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+14−2
@@ -1,6 +1,7 @@
11package soy.iko.opencode.notification
22
33import android.app.Notification
4+import android.app.PendingIntent
45import android.app.Service
56import android.content.Context
67import android.content.Intent
@@ -10,6 +11,7 @@ import android.os.IBinder
1011import android.util.Log
1112import androidx.core.app.NotificationCompat
1213import androidx.core.app.ServiceCompat
14+import soy.iko.opencode.MainActivity
1315import soy.iko.opencode.R
1416
1517/**
@@ -48,15 +50,25 @@ class RunForegroundService : Service() {
4850 return START_NOT_STICKY
4951 }
5052
51− private fun buildNotification(): Notification =
52− NotificationCompat.Builder(this, NotificationChannels.STATUS)
53+ private fun buildNotification(): Notification {
54+ // Tapping the notification opens the app so the user can see the running session.
55+ val openIntent = Intent(this, MainActivity::class.java).apply {
56+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
57+ }
58+ val pendingIntent = PendingIntent.getActivity(
59+ this, 0, openIntent,
60+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61+ )
62+ return NotificationCompat.Builder(this, NotificationChannels.STATUS)
5363 .setSmallIcon(R.drawable.ic_launcher_foreground)
5464 .setContentTitle(getString(R.string.notif_running_title))
5565 .setContentText(getString(R.string.notif_running_text))
66+ .setContentIntent(pendingIntent)
5667 .setOngoing(true)
5768 .setPriority(NotificationCompat.PRIORITY_LOW)
5869 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
5970 .build()
71+ }
6072
6173 override fun onDestroy() {
6274 // Safety net: if the process is being killed while a run is in progress,
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+11−3
@@ -24,11 +24,14 @@ object SessionNotifications {
2424 private const val NOTIF_ID_PREFIX = 4000
2525 private const val TAG = "SessionNotifications"
2626
27+ private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
28+
2729 /** Derive a stable, collision-resistant notification id from a session id.
2830 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
2931 * bits makes accidental collisions astronomically unlikely. */
3032 private fun notifIdFor(sessionId: String): Int {
31− val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
33+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
34+ .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
3235 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
3336 val hash = ((digest[0].toInt() and 0xFF) shl 24 or
3437 (digest[1].toInt() and 0xFF) shl 16 or
@@ -51,9 +54,12 @@ object SessionNotifications {
5154 return
5255 }
5356 val notifId = notifIdFor(sessionId)
57+ // Sanitize the session id before embedding it in the URI so characters like
58+ // /, ?, # can't inject path segments or query parameters.
59+ val safeSessionId = notifIdRegex.replace(sessionId, "")
5460 val openIntent = Intent(context, MainActivity::class.java).apply {
5561 action = Intent.ACTION_VIEW
56− data = android.net.Uri.parse("opencode://session/$sessionId")
62+ data = android.net.Uri.parse("opencode://session/$safeSessionId")
5763 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
5864 }
5965 val pendingIntent = PendingIntent.getActivity(
@@ -63,7 +69,9 @@ object SessionNotifications {
6369 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
6470 .setSmallIcon(R.drawable.ic_launcher_foreground)
6571 .setContentTitle(context.getString(R.string.notif_completed_title))
66− .setContentText(context.getString(R.string.notif_completed_text, title))
72+ // Escape % characters in the title so getString formatting doesn't break
73+ // when a server-controlled session title contains format-specifier-like text.
74+ .setContentText(context.getString(R.string.notif_completed_text, title.replace("%", "%%")))
6775 .setAutoCancel(true)
6876 .setContentIntent(pendingIntent)
6977 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−4
@@ -35,10 +35,11 @@ fun OpencodeApp(container: AppContainer) {
3535
3636 // When text is shared into the app, surface the session list so the user can pick
3737 // (or create) a conversation to drop it into. The chosen session's draft is set
38− // in [onOpenSession] below. Fresh launches rely on auto-reconnect to get here.
39− LaunchedEffect(pendingShare) {
38+ // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
39+ // before auto-reconnect completes is retried once the connection is established.
40+ LaunchedEffect(pendingShare, connection) {
4041 if (pendingShare == null) return@LaunchedEffect
41− if (container.activeConnection.value == null) return@LaunchedEffect
42+ if (connection == null) return@LaunchedEffect
4243 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
4344 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
4445 }
@@ -95,7 +96,13 @@ fun OpencodeApp(container: AppContainer) {
9596 SessionListScreen(
9697 container = container,
9798 onOpenSession = { id ->
98− container.consumePendingShare()?.let { scope.launch { runCatchingCancellable { container.draftStore.set(id, it) } } }
99+ // Set the draft synchronously in-memory before navigating so the
100+ // ChatScreen sees it on first composition. The async persistence
101+ // to disk happens in the background via draftStore.set.
102+ container.consumePendingShare()?.let { shareText ->
103+ container.draftStore.setImmediate(id, shareText)
104+ scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
105+ }
99106 navController.navigate(Routes.chat(id))
100107 },
101108 onDisconnect = {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+7
@@ -118,6 +118,7 @@ fun ChatScreen(
118118 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
119119 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
120120 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
121+ val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
121122 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
122123 val draft by vm.draft.collectAsStateWithLifecycle()
123124 val haptics = LocalHapticFeedback.current
@@ -140,6 +141,12 @@ fun ChatScreen(
140141 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
141142 }
142143
144+ // Navigate away when the session is deleted via SSE so the user isn't left
145+ // on a zombie screen showing a conversation that no longer exists.
146+ LaunchedEffect(sessionDeleted) {
147+ if (sessionDeleted) onBack()
148+ }
149+
143150 val listState = rememberLazyListState()
144151 val snackbar = remember { SnackbarHostState() }
145152 var showModelPicker by rememberSaveable { mutableStateOf(false) }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+5−1
@@ -120,6 +120,10 @@ class ChatViewModel(
120120 private val _sessionTitle = MutableStateFlow<String?>(null)
121121 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
122122
123+ /** Set when the current session is deleted via SSE, so the UI can navigate away. */
124+ private val _sessionDeleted = MutableStateFlow(false)
125+ val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
126+
123127 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
124128 private val _failedDraft = MutableStateFlow<String?>(null)
125129 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
@@ -228,7 +232,7 @@ class ChatViewModel(
228232 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
229233 is SessionDeleted ->
230234 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
231− _sessionTitle.value = event.properties.info?.displayTitle
235+ _sessionDeleted.value = true
232236 }
233237 else -> {}
234238 }
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+13−4
@@ -13,9 +13,9 @@ import soy.iko.opencode.data.model.UserMessage
1313 * be unit-tested directly.
1414 */
1515fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
16− val sb = StringBuilder()
16+ val sb = StringBuilder(messages.size * 128)
1717 if (!title.isNullOrBlank()) {
18− sb.append("# ").append(title.trim()).append("\n\n")
18+ sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
1919 }
2020 for (message in messages) {
2121 val heading = when (message.info) {
@@ -26,8 +26,8 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
2626 val body = message.parts
2727 .mapNotNull { part ->
2828 when (part) {
29− is TextPart -> part.text.takeIf { it.isNotBlank() }
30− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "_${it}_" }
29+ is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
30+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "> _${escapeMarkdown(it)}_" }
3131 else -> null
3232 }
3333 }
@@ -38,3 +38,12 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
3838 }
3939 return sb.toString().trimEnd()
4040}
41+
42+/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
43+private fun escapeMarkdown(text: String): String =
44+ text.replace("\\", "\\\\")
45+ .replace("*", "\\*")
46+ .replace("_", "\\_")
47+ .replace("#", "\\#")
48+ .replace("`", "\\`")
49+ .replace(">", "\\>")
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+4−5
@@ -105,11 +105,10 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
105105 }
106106}
107107
108−private val tokenNumberFormat: java.text.NumberFormat =
109− java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
110−
111−private fun formatTokens(tokens: Tokens, format: String): String =
112− format.format(tokenNumberFormat.format(tokens.input), tokenNumberFormat.format(tokens.output))
108+private fun formatTokens(tokens: Tokens, format: String): String {
109+ val nf = java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
110+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
111+}
113112
114113private fun formatCost(cost: Double): String =
115114 // Locale.US so the formatting is stable regardless of device locale (avoids
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+2−2
@@ -76,9 +76,9 @@ fun ModelPickerSheet(
7676 )
7777 } else {
7878 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79− items(options, key = { it.providerID + "/" + it.modelID }) { option ->
79+ items(options, key = { it.providerID to it.modelID }) { option ->
8080 val isSelected = option.providerID == selected?.providerID &&
81− option.modelID == selected.modelID
81+ option.modelID == selected?.modelID
8282 Column(
8383 modifier = Modifier
8484 .fillMaxWidth()
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+17−4
@@ -16,6 +16,10 @@ import androidx.compose.material3.OutlinedButton
1616import androidx.compose.material3.Text
1717import androidx.compose.material3.TextButton
1818import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.mutableStateOf
20+import androidx.compose.runtime.remember
21+import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.setValue
1923import androidx.compose.ui.Modifier
2024import androidx.compose.ui.platform.LocalHapticFeedback
2125import androidx.compose.ui.res.stringResource
@@ -36,8 +40,17 @@ fun PermissionDialog(
3640 onRespond: (PermissionResponse) -> Unit,
3741) {
3842 val haptics = LocalHapticFeedback.current
43+ // Guard against double-respond: back press + button tap, or rapid double-tap,
44+ // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
45+ var responded by remember { mutableStateOf(false) }
46+ val respond: (PermissionResponse) -> Unit = { response ->
47+ if (!responded) {
48+ responded = true
49+ onRespond(response)
50+ }
51+ }
3952 // Intercept back so the dialog can't be dismissed without an explicit choice.
40− BackHandler { onRespond(PermissionResponse.REJECT) }
53+ BackHandler { respond(PermissionResponse.REJECT) }
4154 AlertDialog(
4255 onDismissRequest = { /* require an explicit choice */ },
4356 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
@@ -70,14 +83,14 @@ fun PermissionDialog(
7083 Button(
7184 onClick = {
7285 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
73− onRespond(PermissionResponse.ONCE)
86+ respond(PermissionResponse.ONCE)
7487 },
7588 modifier = Modifier.fillMaxWidth(),
7689 ) { Text(stringResource(R.string.allow_once)) }
7790 OutlinedButton(
7891 onClick = {
7992 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
80− onRespond(PermissionResponse.ALWAYS)
93+ respond(PermissionResponse.ALWAYS)
8194 },
8295 modifier = Modifier.fillMaxWidth(),
8396 ) { Text(stringResource(R.string.always_allow)) }
@@ -86,7 +99,7 @@ fun PermissionDialog(
8699 dismissButton = {
87100 TextButton(onClick = {
88101 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
89− onRespond(PermissionResponse.REJECT)
102+ respond(PermissionResponse.REJECT)
90103 }) {
91104 Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error)
92105 }
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+11−2
@@ -14,6 +14,8 @@ import androidx.compose.ui.Alignment
1414import androidx.compose.ui.Modifier
1515import androidx.compose.ui.res.stringResource
1616import androidx.compose.ui.semantics.contentDescription
17+import androidx.compose.ui.semantics.liveRegion
18+import androidx.compose.ui.semantics.LiveRegionMode
1719import androidx.compose.ui.semantics.semantics
1820import androidx.compose.ui.unit.dp
1921import soy.iko.opencode.R
@@ -36,7 +38,14 @@ fun ConnectionBanner(
3638 }
3739 if (text != null) {
3840 Surface(
39− modifier = modifier.fillMaxWidth(),
41+ modifier = modifier
42+ .fillMaxWidth()
43+ .semantics {
44+ // Announce connection state changes to TalkBack users so they're
45+ // aware the stream dropped/reconnecting without visual cues.
46+ liveRegion = LiveRegionMode.Polite
47+ contentDescription = text
48+ },
4049 color = MaterialTheme.colorScheme.tertiaryContainer,
4150 ) {
4251 Row(
@@ -45,7 +54,7 @@ fun ConnectionBanner(
4554 verticalAlignment = Alignment.CenterVertically,
4655 ) {
4756 CircularProgressIndicator(
48− Modifier.size(12.dp).semantics { contentDescription = text },
57+ Modifier.size(12.dp),
4958 strokeWidth = 2.dp,
5059 )
5160 Text(
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+19−2
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.Row
88import androidx.compose.foundation.layout.Spacer
99import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.height
1011import androidx.compose.foundation.layout.padding
1112import androidx.compose.foundation.layout.width
1213import androidx.compose.foundation.rememberScrollState
@@ -34,12 +35,20 @@ sealed interface DiffLine {
3435 val text: String
3536
3637 data class Hunk(override val text: String) : DiffLine // @@ ... @@
37− data class FileHeader(override val text: String) : DiffLine // --- / +++
38+ data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
3839 data class Context(override val text: String) : DiffLine
3940 data class Add(override val text: String) : DiffLine
4041 data class Remove(override val text: String) : DiffLine
42+ data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
4143}
4244
45+/** Git metadata line prefixes that aren't part of the diff content. */
46+private val gitMetaPrefixes = listOf(
47+ "new file mode ", "deleted file mode ", "old mode ", "new mode ",
48+ "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
49+ "copy from ", "copy to ", "Binary files ", "\\ No newline",
50+)
51+
4352/** Parse a unified diff string into typed [DiffLine]s. */
4453fun parseDiff(diff: String): List<DiffLine> {
4554 val result = mutableListOf<DiffLine>()
@@ -51,6 +60,7 @@ fun parseDiff(diff: String): List<DiffLine> {
5160 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
5261 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
5362 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
63+ gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
5464 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
5565 }
5666 }
@@ -122,13 +132,20 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
122132 color = MaterialTheme.colorScheme.onSurfaceVariant,
123133 modifier = Modifier.padding(vertical = 1.dp),
124134 )
135+ is DiffLine.Meta -> Text(
136+ line.text,
137+ style = MaterialTheme.typography.bodySmall,
138+ fontFamily = FontFamily.Monospace,
139+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
140+ modifier = Modifier.padding(vertical = 1.dp),
141+ )
125142 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
126143 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
127144 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
128145 }
129146 }
130147 }
131− Spacer(Modifier.padding(bottom = 10.dp))
148+ Spacer(Modifier.height(10.dp))
132149 }
133150}
134151
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+21−8
@@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
66import androidx.compose.runtime.mutableLongStateOf
77import androidx.compose.runtime.remember
88import androidx.compose.runtime.setValue
9+import androidx.lifecycle.compose.LocalLifecycleOwner
910import kotlinx.coroutines.delay
1011import java.util.concurrent.TimeUnit
1112
@@ -25,25 +26,37 @@ fun relativeTime(epochMillis: Long?): String {
2526 if (weeks < 5) return "${weeks}w"
2627 val months = days / 30
2728 if (months < 12) return "${months}mo"
28− return "${days / 365}y"
29+ val years = (days / 365).coerceAtLeast(1)
30+ return "${years}y"
2931}
3032
3133/**
3234 * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
3335 * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
3436 * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
35− * smallest unit we show, which is minutes).
37+ * smallest unit we show, which is minutes). Pauses the timer when the lifecycle is
38+ * not at least resumed (screen off / app backgrounded) to avoid battery drain.
3639 */
3740@Composable
3841fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
39− // Recompose trigger: incrementing this state causes the composable to re-execute
40− // on each interval, re-evaluating relativeTime() so the label stays fresh.
4142 var tick by remember { mutableLongStateOf(0L) }
42− LaunchedEffect(epochMillis) {
43+ val lifecycleOwner = LocalLifecycleOwner.current
44+ LaunchedEffect(epochMillis, lifecycleOwner) {
4345 if (epochMillis == null) return@LaunchedEffect
44− while (true) {
45− delay(intervalMs)
46− tick++
46+ // Observe lifecycle to pause the timer when the screen isn't visible.
47+ val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
48+ if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
49+ tick++
50+ }
51+ }
52+ lifecycleOwner.lifecycle.addObserver(observer)
53+ try {
54+ while (true) {
55+ delay(intervalMs)
56+ tick++
57+ }
58+ } finally {
59+ lifecycleOwner.lifecycle.removeObserver(observer)
4760 }
4861 }
4962 // Read tick so Compose tracks it as a dependency and recomposes when it changes.
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+3−3
@@ -201,7 +201,7 @@ private fun DirectoryListing(
201201 HorizontalDivider()
202202 }
203203 }
204− items(state.entries, key = { it.path }) { node ->
204+ items(state.entries, key = { it.path + "_" + it.name }) { node ->
205205 FileRow(
206206 icon = node.isDirectory,
207207 label = node.name,
@@ -251,8 +251,8 @@ private fun FileRow(
251251@Composable
252252private fun StatusBadge(status: FileStatusEntry) {
253253 val (letter, color, desc) = when (status.status) {
254− "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
255− "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
254+ "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
255+ "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
256256 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
257257 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
258258 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+9−2
@@ -95,10 +95,16 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
9595 )
9696 return
9797 }
98− _state.value = _state.value.copy(path = path, loading = true, error = null)
98+ _state.value = _state.value.copy(
99+ path = path,
100+ query = "",
101+ results = emptyList(),
102+ searching = false,
103+ loading = true,
104+ error = null,
105+ )
99106 openJob?.cancel()
100107 searchJob?.cancel()
101− if (_state.value.isSearching) _state.value = _state.value.copy(results = emptyList(), searching = false)
102108 openJob = viewModelScope.launch {
103109 runCatchingCancellable { client.listDirectory(path) }
104110 .onSuccess { entries ->
@@ -122,6 +128,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
122128 fun setQuery(query: String) {
123129 _state.value = _state.value.copy(query = query, error = null)
124130 searchJob?.cancel()
131+ openJob?.cancel()
125132 if (query.isBlank()) {
126133 _state.value = _state.value.copy(results = emptyList(), searching = false)
127134 return
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+4
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.file
22
33import android.content.Intent
4+import android.widget.Toast
45import androidx.compose.foundation.horizontalScroll
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Row
@@ -78,6 +79,9 @@ fun FileViewScreen(
7879 putExtra(Intent.EXTRA_TEXT, content)
7980 }
8081 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
82+ .onFailure {
83+ Toast.makeText(context, context.getString(R.string.no_share_app), Toast.LENGTH_SHORT).show()
84+ }
8185 }) {
8286 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
8387 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+5−1
@@ -48,8 +48,12 @@ class ServerEditViewModel(
4848 init {
4949 viewModelScope.launch {
5050 if (profileId != null) {
51+ // Wait for a non-empty profile list before searching, so DataStore
52+ // emitting an empty list during loading doesn't cause a false timeout.
5153 val existing = withTimeoutOrNull(5_000) {
52− container.profileStore.profiles.first().firstOrNull { it.id == profileId }
54+ container.profileStore.profiles
55+ .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
56+ .firstOrNull { it.id == profileId }
5357 }
5458 if (existing != null) {
5559 _state.value = ServerEditState(
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+5
@@ -53,6 +53,11 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
5353
5454 fun delete(profile: ServerProfile) {
5555 viewModelScope.launch {
56+ // If the user is deleting the currently-connected profile, disconnect
57+ // first so the active connection doesn't linger pointing at a deleted server.
58+ if (container.activeConnection.value?.profile?.id == profile.id) {
59+ container.disconnect()
60+ }
5661 runCatchingCancellable { container.profileStore.delete(profile.id) }
5762 .onFailure { _error.value = container.friendlyError(it) }
5863 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.session
22
33import androidx.compose.foundation.background
4+import androidx.activity.compose.BackHandler
45import androidx.compose.foundation.clickable
56import androidx.compose.foundation.layout.Arrangement
67import androidx.compose.foundation.layout.Box
@@ -103,6 +104,9 @@ fun SessionListScreen(
103104 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
104105 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
105106
107+ // Close the server dropdown on back press instead of navigating away.
108+ BackHandler(enabled = showServerMenu) { showServerMenu = false }
109+
106110 LaunchedEffect(transientError) {
107111 val msg = transientError ?: return@LaunchedEffect
108112 snackbar.showSnackbar(msg)
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+22−15
@@ -135,7 +135,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
135135 is SessionDeleted -> {
136136 val id = event.properties.info?.id ?: event.properties.sessionID
137137 if (id != null) {
138− livePreviewJobs.remove(id)?.cancel()
138+ synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
139139 container.draftStore.remove(id)
140140 _state.update { s ->
141141 s.copy(
@@ -173,21 +173,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
173173 _refreshing.value = true
174174 refreshJob?.cancel()
175175 refreshJob = viewModelScope.launch {
176− runCatchingCancellable { conn.repository.listSessions() }
177− .onSuccess { list ->
178− val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
179− _state.update { it.copy(sessions = sorted, loading = false, error = null) }
180− loadPreviews(sorted)
181− }
182− .onFailure {
183− if (_state.value.sessions.isNotEmpty()) {
184− _state.update { it.copy(loading = false) }
185− _transientError.value = container.friendlyError(it)
186− } else {
187− _state.value = SessionListState(loading = false, error = container.friendlyError(it))
176+ try {
177+ runCatchingCancellable { conn.repository.listSessions() }
178+ .onSuccess { list ->
179+ val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
180+ _state.update { it.copy(sessions = sorted, loading = false, error = null) }
181+ loadPreviews(sorted)
188182 }
189− }
190− _refreshing.value = false
183+ .onFailure {
184+ if (_state.value.sessions.isNotEmpty()) {
185+ _state.update { it.copy(loading = false) }
186+ _transientError.value = container.friendlyError(it)
187+ } else {
188+ _state.value = SessionListState(loading = false, error = container.friendlyError(it))
189+ }
190+ }
191+ } finally {
192+ _refreshing.value = false
193+ }
191194 }
192195 }
193196
@@ -281,6 +284,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
281284 if (profile.id == activeProfileId) return
282285 if (_switchingId.value != null) return
283286 val previousProfile = container.activeConnection.value?.profile
287+ val savedUnread = container.unread.value
284288 _switchingId.value = profile.id
285289 _state.update { it.copy(loading = true, error = null) }
286290 viewModelScope.launch {
@@ -297,6 +301,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
297301 container.disconnect()
298302 if (previousProfile != null) {
299303 runCatchingCancellable { container.connect(previousProfile) }
304+ // connect() clears unread state; restore what we saved so the
305+ // user doesn't lose unread badges on a failed server switch.
306+ savedUnread.forEach { container.restoreUnread(it) }
300307 _state.update { it.copy(loading = false, error = container.friendlyError(error)) }
301308 } else {
302309 _state.value = SessionListState(loading = false, error = container.friendlyError(error))
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+3−1
@@ -61,7 +61,9 @@ fun TwoPaneSessionChat(
6161 // Inject a pending share into the currently selected session's draft (if any).
6262 // Unlike single-pane mode where the session list is navigated to specifically for
6363 // the share, in two-pane mode the list is always visible — so we inject into the
64− // session the user is already viewing rather than whichever one they tap next.
64+ // session the user is already viewing. If no session is selected, the share is
65+ // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
66+ // changes to non-null).
6567 LaunchedEffect(pendingShare, selected) {
6668 val target = selected
6769 if (pendingShare != null && target != null) {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+3−1
@@ -161,7 +161,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
161161 if (reportName != null) {
162162 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
163163 LaunchedEffect(reportName) {
164− reportContent = withContext(Dispatchers.IO) { logger.readReport(reportName) }
164+ reportContent = withContext(Dispatchers.IO) {
165+ runCatching { logger.readReport(reportName) }.getOrNull()
166+ }
165167 }
166168 val content = reportContent
167169 AlertDialog(
app/src/main/res/values/strings.xml+1
@@ -12,6 +12,7 @@
1212 <string name="remove">Remove</string>
1313 <string name="copy">Copy</string>
1414 <string name="copied">Copied</string>
15+ <string name="no_share_app">No app available to share</string>
1516 <string name="retry">Retry</string>
1617 <string name="refresh">Refresh</string>
1718 <string name="settings">Settings</string>