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
d643c13iko
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+11−1
@@ -3,11 +3,14 @@ package soy.iko.opencode
3import android.app.Application
3import android.app.Application
4import soy.iko.opencode.data.repo.CrashLogger
4import soy.iko.opencode.data.repo.CrashLogger
5import soy.iko.opencode.di.AppContainer
5import soy.iko.opencode.di.AppContainer
6+import java.util.concurrent.atomic.AtomicBoolean
6
7
7class OpencodeApp : Application() {
8class OpencodeApp : Application() {
8 lateinit var container: AppContainer
9 lateinit var container: AppContainer
9 private set
10 private set
10
11
12+ private val shutdownCalled = AtomicBoolean(false)
13+
11 override fun onCreate() {
14 override fun onCreate() {
12 super.onCreate()
15 super.onCreate()
13 CrashLogger.get(this).install()
16 CrashLogger.get(this).install()
@@ -16,12 +19,19 @@ class OpencodeApp : Application() {
16 // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
19 // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
17 // scope cancellation, connection close) runs when the process is actually killed.
20 // scope cancellation, connection close) runs when the process is actually killed.
18 Runtime.getRuntime().addShutdownHook(Thread {
21 Runtime.getRuntime().addShutdownHook(Thread {
19− container.shutdown()
22+ shutdown()
20 })
23 })
21 }
24 }
22
25
23 override fun onTerminate() {
26 override fun onTerminate() {
24 super.onTerminate()
27 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
25 container.shutdown()
35 container.shutdown()
26 }
36 }
27}
37}
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+1−1
@@ -25,7 +25,7 @@ data class Command(
25 val description: String? = null,
25 val description: String? = null,
26 val agent: String? = null,
26 val agent: String? = null,
27 val model: String? = null,
27 val model: String? = null,
28− val template: String,
28+ val template: String = "",
29 val subtask: Boolean = false,
29 val subtask: Boolean = false,
30) {
30) {
31 val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
31 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(
150 },
150 },
151 ) {
151 ) {
152 backoffMs = initialBackoffMs
152 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+ }
153 // Clear any stale reconnect signal from a triggerReconnect() call
159 // Clear any stale reconnect signal from a triggerReconnect() call
154 // made while the previous stream was healthy, so it doesn't suppress
160 // made while the previous stream was healthy, so it doesn't suppress
155 // the next legitimate backoff.
161 // the next legitimate backoff.
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2
@@ -24,6 +24,8 @@ object NetworkConfig {
24 const val retryMaxAttempts = 3
24 const val retryMaxAttempts = 3
25 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
25 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26 const val retryInitialDelayMs = 500L
26 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
27 /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
29 /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
28 const val retryJitterFactor = 0.2
30 const val retryJitterFactor = 0.2
29
31
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+31−25
@@ -124,40 +124,43 @@ class OpencodeApiClient(private val client: HttpClient) {
124 }.body()
124 }.body()
125 }
125 }
126
126
127− suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
127+ suspend fun providers(): ProvidersResponse {
128 val now = System.currentTimeMillis()
128 val now = System.currentTimeMillis()
129+ cacheMutex.withLock {
129− val cached = cachedProviders
130+ val cached = cachedProviders
130− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
131+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
131− cached.value
132+ return cached.value
132− } else {
133+ }
133− val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
134− cachedProviders = CachedEntry(value, now)
135− value
136 }
134 }
135+ val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
136+ cacheMutex.withLock { cachedProviders = CachedEntry(value, System.currentTimeMillis()) }
137+ return value
137 }
138 }
138
139
139− suspend fun agents(): List<Agent> = cacheMutex.withLock {
140+ suspend fun agents(): List<Agent> {
140 val now = System.currentTimeMillis()
141 val now = System.currentTimeMillis()
142+ cacheMutex.withLock {
141− val cached = cachedAgents
143+ val cached = cachedAgents
142− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
144+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
143− cached.value
145+ return cached.value
144− } else {
146+ }
145− val value = withRetry { client.get("agent").body<List<Agent>>() }
146− cachedAgents = CachedEntry(value, now)
147− value
148 }
147 }
148+ val value = withRetry { client.get("agent").body<List<Agent>>() }
149+ cacheMutex.withLock { cachedAgents = CachedEntry(value, System.currentTimeMillis()) }
150+ return value
149 }
151 }
150
152
151− suspend fun commands(): List<Command> = cacheMutex.withLock {
153+ suspend fun commands(): List<Command> {
152 val now = System.currentTimeMillis()
154 val now = System.currentTimeMillis()
155+ cacheMutex.withLock {
153− val cached = cachedCommands
156+ val cached = cachedCommands
154− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
157+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
155− cached.value
158+ return cached.value
156− } else {
159+ }
157− val value = withRetry { client.get("command").body<List<Command>>() }
158− cachedCommands = CachedEntry(value, now)
159− value
160 }
160 }
161+ val value = withRetry { client.get("command").body<List<Command>>() }
162+ cacheMutex.withLock { cachedCommands = CachedEntry(value, System.currentTimeMillis()) }
163+ return value
161 }
164 }
162
165
163 /** Invalidate the catalog cache (e.g. on server switch). */
166 /** Invalidate the catalog cache (e.g. on server switch). */
@@ -231,7 +234,10 @@ internal suspend fun <T> withRetryInternal(
231 if (t.response.status.value != 429) throw t
234 if (t.response.status.value != 429) throw t
232 }
235 }
233 if (attempt < maxAttempts) {
236 if (attempt < maxAttempts) {
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)
234− val baseDelay = initialDelayMs * (1 shl (attempt - 1))
240+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
235 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
241 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
236 // shorter, sometimes longer — spreads concurrent client retries and
242 // shorter, sometimes longer — spreads concurrent client retries and
237 // prevents thundering-herd reconnection storms.
243 // 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) {
81 }
81 }
82
82
83 fun deleteReport(fileName: String) {
83 fun deleteReport(fileName: String) {
84+ scope.launch {
84− val file = File(crashDir, fileName).canonicalFile
85+ val file = File(crashDir, fileName).canonicalFile
85− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
86+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
86− file.delete()
87+ file.delete()
88+ }
89+ refresh()
87 }
90 }
88− refresh()
89 }
91 }
90
92
91 fun clearAll() {
93 fun clearAll() {
94+ scope.launch {
92− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
95+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
93− refresh()
96+ refresh()
97+ }
94 }
98 }
95
99
96 fun reportCount(): Int = _reports.value.size
100 fun reportCount(): Int = _reports.value.size
@@ -107,7 +111,7 @@ class CrashLogger private constructor(private val appContext: Context) {
107 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
111 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
108 // Append a short random suffix so two crashes in the same millisecond don't
112 // Append a short random suffix so two crashes in the same millisecond don't
109 // overwrite each other.
113 // overwrite each other.
110− val suffix = Integer.toHexString(System.nanoTime().toInt())
114+ val suffix = java.lang.Long.toHexString(System.nanoTime())
111 val file = File(crashDir, "crash-$stamp-$suffix.txt")
115 val file = File(crashDir, "crash-$stamp-$suffix.txt")
112 val sw = StringWriter()
116 val sw = StringWriter()
113 PrintWriter(sw).use { pw ->
117 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
9import kotlinx.coroutines.flow.update
9import kotlinx.coroutines.flow.update
10import android.util.Log
10import android.util.Log
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12−import kotlinx.coroutines.runBlocking
13import kotlinx.coroutines.withContext
12import kotlinx.coroutines.withContext
13+import java.util.concurrent.Executors
14
14
15/**
15/**
16 * Persists the in-progress message draft for each session so it survives
16 * Persists the in-progress message draft for each session so it survives
@@ -36,6 +36,12 @@ class DraftStore(context: Context, scope: CoroutineScope) {
36 * background thread to avoid blocking on first-time disk I/O. */
36 * background thread to avoid blocking on first-time disk I/O. */
37 private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
37 private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
38
38
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+
39 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
45 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
40 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
46 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
41 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
47 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
@@ -60,6 +66,17 @@ class DraftStore(context: Context, scope: CoroutineScope) {
60 /** Returns the draft for [sessionId], or "" if not found or still loading. */
66 /** Returns the draft for [sessionId], or "" if not found or still loading. */
61 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
67 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
62
68
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+
63 suspend fun set(sessionId: String, text: String) {
80 suspend fun set(sessionId: String, text: String) {
64 _drafts.update { current ->
81 _drafts.update { current ->
65 val updated = current.toMutableMap()
82 val updated = current.toMutableMap()
@@ -98,9 +115,10 @@ class DraftStore(context: Context, scope: CoroutineScope) {
98 * [apply] (asynchronous) and returns immediately without blocking the main thread.
115 * [apply] (asynchronous) and returns immediately without blocking the main thread.
99 *
116 *
100 * If the prefs file has NOT been opened yet (a rare race where onCleared fires
117 * 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
118+ * before the background init), the write is dispatched to a background thread
102− * parse the XML on the main thread. To avoid this, the write is deferred to a
119+ * instead of blocking the main thread on a synchronous disk read. The draft
103− * blocking call on the IO dispatcher instead.
120+ * may be lost if the process exits before the write completes, but this is
121+ * preferable to an ANR.
104 */
122 */
105 fun flushDraft(sessionId: String, text: String) {
123 fun flushDraft(sessionId: String, text: String) {
106 _drafts.update { current ->
124 _drafts.update { current ->
@@ -116,16 +134,16 @@ class DraftStore(context: Context, scope: CoroutineScope) {
116 }.apply()
134 }.apply()
117 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
135 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
118 } else {
136 } else {
137+ // Prefs not yet opened — dispatch to a background thread to avoid a
119− // Prefs not yet opened — avoid a synchronous disk read on the main thread
138+ // synchronous disk read on the main thread (which would ANR). The draft
120− // by blocking on IO instead. This is acceptable in onCleared() because the
139+ // may be lost if the process exits before the write completes.
121− // viewModelScope is already dead and there's no suspending alternative.
140+ flushExecutor.execute {
122− runCatching {
141+ runCatching {
123− runBlocking(Dispatchers.IO) {
124 prefs.edit().apply {
142 prefs.edit().apply {
125 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
143 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
126 }.apply()
144 }.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+ }
129 }
147 }
130 }
148 }
131}
149}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+3−2
@@ -2,6 +2,7 @@ package soy.iko.opencode.data.repo
2
2
3import io.ktor.client.plugins.ClientRequestException
3import io.ktor.client.plugins.ClientRequestException
4import io.ktor.client.plugins.HttpRequestTimeoutException
4import io.ktor.client.plugins.HttpRequestTimeoutException
5+import io.ktor.client.plugins.RedirectResponseException
5import io.ktor.client.plugins.ResponseException
6import io.ktor.client.plugins.ResponseException
6import io.ktor.client.plugins.ServerResponseException
7import io.ktor.client.plugins.ServerResponseException
7import java.io.IOException
8import java.io.IOException
@@ -51,10 +52,10 @@ fun classifyError(t: Throwable): ErrorKind {
51 return ErrorKind.TIMEOUT
52 return ErrorKind.TIMEOUT
52 is ServerResponseException -> return ErrorKind.SERVER
53 is ServerResponseException -> return ErrorKind.SERVER
53 is ClientRequestException -> return ErrorKind.CLIENT
54 is ClientRequestException -> return ErrorKind.CLIENT
55+ is RedirectResponseException -> return ErrorKind.CLIENT
54 is IOException -> return ErrorKind.NETWORK
56 is IOException -> return ErrorKind.NETWORK
55 else -> {
57 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).
58 if (current is ResponseException) return ErrorKind.SERVER
59 if (current is ResponseException) return ErrorKind.SERVER
59 }
60 }
60 }
61 }
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+24−1
@@ -59,6 +59,23 @@ class ProfileStore(context: Context) {
59 private fun prefsForPasswords(): SharedPreferences =
59 private fun prefsForPasswords(): SharedPreferences =
60 securePrefs ?: fallbackPrefs
60 securePrefs ?: fallbackPrefs
61
61
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+
62 /** Stored shape on DataStore (everything except the secret password). */
79 /** Stored shape on DataStore (everything except the secret password). */
63 @Serializable
80 @Serializable
64 private data class StoredProfile(
81 private data class StoredProfile(
@@ -70,6 +87,8 @@ class ProfileStore(context: Context) {
70 )
87 )
71
88
72 val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
89 val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
90+ // Migrate any orphaned plaintext passwords to secure prefs on first load.
91+ migrateFallbackPasswords()
73 val json = prefs[profilesKey] ?: return@map emptyList()
92 val json = prefs[profilesKey] ?: return@map emptyList()
74 runCatching {
93 runCatching {
75 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
94 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
@@ -144,8 +163,12 @@ class ProfileStore(context: Context) {
144 current.filterNot { it.id == id },
163 current.filterNot { it.id == id },
145 )
164 )
146 }
165 }
166+ // Clean up the password in both secure and fallback prefs so no orphaned
167+ // plaintext password lingers if secure prefs were temporarily unavailable.
147 withContext(Dispatchers.IO) {
168 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()
149 }
172 }
150 }
173 }
151
174
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+27−15
@@ -78,20 +78,20 @@ class SessionRepository(
78 // re-fetching the current snapshot fills those holes. The seed merge logic
78 // re-fetching the current snapshot fills those holes. The seed merge logic
79 // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
79 // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
80 //
80 //
81− // Both the REST fetch and the seed are performed under the lock so that events
81+ // The REST fetch is performed *outside* the lock so events arriving via SSE
82− // arriving via SSE between the fetch and the seed are not incorrectly pruned
82+ // during the fetch are not blocked — holding the lock during a network call
83− // by the prune=true pass.
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.
84 launch {
86 launch {
85 var wasConnected = false
87 var wasConnected = false
86 eventStream.state.collect { state ->
88 eventStream.state.collect { state ->
87 if (state == EventStreamClient.ConnectionState.Connected) {
89 if (state == EventStreamClient.ConnectionState.Connected) {
88 if (wasConnected) {
90 if (wasConnected) {
89− lock.withLock {
90− val fresh = runCatchingCancellable { api.listMessages(sessionId) }
91+ val fresh = runCatchingCancellable { api.listMessages(sessionId) }
91− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE", it) }
92+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE", it) }
92− .getOrDefault(emptyList())
93+ .getOrDefault(emptyList())
93− store.seed(fresh, prune = true)
94+ lock.withLock { store.seed(fresh, prune = true) }
94− }
95 publish()
95 publish()
96 }
96 }
97 wasConnected = true
97 wasConnected = true
@@ -113,12 +113,14 @@ class SessionRepository(
113 }.conflate()
113 }.conflate()
114
114
115 companion object {
115 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. */
117 fun isIdle(event: BusEvent, sessionId: String): Boolean =
119 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
119
121
120 fun isError(event: BusEvent, sessionId: String): Boolean =
122 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
122 }
124 }
123}
125}
124
126
@@ -177,8 +179,17 @@ internal class MessageStore {
177 if (info.sessionID != sessionId) {
179 if (info.sessionID != sessionId) {
178 false
180 false
179 } else {
181 } else {
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+ }
180− val existing = messages[info.id]
191+ val existing = messages[key]
181− messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
192+ messages[key] = existing?.copy(info = info) ?: MessageWithParts(info)
182 if (existing == null) evictOldMessages()
193 if (existing == null) evictOldMessages()
183 true
194 true
184 }
195 }
@@ -214,6 +225,7 @@ internal class MessageStore {
214 val current = messages[messageId]
225 val current = messages[messageId]
215 val parts = current?.parts.orEmpty()
226 val parts = current?.parts.orEmpty()
216 val idx = parts.indexOfFirst { it.id == part.id }
227 val idx = parts.indexOfFirst { it.id == part.id }
228+ if (idx >= 0 && parts[idx] == part) return false
217 val newParts = if (idx >= 0) {
229 val newParts = if (idx >= 0) {
218 parts.toMutableList().also { it[idx] = part }
230 parts.toMutableList().also { it[idx] = part }
219 } else {
231 } else {
@@ -224,7 +236,7 @@ internal class MessageStore {
224 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
236 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
225 parts = newParts,
237 parts = newParts,
226 )
238 )
227− evictOldMessages()
239+ if (current == null) evictOldMessages()
228 return true
240 return true
229 }
241 }
230
242
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+27−11
@@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
25import kotlinx.coroutines.ExperimentalCoroutinesApi
25import kotlinx.coroutines.ExperimentalCoroutinesApi
26import kotlinx.coroutines.SupervisorJob
26import kotlinx.coroutines.SupervisorJob
27import kotlinx.coroutines.cancel
27import kotlinx.coroutines.cancel
28+import kotlinx.coroutines.delay
28import kotlinx.coroutines.flow.MutableStateFlow
29import kotlinx.coroutines.flow.MutableStateFlow
29import kotlinx.coroutines.flow.StateFlow
30import kotlinx.coroutines.flow.StateFlow
30import kotlinx.coroutines.flow.asStateFlow
31import kotlinx.coroutines.flow.asStateFlow
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.emptyFlow
32import kotlinx.coroutines.flow.first
33import kotlinx.coroutines.flow.first
33import kotlinx.coroutines.flow.flatMapLatest
34import kotlinx.coroutines.flow.flatMapLatest
34import kotlinx.coroutines.flow.update
35import kotlinx.coroutines.flow.update
36+import kotlinx.coroutines.isActive
35import kotlinx.coroutines.launch
37import kotlinx.coroutines.launch
36import kotlinx.coroutines.sync.Mutex
38import kotlinx.coroutines.sync.Mutex
37import kotlinx.coroutines.sync.withLock
39import kotlinx.coroutines.sync.withLock
@@ -108,6 +110,13 @@ class AppContainer(context: Context) {
108 }
110 }
109 }
111 }
110
112
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+
111 /** Resolve a localized string — view models reach resources through the container. */
120 /** Resolve a localized string — view models reach resources through the container. */
112 fun string(id: Int, vararg formatArgs: Any): String =
121 fun string(id: Int, vararg formatArgs: Any): String =
113 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
122 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
@@ -141,19 +150,20 @@ class AppContainer(context: Context) {
141
150
142 /** Release resources held for the process lifetime (network callback, app scope). */
151 /** Release resources held for the process lifetime (network callback, app scope). */
143 fun shutdown() {
152 fun shutdown() {
144− // Acquire the connection mutex so a concurrent connect() can't leak a new
153+ // Cancel the app scope first so any coroutine holding the connection mutex
145− // connection after we've nulled the reference. This runs on a shutdown hook
154+ // (e.g. a connect() suspended on profileStore) is cancelled and releases the
146− // thread (or onTerminate), so a brief blocking wait is acceptable here.
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()
147 kotlinx.coroutines.runBlocking {
158 kotlinx.coroutines.runBlocking {
148 connectionMutex.withLock {
159 connectionMutex.withLock {
149− _activeConnection.value?.close()
160+ runCatching { _activeConnection.value?.close() }
150 _activeConnection.value = null
161 _activeConnection.value = null
151 }
162 }
152 }
163 }
153 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
164 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
154 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
165 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
155 soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
166 soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
156− appScope.cancel()
157 }
167 }
158
168
159 /**
169 /**
@@ -167,10 +177,14 @@ class AppContainer(context: Context) {
167 @OptIn(ExperimentalCoroutinesApi::class)
177 @OptIn(ExperimentalCoroutinesApi::class)
168 private fun observeMessageActivity() {
178 private fun observeMessageActivity() {
169 appScope.launch {
179 appScope.launch {
170− runCatchingCancellable {
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 {
171− activeConnection
185+ activeConnection
172− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
186+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
173− .collect { event ->
187+ .collect { event ->
174 // SessionIdle is not "message activity" (don't badge as unread)
188 // SessionIdle is not "message activity" (don't badge as unread)
175 // but signals a run finished — fire a completion notification if the
189 // but signals a run finished — fire a completion notification if the
176 // session isn't currently being viewed and was actively streaming.
190 // session isn't currently being viewed and was actively streaming.
@@ -198,8 +212,10 @@ class AppContainer(context: Context) {
198 activeRuns.add(sid)
212 activeRuns.add(sid)
199 }
213 }
200 }
214 }
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+ }
203 }
219 }
204 }
220 }
205
221
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1
@@ -35,6 +35,6 @@ class OpencodeConnection(val profile: ServerProfile) {
35 suspend fun close() {
35 suspend fun close() {
36 scope.cancel()
36 scope.cancel()
37 scopeJob.join()
37 scopeJob.join()
38− client.close()
38+ runCatching { client.close() }
39 }
39 }
40}
40}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+14−2
@@ -1,6 +1,7 @@
1package soy.iko.opencode.notification
1package soy.iko.opencode.notification
2
2
3import android.app.Notification
3import android.app.Notification
4+import android.app.PendingIntent
4import android.app.Service
5import android.app.Service
5import android.content.Context
6import android.content.Context
6import android.content.Intent
7import android.content.Intent
@@ -10,6 +11,7 @@ import android.os.IBinder
10import android.util.Log
11import android.util.Log
11import androidx.core.app.NotificationCompat
12import androidx.core.app.NotificationCompat
12import androidx.core.app.ServiceCompat
13import androidx.core.app.ServiceCompat
14+import soy.iko.opencode.MainActivity
13import soy.iko.opencode.R
15import soy.iko.opencode.R
14
16
15/**
17/**
@@ -48,15 +50,25 @@ class RunForegroundService : Service() {
48 return START_NOT_STICKY
50 return START_NOT_STICKY
49 }
51 }
50
52
51− private fun buildNotification(): Notification =
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+ )
52− NotificationCompat.Builder(this, NotificationChannels.STATUS)
62+ return NotificationCompat.Builder(this, NotificationChannels.STATUS)
53 .setSmallIcon(R.drawable.ic_launcher_foreground)
63 .setSmallIcon(R.drawable.ic_launcher_foreground)
54 .setContentTitle(getString(R.string.notif_running_title))
64 .setContentTitle(getString(R.string.notif_running_title))
55 .setContentText(getString(R.string.notif_running_text))
65 .setContentText(getString(R.string.notif_running_text))
66+ .setContentIntent(pendingIntent)
56 .setOngoing(true)
67 .setOngoing(true)
57 .setPriority(NotificationCompat.PRIORITY_LOW)
68 .setPriority(NotificationCompat.PRIORITY_LOW)
58 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
69 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
59 .build()
70 .build()
71+ }
60
72
61 override fun onDestroy() {
73 override fun onDestroy() {
62 // Safety net: if the process is being killed while a run is in progress,
74 // 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 {
24 private const val NOTIF_ID_PREFIX = 4000
24 private const val NOTIF_ID_PREFIX = 4000
25 private const val TAG = "SessionNotifications"
25 private const val TAG = "SessionNotifications"
26
26
27+ private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
28+
27 /** Derive a stable, collision-resistant notification id from a session id.
29 /** Derive a stable, collision-resistant notification id from a session id.
28 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
30 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
29 * bits makes accidental collisions astronomically unlikely. */
31 * bits makes accidental collisions astronomically unlikely. */
30 private fun notifIdFor(sessionId: String): Int {
32 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
32 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
35 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
33 val hash = ((digest[0].toInt() and 0xFF) shl 24 or
36 val hash = ((digest[0].toInt() and 0xFF) shl 24 or
34 (digest[1].toInt() and 0xFF) shl 16 or
37 (digest[1].toInt() and 0xFF) shl 16 or
@@ -51,9 +54,12 @@ object SessionNotifications {
51 return
54 return
52 }
55 }
53 val notifId = notifIdFor(sessionId)
56 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, "")
54 val openIntent = Intent(context, MainActivity::class.java).apply {
60 val openIntent = Intent(context, MainActivity::class.java).apply {
55 action = Intent.ACTION_VIEW
61 action = Intent.ACTION_VIEW
56− data = android.net.Uri.parse("opencode://session/$sessionId")
62+ data = android.net.Uri.parse("opencode://session/$safeSessionId")
57 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
63 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
58 }
64 }
59 val pendingIntent = PendingIntent.getActivity(
65 val pendingIntent = PendingIntent.getActivity(
@@ -63,7 +69,9 @@ object SessionNotifications {
63 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
69 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
64 .setSmallIcon(R.drawable.ic_launcher_foreground)
70 .setSmallIcon(R.drawable.ic_launcher_foreground)
65 .setContentTitle(context.getString(R.string.notif_completed_title))
71 .setContentTitle(context.getString(R.string.notif_completed_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.
66− .setContentText(context.getString(R.string.notif_completed_text, title))
74+ .setContentText(context.getString(R.string.notif_completed_text, title.replace("%", "%%")))
67 .setAutoCancel(true)
75 .setAutoCancel(true)
68 .setContentIntent(pendingIntent)
76 .setContentIntent(pendingIntent)
69 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
77 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−4
@@ -35,10 +35,11 @@ fun OpencodeApp(container: AppContainer) {
35
35
36 // When text is shared into the app, surface the session list so the user can pick
36 // When text is shared into the app, surface the session list so the user can pick
37 // (or create) a conversation to drop it into. The chosen session's draft is set
37 // (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.
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.
39− LaunchedEffect(pendingShare) {
40+ LaunchedEffect(pendingShare, connection) {
40 if (pendingShare == null) return@LaunchedEffect
41 if (pendingShare == null) return@LaunchedEffect
41− if (container.activeConnection.value == null) return@LaunchedEffect
42+ if (connection == null) return@LaunchedEffect
42 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
43 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
43 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
44 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
44 }
45 }
@@ -95,7 +96,13 @@ fun OpencodeApp(container: AppContainer) {
95 SessionListScreen(
96 SessionListScreen(
96 container = container,
97 container = container,
97 onOpenSession = { id ->
98 onOpenSession = { id ->
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)
98− container.consumePendingShare()?.let { scope.launch { runCatchingCancellable { container.draftStore.set(id, it) } } }
104+ scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
105+ }
99 navController.navigate(Routes.chat(id))
106 navController.navigate(Routes.chat(id))
100 },
107 },
101 onDisconnect = {
108 onDisconnect = {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+7
@@ -118,6 +118,7 @@ fun ChatScreen(
118 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
118 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
119 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
119 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
120 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
120 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
121+ val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
121 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
122 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
122 val draft by vm.draft.collectAsStateWithLifecycle()
123 val draft by vm.draft.collectAsStateWithLifecycle()
123 val haptics = LocalHapticFeedback.current
124 val haptics = LocalHapticFeedback.current
@@ -140,6 +141,12 @@ fun ChatScreen(
140 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
141 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
141 }
142 }
142
143
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+
143 val listState = rememberLazyListState()
150 val listState = rememberLazyListState()
144 val snackbar = remember { SnackbarHostState() }
151 val snackbar = remember { SnackbarHostState() }
145 var showModelPicker by rememberSaveable { mutableStateOf(false) }
152 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(
120 private val _sessionTitle = MutableStateFlow<String?>(null)
120 private val _sessionTitle = MutableStateFlow<String?>(null)
121 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
121 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
122
122
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+
123 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
127 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
124 private val _failedDraft = MutableStateFlow<String?>(null)
128 private val _failedDraft = MutableStateFlow<String?>(null)
125 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
129 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
@@ -228,7 +232,7 @@ class ChatViewModel(
228 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
232 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
229 is SessionDeleted ->
233 is SessionDeleted ->
230 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
234 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
231− _sessionTitle.value = event.properties.info?.displayTitle
235+ _sessionDeleted.value = true
232 }
236 }
233 else -> {}
237 else -> {}
234 }
238 }
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+13−4
@@ -13,9 +13,9 @@ import soy.iko.opencode.data.model.UserMessage
13 * be unit-tested directly.
13 * be unit-tested directly.
14 */
14 */
15fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
15fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
16− val sb = StringBuilder()
16+ val sb = StringBuilder(messages.size * 128)
17 if (!title.isNullOrBlank()) {
17 if (!title.isNullOrBlank()) {
18− sb.append("# ").append(title.trim()).append("\n\n")
18+ sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
19 }
19 }
20 for (message in messages) {
20 for (message in messages) {
21 val heading = when (message.info) {
21 val heading = when (message.info) {
@@ -26,8 +26,8 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
26 val body = message.parts
26 val body = message.parts
27 .mapNotNull { part ->
27 .mapNotNull { part ->
28 when (part) {
28 when (part) {
29− is TextPart -> part.text.takeIf { it.isNotBlank() }
29+ is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
30− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "_${it}_" }
30+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "> _${escapeMarkdown(it)}_" }
31 else -> null
31 else -> null
32 }
32 }
33 }
33 }
@@ -38,3 +38,12 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
38 }
38 }
39 return sb.toString().trimEnd()
39 return sb.toString().trimEnd()
40}
40}
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) {
105 }
105 }
106}
106}
107
107
108−private val tokenNumberFormat: java.text.NumberFormat =
108+private fun formatTokens(tokens: Tokens, format: String): String {
109− java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
109+ val nf = 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))
110+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
111+}
113
112
114private fun formatCost(cost: Double): String =
113private fun formatCost(cost: Double): String =
115 // Locale.US so the formatting is stable regardless of device locale (avoids
114 // 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(
76 )
76 )
77 } else {
77 } else {
78 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
78 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 ->
80 val isSelected = option.providerID == selected?.providerID &&
80 val isSelected = option.providerID == selected?.providerID &&
81− option.modelID == selected.modelID
81+ option.modelID == selected?.modelID
82 Column(
82 Column(
83 modifier = Modifier
83 modifier = Modifier
84 .fillMaxWidth()
84 .fillMaxWidth()
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+17−4
@@ -16,6 +16,10 @@ import androidx.compose.material3.OutlinedButton
16import androidx.compose.material3.Text
16import androidx.compose.material3.Text
17import androidx.compose.material3.TextButton
17import androidx.compose.material3.TextButton
18import androidx.compose.runtime.Composable
18import 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
19import androidx.compose.ui.Modifier
23import androidx.compose.ui.Modifier
20import androidx.compose.ui.platform.LocalHapticFeedback
24import androidx.compose.ui.platform.LocalHapticFeedback
21import androidx.compose.ui.res.stringResource
25import androidx.compose.ui.res.stringResource
@@ -36,8 +40,17 @@ fun PermissionDialog(
36 onRespond: (PermissionResponse) -> Unit,
40 onRespond: (PermissionResponse) -> Unit,
37) {
41) {
38 val haptics = LocalHapticFeedback.current
42 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+ }
39 // Intercept back so the dialog can't be dismissed without an explicit choice.
52 // Intercept back so the dialog can't be dismissed without an explicit choice.
40− BackHandler { onRespond(PermissionResponse.REJECT) }
53+ BackHandler { respond(PermissionResponse.REJECT) }
41 AlertDialog(
54 AlertDialog(
42 onDismissRequest = { /* require an explicit choice */ },
55 onDismissRequest = { /* require an explicit choice */ },
43 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
56 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
@@ -70,14 +83,14 @@ fun PermissionDialog(
70 Button(
83 Button(
71 onClick = {
84 onClick = {
72 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
85 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
73− onRespond(PermissionResponse.ONCE)
86+ respond(PermissionResponse.ONCE)
74 },
87 },
75 modifier = Modifier.fillMaxWidth(),
88 modifier = Modifier.fillMaxWidth(),
76 ) { Text(stringResource(R.string.allow_once)) }
89 ) { Text(stringResource(R.string.allow_once)) }
77 OutlinedButton(
90 OutlinedButton(
78 onClick = {
91 onClick = {
79 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
92 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
80− onRespond(PermissionResponse.ALWAYS)
93+ respond(PermissionResponse.ALWAYS)
81 },
94 },
82 modifier = Modifier.fillMaxWidth(),
95 modifier = Modifier.fillMaxWidth(),
83 ) { Text(stringResource(R.string.always_allow)) }
96 ) { Text(stringResource(R.string.always_allow)) }
@@ -86,7 +99,7 @@ fun PermissionDialog(
86 dismissButton = {
99 dismissButton = {
87 TextButton(onClick = {
100 TextButton(onClick = {
88 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
101 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
89− onRespond(PermissionResponse.REJECT)
102+ respond(PermissionResponse.REJECT)
90 }) {
103 }) {
91 Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error)
104 Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error)
92 }
105 }
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+11−2
@@ -14,6 +14,8 @@ import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.res.stringResource
15import androidx.compose.ui.res.stringResource
16import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.semantics.contentDescription
17+import androidx.compose.ui.semantics.liveRegion
18+import androidx.compose.ui.semantics.LiveRegionMode
17import androidx.compose.ui.semantics.semantics
19import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.unit.dp
20import androidx.compose.ui.unit.dp
19import soy.iko.opencode.R
21import soy.iko.opencode.R
@@ -36,7 +38,14 @@ fun ConnectionBanner(
36 }
38 }
37 if (text != null) {
39 if (text != null) {
38 Surface(
40 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+ },
40 color = MaterialTheme.colorScheme.tertiaryContainer,
49 color = MaterialTheme.colorScheme.tertiaryContainer,
41 ) {
50 ) {
42 Row(
51 Row(
@@ -45,7 +54,7 @@ fun ConnectionBanner(
45 verticalAlignment = Alignment.CenterVertically,
54 verticalAlignment = Alignment.CenterVertically,
46 ) {
55 ) {
47 CircularProgressIndicator(
56 CircularProgressIndicator(
48− Modifier.size(12.dp).semantics { contentDescription = text },
57+ Modifier.size(12.dp),
49 strokeWidth = 2.dp,
58 strokeWidth = 2.dp,
50 )
59 )
51 Text(
60 Text(
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+19−2
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.width
12import androidx.compose.foundation.layout.width
12import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.rememberScrollState
@@ -34,12 +35,20 @@ sealed interface DiffLine {
34 val text: String
35 val text: String
35
36
36 data class Hunk(override val text: String) : DiffLine // @@ ... @@
37 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
38 data class Context(override val text: String) : DiffLine
39 data class Context(override val text: String) : DiffLine
39 data class Add(override val text: String) : DiffLine
40 data class Add(override val text: String) : DiffLine
40 data class Remove(override val text: String) : DiffLine
41 data class Remove(override val text: String) : DiffLine
42+ data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
41}
43}
42
44
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+
43/** Parse a unified diff string into typed [DiffLine]s. */
52/** Parse a unified diff string into typed [DiffLine]s. */
44fun parseDiff(diff: String): List<DiffLine> {
53fun parseDiff(diff: String): List<DiffLine> {
45 val result = mutableListOf<DiffLine>()
54 val result = mutableListOf<DiffLine>()
@@ -51,6 +60,7 @@ fun parseDiff(diff: String): List<DiffLine> {
51 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
60 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
52 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
61 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
53 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
62 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
63+ gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
54 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
64 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
55 }
65 }
56 }
66 }
@@ -122,13 +132,20 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
122 color = MaterialTheme.colorScheme.onSurfaceVariant,
132 color = MaterialTheme.colorScheme.onSurfaceVariant,
123 modifier = Modifier.padding(vertical = 1.dp),
133 modifier = Modifier.padding(vertical = 1.dp),
124 )
134 )
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+ )
125 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
142 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
126 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
143 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
127 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
144 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
128 }
145 }
129 }
146 }
130 }
147 }
131− Spacer(Modifier.padding(bottom = 10.dp))
148+ Spacer(Modifier.height(10.dp))
132 }
149 }
133}
150}
134
151
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+21−8
@@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
6import androidx.compose.runtime.mutableLongStateOf
6import androidx.compose.runtime.mutableLongStateOf
7import androidx.compose.runtime.remember
7import androidx.compose.runtime.remember
8import androidx.compose.runtime.setValue
8import androidx.compose.runtime.setValue
9+import androidx.lifecycle.compose.LocalLifecycleOwner
9import kotlinx.coroutines.delay
10import kotlinx.coroutines.delay
10import java.util.concurrent.TimeUnit
11import java.util.concurrent.TimeUnit
11
12
@@ -25,25 +26,37 @@ fun relativeTime(epochMillis: Long?): String {
25 if (weeks < 5) return "${weeks}w"
26 if (weeks < 5) return "${weeks}w"
26 val months = days / 30
27 val months = days / 30
27 if (months < 12) return "${months}mo"
28 if (months < 12) return "${months}mo"
28− return "${days / 365}y"
29+ val years = (days / 365).coerceAtLeast(1)
30+ return "${years}y"
29}
31}
30
32
31/**
33/**
32 * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
34 * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
33 * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
35 * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
34 * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
36 * 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.
36 */
39 */
37@Composable
40@Composable
38fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
41fun 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.
41 var tick by remember { mutableLongStateOf(0L) }
42 var tick by remember { mutableLongStateOf(0L) }
43+ val lifecycleOwner = LocalLifecycleOwner.current
42− LaunchedEffect(epochMillis) {
44+ LaunchedEffect(epochMillis, lifecycleOwner) {
43 if (epochMillis == null) return@LaunchedEffect
45 if (epochMillis == null) return@LaunchedEffect
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 {
44− while (true) {
54+ while (true) {
45− delay(intervalMs)
55+ delay(intervalMs)
46− tick++
56+ tick++
57+ }
58+ } finally {
59+ lifecycleOwner.lifecycle.removeObserver(observer)
47 }
60 }
48 }
61 }
49 // Read tick so Compose tracks it as a dependency and recomposes when it changes.
62 // 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(
201 HorizontalDivider()
201 HorizontalDivider()
202 }
202 }
203 }
203 }
204− items(state.entries, key = { it.path }) { node ->
204+ items(state.entries, key = { it.path + "_" + it.name }) { node ->
205 FileRow(
205 FileRow(
206 icon = node.isDirectory,
206 icon = node.isDirectory,
207 label = node.name,
207 label = node.name,
@@ -251,8 +251,8 @@ private fun FileRow(
251@Composable
251@Composable
252private fun StatusBadge(status: FileStatusEntry) {
252private fun StatusBadge(status: FileStatusEntry) {
253 val (letter, color, desc) = when (status.status) {
253 val (letter, color, desc) = when (status.status) {
254− "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
254+ "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
255− "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
255+ "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
256 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
256 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
257 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
257 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
258 }
258 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+9−2
@@ -95,10 +95,16 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
95 )
95 )
96 return
96 return
97 }
97 }
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+ )
99 openJob?.cancel()
106 openJob?.cancel()
100 searchJob?.cancel()
107 searchJob?.cancel()
101− if (_state.value.isSearching) _state.value = _state.value.copy(results = emptyList(), searching = false)
102 openJob = viewModelScope.launch {
108 openJob = viewModelScope.launch {
103 runCatchingCancellable { client.listDirectory(path) }
109 runCatchingCancellable { client.listDirectory(path) }
104 .onSuccess { entries ->
110 .onSuccess { entries ->
@@ -122,6 +128,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
122 fun setQuery(query: String) {
128 fun setQuery(query: String) {
123 _state.value = _state.value.copy(query = query, error = null)
129 _state.value = _state.value.copy(query = query, error = null)
124 searchJob?.cancel()
130 searchJob?.cancel()
131+ openJob?.cancel()
125 if (query.isBlank()) {
132 if (query.isBlank()) {
126 _state.value = _state.value.copy(results = emptyList(), searching = false)
133 _state.value = _state.value.copy(results = emptyList(), searching = false)
127 return
134 return
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+4
@@ -1,6 +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
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
@@ -78,6 +79,9 @@ fun FileViewScreen(
78 putExtra(Intent.EXTRA_TEXT, content)
79 putExtra(Intent.EXTRA_TEXT, content)
79 }
80 }
80 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
81 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+ }
81 }) {
85 }) {
82 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
86 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
83 }
87 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+5−1
@@ -48,8 +48,12 @@ class ServerEditViewModel(
48 init {
48 init {
49 viewModelScope.launch {
49 viewModelScope.launch {
50 if (profileId != null) {
50 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.
51 val existing = withTimeoutOrNull(5_000) {
53 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 }
53 }
57 }
54 if (existing != null) {
58 if (existing != null) {
55 _state.value = ServerEditState(
59 _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() {
53
53
54 fun delete(profile: ServerProfile) {
54 fun delete(profile: ServerProfile) {
55 viewModelScope.launch {
55 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+ }
56 runCatchingCancellable { container.profileStore.delete(profile.id) }
61 runCatchingCancellable { container.profileStore.delete(profile.id) }
57 .onFailure { _error.value = container.friendlyError(it) }
62 .onFailure { _error.value = container.friendlyError(it) }
58 }
63 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4
@@ -1,6 +1,7 @@
1package soy.iko.opencode.ui.session
1package soy.iko.opencode.ui.session
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
@@ -103,6 +104,9 @@ fun SessionListScreen(
103 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
104 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
104 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
105 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
105
106
107+ // Close the server dropdown on back press instead of navigating away.
108+ BackHandler(enabled = showServerMenu) { showServerMenu = false }
109+
106 LaunchedEffect(transientError) {
110 LaunchedEffect(transientError) {
107 val msg = transientError ?: return@LaunchedEffect
111 val msg = transientError ?: return@LaunchedEffect
108 snackbar.showSnackbar(msg)
112 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() {
135 is SessionDeleted -> {
135 is SessionDeleted -> {
136 val id = event.properties.info?.id ?: event.properties.sessionID
136 val id = event.properties.info?.id ?: event.properties.sessionID
137 if (id != null) {
137 if (id != null) {
138− livePreviewJobs.remove(id)?.cancel()
138+ synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
139 container.draftStore.remove(id)
139 container.draftStore.remove(id)
140 _state.update { s ->
140 _state.update { s ->
141 s.copy(
141 s.copy(
@@ -173,21 +173,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
173 _refreshing.value = true
173 _refreshing.value = true
174 refreshJob?.cancel()
174 refreshJob?.cancel()
175 refreshJob = viewModelScope.launch {
175 refreshJob = viewModelScope.launch {
176+ try {
176− runCatchingCancellable { conn.repository.listSessions() }
177+ runCatchingCancellable { conn.repository.listSessions() }
177− .onSuccess { list ->
178+ .onSuccess { list ->
178− val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
179+ val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
179− _state.update { it.copy(sessions = sorted, loading = false, error = null) }
180+ _state.update { it.copy(sessions = sorted, loading = false, error = null) }
180− loadPreviews(sorted)
181+ 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))
188 }
182 }
189− }
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 {
190− _refreshing.value = false
192+ _refreshing.value = false
193+ }
191 }
194 }
192 }
195 }
193
196
@@ -281,6 +284,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
281 if (profile.id == activeProfileId) return
284 if (profile.id == activeProfileId) return
282 if (_switchingId.value != null) return
285 if (_switchingId.value != null) return
283 val previousProfile = container.activeConnection.value?.profile
286 val previousProfile = container.activeConnection.value?.profile
287+ val savedUnread = container.unread.value
284 _switchingId.value = profile.id
288 _switchingId.value = profile.id
285 _state.update { it.copy(loading = true, error = null) }
289 _state.update { it.copy(loading = true, error = null) }
286 viewModelScope.launch {
290 viewModelScope.launch {
@@ -297,6 +301,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
297 container.disconnect()
301 container.disconnect()
298 if (previousProfile != null) {
302 if (previousProfile != null) {
299 runCatchingCancellable { container.connect(previousProfile) }
303 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) }
300 _state.update { it.copy(loading = false, error = container.friendlyError(error)) }
307 _state.update { it.copy(loading = false, error = container.friendlyError(error)) }
301 } else {
308 } else {
302 _state.value = SessionListState(loading = false, error = container.friendlyError(error))
309 _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(
61 // Inject a pending share into the currently selected session's draft (if any).
61 // Inject a pending share into the currently selected session's draft (if any).
62 // Unlike single-pane mode where the session list is navigated to specifically for
62 // Unlike single-pane mode where the session list is navigated to specifically for
63 // the share, in two-pane mode the list is always visible — so we inject into the
63 // 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).
65 LaunchedEffect(pendingShare, selected) {
67 LaunchedEffect(pendingShare, selected) {
66 val target = selected
68 val target = selected
67 if (pendingShare != null && target != null) {
69 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) {
161 if (reportName != null) {
161 if (reportName != null) {
162 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
162 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
163 LaunchedEffect(reportName) {
163 LaunchedEffect(reportName) {
164− reportContent = withContext(Dispatchers.IO) { logger.readReport(reportName) }
164+ reportContent = withContext(Dispatchers.IO) {
165+ runCatching { logger.readReport(reportName) }.getOrNull()
166+ }
165 }
167 }
166 val content = reportContent
168 val content = reportContent
167 AlertDialog(
169 AlertDialog(
app/src/main/res/values/strings.xml+1
@@ -12,6 +12,7 @@
12 <string name="remove">Remove</string>
12 <string name="remove">Remove</string>
13 <string name="copy">Copy</string>
13 <string name="copy">Copy</string>
14 <string name="copied">Copied</string>
14 <string name="copied">Copied</string>
15+ <string name="no_share_app">No app available to share</string>
15 <string name="retry">Retry</string>
16 <string name="retry">Retry</string>
16 <string name="refresh">Refresh</string>
17 <string name="refresh">Refresh</string>
17 <string name="settings">Settings</string>
18 <string name="settings">Settings</string>