git.iko.soy/opencode-android
on master help sign in
Fix 25 bugs: app-lock bypass, data loss, concurrency races, networking, UI state races, and improve detekt compliance
commitfac92bb7baf129a058075160375c0594bea30914 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 17:08 (3 months ago) parentf6efdb3
22 files changed+411−177
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7−1 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+56−11 app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+6−4 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+4−1 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+26−14 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+12−4 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+76−56 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−2 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+63−40 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+4−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+45−21 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+3−1 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+11−1 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+5−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+5−3 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+9−4 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+13 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+17 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+14−5 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+10−4
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7−1
@@ -246,7 +246,13 @@ open class EventStreamClient(
246246 // message carries the request URL and may include auth or paths.
247247 Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
248248 _state.value = ConnectionState.Failed
249− if (!isActive) break
249+ if (!isActive) {
250+ // Final teardown (last subscriber left) while a 4xx surfaced: end in
251+ // Disconnected like every other loop-exit path. Failed must stay
252+ // observable only while the flow is live/retrying, not after teardown.
253+ _state.value = ConnectionState.Disconnected
254+ break
255+ }
250256 // Don't complete the flow: a subscriber may fix the profile and call
251257 // triggerReconnect(). Suspend until an explicit reconnect signal
252258 // arrives (no backoff delay), then re-attempt the connection. This receive
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+56−11
@@ -3,6 +3,7 @@ package soy.iko.opencode.data.network
33import android.content.Context
44import android.net.nsd.NsdManager
55import android.net.nsd.NsdServiceInfo
6+import android.os.Build
67import android.util.Log
78import kotlinx.coroutines.channels.Channel
89import kotlinx.coroutines.flow.Flow
@@ -16,9 +17,10 @@ import kotlin.coroutines.resume
1617/** A server discovered on the local network via mDNS/DNS-SD. */
1718data class DiscoveredServer(val name: String, val host: String, val port: Int) {
1819 val baseUrl: String get() {
19− // FIX 14: IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed and
20− // have their scope id dropped to form a valid URL authority; IPv4 hosts are unchanged.
21− val formattedHost = if (host.contains(':')) "[${host.substringBefore('%')}]" else host
20+ // IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed to form a valid
21+ // URL authority; a link-local zone id's '%' is percent-encoded to "%25" per RFC 6874
22+ // ("[fe80::1%25wlan0]") rather than dropped, so link-local hosts stay dialable. IPv4 unchanged.
23+ val formattedHost = if (host.contains(':')) "[${host.replace("%", "%25")}]" else host
2224 return "http://$formattedHost:$port"
2325 }
2426}
@@ -54,6 +56,11 @@ open class NsdDiscovery(context: Context?) {
5456
5557 val resolver = launch {
5658 for (info in toResolve) {
59+ // Key the map by the name from discovery: onServiceFound/onServiceLost both use
60+ // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
61+ // keying by the resolved name would leave a departed server that onServiceLost
62+ // can't match, so it would linger in the emitted list forever.
63+ val discoveredName = info.serviceName.orEmpty()
5764 // NsdManager.resolveService occasionally never invokes its listener. Without a
5865 // timeout, one stuck resolve would hang this single serialized resolver forever,
5966 // so every later-discovered service queued behind it is never processed and the
@@ -65,7 +72,7 @@ open class NsdDiscovery(context: Context?) {
6572 if (!name.startsWith("opencode", ignoreCase = true)) continue
6673 @Suppress("DEPRECATION")
6774 val host = resolved.host?.hostAddress ?: continue
68− found[name] = DiscoveredServer(name = name, host = host, port = resolved.port)
75+ found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
6976 trySend(found.values.sortedBy { it.name })
7077 }
7178 }
@@ -99,16 +106,54 @@ open class NsdDiscovery(context: Context?) {
99106
100107 private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
101108 suspendCancellableCoroutine { cont ->
102− val listener = object : NsdManager.ResolveListener {
103− override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
104− if (cont.isActive) cont.resume(null)
109+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
110+ // API 34 deprecated the one-shot resolveService (which has no cancel API and
111+ // whose listener leaks on timeout, wedging the platform's serialized slot so the
112+ // next resolve fails with ALREADY_ACTIVE) in favour of a persistent callback that
113+ // can be detached. A timed-out (cancelled) resolve unregisters its callback in
114+ // invokeOnCancellation, and a settled one unregisters itself, so nothing lingers.
115+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
116+ lateinit var callback: NsdManager.ServiceInfoCallback
117+ callback = object : NsdManager.ServiceInfoCallback {
118+ override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
119+ if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null)
120+ }
121+ override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
122+ if (settled.compareAndSet(false, true)) {
123+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
124+ if (cont.isActive) cont.resume(serviceInfo)
125+ }
126+ }
127+ override fun onServiceLost() {
128+ if (settled.compareAndSet(false, true)) {
129+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
130+ if (cont.isActive) cont.resume(null)
131+ }
132+ }
133+ override fun onServiceInfoCallbackUnregistered() {}
105134 }
106− override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
107− if (cont.isActive) cont.resume(serviceInfo)
135+ cont.invokeOnCancellation {
136+ if (settled.compareAndSet(false, true)) {
137+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
138+ }
108139 }
140+ runCatching { nsd.registerServiceInfoCallback(info, { it.run() }, callback) }
141+ .onFailure { if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null) }
142+ } else {
143+ // Pre-34: the deprecated one-shot resolveService has no cancel API, so a stuck
144+ // resolve abandoned by RESOLVE_TIMEOUT_MS can still wedge the platform's single
145+ // serialized slot; this is a platform limitation we can only bound, not clear.
146+ val listener = object : NsdManager.ResolveListener {
147+ override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
148+ if (cont.isActive) cont.resume(null)
149+ }
150+ override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
151+ if (cont.isActive) cont.resume(serviceInfo)
152+ }
153+ }
154+ runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
155+ .onFailure { if (cont.isActive) cont.resume(null) }
109156 }
110− runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
111− .onFailure { if (cont.isActive) cont.resume(null) }
112157 }
113158
114159 private companion object {
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+6−4
@@ -98,15 +98,17 @@ open class AttachmentDraftStore private constructor(
9898
9999 /** Remove staged attachments for a session (call on session deletion). */
100100 open suspend fun remove(sessionId: String) {
101+ // Keep the per-session Mutex in [locks] rather than evicting it: remove() is not always
102+ // terminal — a concurrent teardown save() for the same session must serialize against this
103+ // delete under the *same* Mutex instance. Evicting here would let that save() acquire a
104+ // fresh Mutex, so the two operations no longer serialize (resurrecting an orphan file, or
105+ // theoretically tearing a write). The bounded per-session map growth matches the other
106+ // lock-keyed stores.
101107 lockFor(sessionId).withLock {
102108 withContext(Dispatchers.IO) {
103109 runCatchingCancellable { fileFor(sessionId)?.delete() }
104110 Unit
105111 }
106112 }
107− // Drop the per-session lock now the session is gone so the map doesn't accumulate one
108− // entry per deleted session over the install's lifetime. Safe because remove() is the
109− // terminal operation for a session id.
110− locks.remove(sessionId)
111113 }
112114}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+4−1
@@ -114,7 +114,10 @@ class BackupManager(
114114 )
115115 }
116116 data.settings?.let { settings ->
117− runCatching { settingsStore.setThemeMode(ThemeMode.valueOf(settings.themeMode)) }
117+ // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
118+ // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
119+ // a CancellationException and break structured concurrency.
120+ ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
118121 settingsStore.setDynamicColor(settings.dynamicColor)
119122 settingsStore.setSendOnEnter(settings.sendOnEnter)
120123 settingsStore.setAppLock(settings.appLock)
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+26−14
@@ -40,8 +40,13 @@ open class MessageCacheStore private constructor(
4040 appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
4141 }
4242
43− private fun fileFor(sessionId: String): File? =
44− dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
43+ /** Per-profile subdirectory: two servers with a colliding session id must not share a cache
44+ * file, so entries are namespaced by profile id under [dir]. */
45+ private fun profileDir(profileId: String): File? =
46+ dir?.let { File(it, idRegex.replace(profileId, "_")).apply { mkdirs() } }
47+
48+ private fun fileFor(profileId: String, sessionId: String): File? =
49+ profileDir(profileId)?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
4550
4651 /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
4752 * panes of two-pane mode saving the same session, or a save racing a delete's remove)
@@ -50,19 +55,19 @@ open class MessageCacheStore private constructor(
5055 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
5156 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
5257
53− /** Load the cached messages for [sessionId], or empty if none / unreadable. */
54− open suspend fun load(sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
58+ /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
59+ open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
5560 withContext(Dispatchers.IO) {
56− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
61+ val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
5762 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
5863 .getOrDefault(emptyList())
5964 }
6065 }
6166
62− /** Persist [messages] for [sessionId] (an empty list deletes the file). */
63− open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
67+ /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
68+ open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
6469 withContext(Dispatchers.IO) {
65− val file = fileFor(sessionId) ?: return@withContext
70+ val file = fileFor(profileId, sessionId) ?: return@withContext
6671 runCatchingCancellable {
6772 if (messages.isEmpty()) file.delete()
6873 else {
@@ -82,17 +87,24 @@ open class MessageCacheStore private constructor(
8287 }
8388 }
8489
85− /** Remove a session's cached messages (call on deletion). */
90+ /** Remove a session's cached messages (call on deletion). Clears the entry in every profile
91+ * namespace: session ids are unique per server and deletion is terminal, so the caller
92+ * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
93+ * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
94+ * and the map is bounded by the number of distinct sessions touched. */
8695 open suspend fun remove(sessionId: String) {
8796 lockFor(sessionId).withLock {
8897 withContext(Dispatchers.IO) {
89− runCatchingCancellable { fileFor(sessionId)?.delete() }
98+ runCatchingCancellable {
99+ val name = idRegex.replace(sessionId, "_") + ".json"
100+ dir?.listFiles()?.forEach { entry ->
101+ if (entry.isDirectory) File(entry, name).delete()
102+ }
103+ // Back-compat: pre-namespacing caches were written flat under [dir].
104+ dir?.let { File(it, name).delete() }
105+ }
90106 Unit
91107 }
92108 }
93− // Drop the per-session lock now the session is gone so the map doesn't accumulate one
94− // entry per deleted session over the install's lifetime. Safe because remove() is the
95− // terminal operation for a session id (no further save/load is expected after deletion).
96− locks.remove(sessionId)
97109 }
98110}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+12−4
@@ -77,12 +77,19 @@ open class OutboxStore private constructor(
7777 * disk is read before any write, then a later [load] is a no-op. */
7878 private suspend fun loadLocked() {
7979 if (loaded) return
80− loaded = true
81− val f = file?.takeIf { it.exists() } ?: return
80+ val f = file?.takeIf { it.exists() }
81+ if (f == null) {
82+ // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
83+ loaded = true
84+ return
85+ }
86+ // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
87+ // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
88+ // leave loaded=false and return so a later mutate/load retries the read before any write,
89+ // instead of persisting an empty snapshot over the user's offline-composed messages.
8290 val diskList = withContext(Dispatchers.IO) {
8391 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
84− .getOrDefault(emptyList())
85− }
92+ }.getOrElse { return }
8693 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
8794 // the in-memory version on conflict (it's newer).
8895 if (_messages.value.isEmpty()) {
@@ -96,6 +103,7 @@ open class OutboxStore private constructor(
96103 // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
97104 persist(merged)
98105 }
106+ loaded = true
99107 }
100108
101109 open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+76−56
@@ -20,6 +20,7 @@ import soy.iko.opencode.data.network.OpencodeApiClient
2020import soy.iko.opencode.util.runCatchingCancellable
2121import soy.iko.opencode.util.safeExceptionSummary
2222import android.util.Log
23+import kotlinx.coroutines.CoroutineScope
2324import kotlinx.coroutines.Dispatchers
2425import kotlinx.coroutines.Job
2526import kotlinx.coroutines.NonCancellable
@@ -46,6 +47,9 @@ open class SessionRepository(
4647 // Optional on-disk cache so a conversation renders instantly on open and stays readable
4748 // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
4849 private val messageCache: MessageCacheStore? = null,
50+ // Profile the cache is namespaced under, so two servers with a colliding session id don't
51+ // share a cache file. Empty in tests / when no cache is wired.
52+ private val profileId: String = "",
4953) {
5054 open suspend fun listSessions() = api.listSessions()
5155 open suspend fun createSession(title: String? = null, directory: String? = null) =
@@ -78,6 +82,58 @@ open class SessionRepository(
7882 * streaming tokens (each publishing a fresh snapshot) never back-pressures the
7983 * event reducer — a slow collector simply sees the most recent snapshot.
8084 */
85+ /** Per-subscription helper that persists reduced snapshots to [messageCache], throttled so a
86+ * fast token stream doesn't hammer the disk, plus a NonCancellable teardown flush. Extracted
87+ * from [observeMessages] so this cache-write branching lives in its own methods. [scope] is
88+ * the channelFlow scope, so scheduled saves are torn down with the flow — the teardown flush
89+ * re-persists a trailing write the cancellation dropped. */
90+ private inner class CacheWriter(private val scope: CoroutineScope, private val sessionId: String) {
91+ private var lastWrite = 0L
92+ private var pending: Job? = null
93+ // Most recent snapshot handed to [schedule], and the most recent one whose on-disk write
94+ // completed. `saved` is assigned from inside the launched save *after* it returns, so a
95+ // save cancelled mid-IO by teardown is NOT marked saved and [flushOnTeardown] re-persists
96+ // it. Written from the save coroutines, read from the collector/teardown; the only race is
97+ // a stale read causing one redundant, idempotent re-save (atomic temp+rename makes a
98+ // duplicate write of identical data harmless) — never data loss.
99+ private var latest: List<MessageWithParts>? = null
100+ private var saved: List<MessageWithParts>? = null
101+
102+ fun schedule(snapshot: List<MessageWithParts>) {
103+ val cache = messageCache ?: return
104+ if (snapshot.isEmpty()) return
105+ latest = snapshot
106+ val now = System.currentTimeMillis()
107+ pending?.cancel()
108+ // Immediate write once the throttle window has elapsed; otherwise a trailing-edge
109+ // write so a burst that ends inside the window (a run's final tokens right after a
110+ // write) still persists its last snapshot. A newer snapshot cancels and replaces a
111+ // pending trailing write, so continuous streaming coalesces to one write per window.
112+ val delayMs = if (now - lastWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
113+ lastWrite = now
114+ 0L
115+ } else {
116+ NetworkConfig.messageCacheWriteThrottleMs
117+ }
118+ pending = scope.launch {
119+ if (delayMs > 0) delay(delayMs)
120+ cache.save(profileId, sessionId, snapshot)
121+ saved = snapshot
122+ }
123+ }
124+
125+ suspend fun flushOnTeardown() {
126+ // Scheduled saves are children of [scope], so teardown cancels a pending trailing
127+ // write before its delay elapses — a run's final tokens would never reach disk. Flush
128+ // the latest un-saved snapshot under NonCancellable so it survives the cancellation.
129+ val cache = messageCache ?: return
130+ val pendingSnapshot = latest ?: return
131+ if (pendingSnapshot !== saved) {
132+ withContext(NonCancellable) { cache.save(profileId, sessionId, pendingSnapshot) }
133+ }
134+ }
135+ }
136+
81137 open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
82138 val store = MessageStore()
83139 val lock = Mutex()
@@ -118,12 +174,12 @@ open class SessionRepository(
118174 // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
119175 // (also generation 0) then overrides this with authoritative data when it succeeds.
120176 messageCache?.let { cache ->
121− val cached = cache.load(sessionId)
177+ val cached = cache.load(profileId, sessionId)
122178 if (cached.isNotEmpty()) {
123179 val changed = lock.withLock {
124180 if (seedGeneration == 0) {
125181 cacheSeededIds = cached.mapTo(mutableSetOf()) { it.info.id }
126− store.seed(cached)
182+ store.seed(cached, clearPivot = false)
127183 } else false
128184 }
129185 if (changed) publish()
@@ -212,18 +268,10 @@ open class SessionRepository(
212268 // outside the lock so a slow downstream collector can't stall the drain. This
213269 // loop also keeps the flow alive until the collector cancels; the launched jobs
214270 // above are children of this scope and are torn down with it.
215− var lastCacheWrite = 0L
216− var pendingCacheSave: Job? = null
217− // Most recent published snapshot, and the most recent one whose on-disk write has
218− // actually completed. The teardown flush in the finally below persists a pending
219− // trailing snapshot exactly once. savedSnapshot is assigned from inside the launched
220− // saves *after* messageCache.save() returns, so a save cancelled mid-IO by teardown is
221− // NOT marked saved and the finally re-persists it under NonCancellable. It's written
222− // from the save coroutines and read from the collector/finally; the only possible race
223− // is a stale read causing one redundant, idempotent re-save (atomic temp-file+rename
224− // makes a duplicate write of identical data harmless) — never data loss.
225− var latestSnapshot: List<MessageWithParts>? = null
226− var savedSnapshot: List<MessageWithParts>? = null
271+ // Per-subscription cache writer: throttled snapshot persistence + a NonCancellable
272+ // teardown flush. `this` is the channelFlow scope, so its scheduled saves are torn down
273+ // with the flow (the teardown flush re-persists a cancelled trailing write).
274+ val cacheWriter = CacheWriter(this, sessionId)
227275 // Guarantee at least one emission even when nothing seeds the store: with no on-disk
228276 // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
229277 // the seed paths publish, so the flow would emit nothing at all. The sole collector
@@ -236,46 +284,13 @@ open class SessionRepository(
236284 dirty.consumeAsFlow().collect {
237285 val snapshot = lock.withLock { store.snapshot() }
238286 send(snapshot)
239− // Persist the snapshot to the on-disk cache, throttled so a fast token stream
240− // doesn't hammer the disk — the cache only needs to be "recent enough" for an
241− // instant/offline first paint; the network corrects it on the next open.
242− if (messageCache != null && snapshot.isNotEmpty()) {
243− latestSnapshot = snapshot
244− val now = System.currentTimeMillis()
245− pendingCacheSave?.cancel()
246− if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
247− lastCacheWrite = now
248− pendingCacheSave = launch {
249− messageCache.save(sessionId, snapshot)
250− savedSnapshot = snapshot
251− }
252− } else {
253− // Trailing-edge write: a burst that ends inside the throttle window (a run's
254− // final tokens right after a write) would otherwise never persist its last
255− // snapshot, so an offline reopen would show the conversation missing its tail.
256− // Write the latest snapshot once the window elapses; a newer snapshot cancels
257− // and replaces this, so continuous streaming still coalesces to one write/window.
258− pendingCacheSave = launch {
259− delay(NetworkConfig.messageCacheWriteThrottleMs)
260− messageCache.save(sessionId, snapshot)
261− savedSnapshot = snapshot
262− }
263− }
264− }
287+ // Persist to the on-disk cache, throttled so a fast token stream doesn't hammer
288+ // the disk — the cache only needs to be "recent enough" for an instant/offline
289+ // first paint; the network corrects it on the next open.
290+ cacheWriter.schedule(snapshot)
265291 }
266292 } finally {
267− // FIX: both cache-saves above run as children of this inner flow scope, so when
268− // ChatViewModel's flatMapLatest switches (an SSE reconnect changing activeConnection)
269− // or onCleared cancels this flow, a pending trailing save is torn down before its delay
270− // elapses — a run's final tokens then never reach the on-disk cache. Flush the latest
271− // un-persisted snapshot here under NonCancellable so it survives the cancellation.
272− // savedSnapshot tracks the snapshot an immediate write already handled, so the common
273− // case (nothing pending) writes nothing extra — at most one write per teardown, never
274− // one per emission.
275− val pending = latestSnapshot
276− if (messageCache != null && pending != null && pending !== savedSnapshot) {
277− withContext(NonCancellable) { messageCache.save(sessionId, pending) }
278− }
293+ cacheWriter.flushOnTeardown()
279294 }
280295 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
281296 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
@@ -380,7 +395,7 @@ internal class MessageStore {
380395
381396 fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
382397
383− fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
398+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
384399 var changed = false
385400 // On re-seed (after SSE reconnect), remove messages that are no longer in the
386401 // server snapshot (e.g. deleted during the disconnection gap). This keeps the
@@ -439,9 +454,14 @@ internal class MessageStore {
439454 if (reorderByTime()) changed = true
440455 if (evictOldMessages()) changed = true
441456 // Reset the pivot: everything just merged is now the baseline, so only parts/info
442− // that stream in *after* this seed can outrank the next reconnect's snapshot.
443− streamedSincePivot.clear()
444− messageInfoUpdatedSincePivot.clear()
457+ // that stream in *after* this seed can outrank the next reconnect's snapshot. The
458+ // on-disk cache seed passes clearPivot = false: it runs *before* the authoritative
459+ // initial REST seed, so clearing here would strip pivot protection from SSE parts/info
460+ // that streamed in since subscribe, letting that REST seed regress them.
461+ if (clearPivot) {
462+ streamedSincePivot.clear()
463+ messageInfoUpdatedSincePivot.clear()
464+ }
445465 return changed
446466 }
447467
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−2
@@ -919,13 +919,18 @@ open class AppContainer private constructor(
919919 * The probe client is always closed afterwards so no resources linger. This does not
920920 * touch the active connection or the profile store.
921921 */
922− open suspend fun probeServer(baseUrl: String): ProbeResult {
922+ open suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
923923 return probeWithProfile(ServerProfile(
924924 id = "probe",
925925 label = "",
926926 baseUrl = baseUrl.trim(),
927927 username = null,
928928 password = null,
929+ // Probe the same effective URL save()/connect() will use: HttpClientFactory upgrades
930+ // http->https and installs the cert pinner when these are set, so a probe without them
931+ // would validate a plain-http endpoint the real connection never talks to.
932+ requireHttps = requireHttps,
933+ certPin = certPin,
929934 ))
930935 }
931936
@@ -935,13 +940,22 @@ open class AppContainer private constructor(
935940 * on other failures (unreachable, timeout) so the caller can surface a friendly
936941 * message. Mirrors [probeServer]'s short-lived-client pattern.
937942 */
938− open suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
943+ open suspend fun probeWithCredentials(
944+ baseUrl: String,
945+ username: String,
946+ password: String,
947+ requireHttps: Boolean,
948+ certPin: String?,
949+ ): Boolean {
939950 val probeProfile = ServerProfile(
940951 id = "probe-auth",
941952 label = "",
942953 baseUrl = baseUrl.trim(),
943954 username = username.trim().takeIf { it.isNotBlank() },
944955 password = password.trim().takeIf { it.isNotEmpty() },
956+ // Match the effective URL/pinning save()/connect() will use (see probeServer).
957+ requireHttps = requireHttps,
958+ certPin = certPin,
945959 )
946960 val client = HttpClientFactory.create(probeProfile)
947961 return try {
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1
@@ -31,7 +31,7 @@ open class OpencodeConnection(
3131
3232 open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
3333 open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
34− open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache) }
34+ open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache, profile.id) }
3535
3636 /**
3737 * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+63−40
@@ -5,10 +5,7 @@ import android.content.ContextWrapper
55import android.os.Build
66import androidx.biometric.BiometricManager
77import androidx.biometric.BiometricPrompt
8−import androidx.compose.foundation.interaction.MutableInteractionSource
9−import androidx.compose.foundation.clickable
108import androidx.compose.foundation.layout.Arrangement
11−import androidx.compose.foundation.layout.Box
129import androidx.compose.foundation.layout.Column
1310import androidx.compose.foundation.layout.fillMaxSize
1411import androidx.compose.foundation.layout.padding
@@ -25,6 +22,7 @@ import androidx.compose.runtime.DisposableEffect
2522import androidx.compose.runtime.getValue
2623import androidx.compose.runtime.mutableStateOf
2724import androidx.compose.runtime.remember
25+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
2826import androidx.compose.runtime.setValue
2927import androidx.compose.ui.Alignment
3028import androidx.compose.ui.Modifier
@@ -54,6 +52,20 @@ private val lockAuthenticators: Int
5452fun canAuthenticateForAppLock(context: Context): Boolean =
5553 BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
5654
55+/**
56+ * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
57+ * enrolled, or a pending security update). Only these states may fail the gate open. A transient
58+ * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
59+ * a momentary glitch — so the gate stays locked and lets the user retry.
60+ */
61+private fun appLockPermanentlyUnavailable(context: Context): Boolean =
62+ when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
63+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
64+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
65+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
66+ else -> false
67+ }
68+
5769/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
5870private fun Context.findFragmentActivity(): FragmentActivity? {
5971 var ctx: Context? = this
@@ -83,10 +95,11 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
8395 content()
8496 return
8597 }
86− // Fail open if device authentication is no longer possible (e.g. the user enabled app-lock
87− // while a credential was enrolled and later removed their PIN/biometric): authenticate()
98+ // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
99+ // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
88100 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
89− if (!canAuthenticateForAppLock(context)) {
101+ // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
102+ if (appLockPermanentlyUnavailable(context)) {
90103 content()
91104 return
92105 }
@@ -146,7 +159,13 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
146159 DisposableEffect(lifecycleOwner) {
147160 val observer = LifecycleEventObserver { _, event ->
148161 when (event) {
149− Lifecycle.Event.ON_STOP -> unlocked = false
162+ Lifecycle.Event.ON_STOP -> {
163+ unlocked = false
164+ // The system dismisses an in-flight prompt on background and posts its
165+ // onAuthenticationError asynchronously; clear the flag now so ON_START re-prompts
166+ // instead of no-opping on a stale authInFlight and stranding the manual button.
167+ authInFlight = false
168+ }
150169 Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
151170 else -> {}
152171 }
@@ -155,43 +174,47 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
155174 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
156175 }
157176
158− Box(modifier = Modifier.fillMaxSize()) {
159− content()
160− if (!unlocked) {
161− // Opaque overlay covering the content. The no-op clickable swallows touches so
162− // the hidden UI beneath can't be interacted with while locked.
163− val interaction = remember { MutableInteractionSource() }
164− Surface(
165− modifier = Modifier
166− .fillMaxSize()
167− .clickable(interactionSource = interaction, indication = null) {},
168− color = MaterialTheme.colorScheme.background,
177+ // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
178+ // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
179+ // separate windows z-ordered above the Activity content, so an open permission dialog or picker
180+ // sheet would stay visible and interactive above the lock — an authentication bypass. Not
181+ // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
182+ // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
183+ // they were, replacing the previous "keep composed underneath" approach.
184+ val stateHolder = rememberSaveableStateHolder()
185+ if (unlocked) {
186+ stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
187+ } else {
188+ Surface(
189+ modifier = Modifier.fillMaxSize(),
190+ color = MaterialTheme.colorScheme.background,
191+ ) {
192+ Column(
193+ modifier = Modifier.fillMaxSize().padding(32.dp),
194+ horizontalAlignment = Alignment.CenterHorizontally,
195+ verticalArrangement = Arrangement.Center,
169196 ) {
170− Column(
171− modifier = Modifier.fillMaxSize().padding(32.dp),
172− horizontalAlignment = Alignment.CenterHorizontally,
173− verticalArrangement = Arrangement.Center,
197+ Icon(
198+ Icons.Filled.Lock,
199+ contentDescription = null,
200+ modifier = Modifier.size(48.dp),
201+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
202+ )
203+ Text(
204+ stringResource(R.string.app_locked),
205+ style = MaterialTheme.typography.titleMedium,
206+ modifier = Modifier.padding(top = 16.dp),
207+ )
208+ Button(
209+ onClick = { authenticate() },
210+ enabled = !authInFlight,
211+ modifier = Modifier.padding(top = 24.dp),
174212 ) {
175− Icon(
176− Icons.Filled.Lock,
177− contentDescription = null,
178− modifier = Modifier.size(48.dp),
179− tint = MaterialTheme.colorScheme.onSurfaceVariant,
180− )
181− Text(
182− stringResource(R.string.app_locked),
183− style = MaterialTheme.typography.titleMedium,
184− modifier = Modifier.padding(top = 16.dp),
185− )
186− Button(
187− onClick = { authenticate() },
188− enabled = !authInFlight,
189− modifier = Modifier.padding(top = 24.dp),
190− ) {
191− Text(stringResource(R.string.app_unlock))
192− }
213+ Text(stringResource(R.string.app_unlock))
193214 }
194215 }
195216 }
196217 }
197218}
219+
220+private const val APP_LOCK_CONTENT_KEY = "app_lock_content"
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+4−1
@@ -1482,7 +1482,10 @@ private fun RenameSessionChatDialog(
14821482 onDismiss: () -> Unit,
14831483 onConfirm: (String) -> Unit,
14841484) {
1485− var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1485+ // Seed the field once (no key input) so a later external title change — e.g. opencode
1486+ // auto-generating a session title after the first prompt, arriving via SessionUpdated —
1487+ // doesn't re-initialize the box and discard the user's in-progress edit.
1488+ var title by rememberSaveable { mutableStateOf(initialTitle) }
14861489 AlertDialog(
14871490 onDismissRequest = onDismiss,
14881491 title = { Text(stringResource(R.string.rename_session_chat)) },
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+45−21
@@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.retryWhen
5353import kotlinx.coroutines.flow.stateIn
5454import kotlinx.coroutines.flow.update
5555import kotlinx.coroutines.Dispatchers
56+import kotlinx.coroutines.Job
5657import kotlinx.coroutines.delay
5758import kotlinx.coroutines.launch
5859
@@ -242,6 +243,11 @@ class ChatViewModel(
242243 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
243244 @Volatile private var runEndedByIdle = false
244245
246+ /** The in-flight REST title resolution for the current connection. Launched from — and
247+ * cancelled by — the per-connection reset block so it can't observe or restore a previous
248+ * server's stale title. */
249+ private var sessionTitleJob: Job? = null
250+
245251 /** True while an abort REST call is in flight, so the Stop button can show a
246252 * spinner and prevent double-taps from firing a second abort. */
247253 private val _aborting = MutableStateFlow(false)
@@ -557,6 +563,7 @@ class ChatViewModel(
557563 _running.value = false
558564 _aborting.value = false
559565 clearPermissions()
566+ sessionTitleJob?.cancel()
560567 return@collectLatest
561568 }
562569 _running.value = false
@@ -572,6 +579,24 @@ class ChatViewModel(
572579 // restore path — would drop a legitimately queued/recovered follow-up.
573580 _selectedAgent.value = null
574581 _sessionTitle.value = null
582+ // Resolve the app-bar title via REST for THIS connection. Launched from the same
583+ // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
584+ // never observe or restore the previous server's stale title — the old separate
585+ // collector could, racing that reset. The assignment is guarded on a still-null
586+ // title so a fresher SSE SessionUpdated (from the event collector below) wins.
587+ sessionTitleJob?.cancel()
588+ sessionTitleJob = viewModelScope.launch {
589+ runCatchingCancellable { conn.repository.listSessions() }
590+ .getOrNull()
591+ ?.firstOrNull { it.id == sessionId }
592+ ?.let { session ->
593+ if (_sessionTitle.value == null) {
594+ _sessionTitle.value = session.displayTitle
595+ _reverted.value = session.isReverted
596+ _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
597+ }
598+ }
599+ }
575600 try {
576601 conn.events.events.collect { event ->
577602 if (SessionRepository.isIdle(event, sessionId)) {
@@ -601,7 +626,9 @@ class ChatViewModel(
601626 // Restore the run indicator if a live streaming event arrives while it's
602627 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
603628 // agent is still working. Don't revive it once the user has hit Stop.
604− if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value && !runEndedByIdle) {
629+ val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
630+ !_aborting.value && !_running.value && !runEndedByIdle
631+ if (relightRunIndicator) {
605632 _running.value = true
606633 }
607634 when (event) {
@@ -671,24 +698,6 @@ class ChatViewModel(
671698 onNull = { _commands.value = emptyList() },
672699 reloadTrigger = _commandsReload,
673700 )
674− // Resolve the human-readable session title for the app bar (non-fatal).
675− // Skips the REST call when the SSE event collector already delivered the title
676− // (SessionUpdated fires shortly after connect), avoiding a redundant full
677− // session-list download.
678− viewModelScope.launch {
679− container.activeConnection.collectLatest { conn ->
680− if (conn == null) return@collectLatest
681− if (_sessionTitle.value != null) return@collectLatest
682− runCatchingCancellable { conn.repository.listSessions() }
683− .getOrNull()
684− ?.firstOrNull { it.id == sessionId }
685− ?.let { session ->
686− _sessionTitle.value = session.displayTitle
687− _reverted.value = session.isReverted
688− _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
689− }
690− }
691− }
692701 // If the SSE stream drops mid-run, the run indicator would spin forever;
693702 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
694703 viewModelScope.launch {
@@ -952,6 +961,9 @@ class ChatViewModel(
952961 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
953962 return
954963 }
964+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
965+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
966+ runEndedByIdle = false
955967 viewModelScope.launch {
956968 runCatchingCancellable {
957969 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
@@ -1034,7 +1046,7 @@ class ChatViewModel(
10341046 * Overwrites the current draft — this is an explicit edit action on that message. */
10351047 fun editMessage(messageId: String, text: String) {
10361048 revertTo(messageId)
1037− _draft.value = text
1049+ _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
10381050 }
10391051
10401052 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
@@ -1044,7 +1056,10 @@ class ChatViewModel(
10441056 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
10451057 if (quoted.isBlank()) return
10461058 val current = _draft.value
1047− _draft.value = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1059+ val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1060+ // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1061+ // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1062+ _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
10481063 }
10491064
10501065 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
@@ -1118,6 +1133,9 @@ class ChatViewModel(
11181133 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
11191134 return
11201135 }
1136+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1137+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1138+ runEndedByIdle = false
11211139 viewModelScope.launch {
11221140 runCatchingCancellable { conn.api.summarize(sessionId, model) }
11231141 .onFailure {
@@ -1134,6 +1152,9 @@ class ChatViewModel(
11341152 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
11351153 return
11361154 }
1155+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1156+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1157+ runEndedByIdle = false
11371158 viewModelScope.launch {
11381159 runCatchingCancellable { conn.api.initSession(sessionId) }
11391160 .onFailure {
@@ -1157,6 +1178,9 @@ class ChatViewModel(
11571178 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
11581179 return
11591180 }
1181+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1182+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1183+ runEndedByIdle = false
11601184 viewModelScope.launch {
11611185 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
11621186 .onFailure {
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+3−1
@@ -256,7 +256,9 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
256256internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
257257 if (!isFenced) return null
258258 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
259− val info = first.removePrefix("```").removePrefix("~~~").trim()
259+ // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
260+ // ````kotlin), not a fixed 3 chars, before reading the info string.
261+ val info = first.trimStart('`', '~').trim()
260262 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
261263 // first whitespace-delimited token is the language.
262264 return info.substringBefore(' ').takeIf { it.isNotBlank() }
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+11−1
@@ -85,8 +85,18 @@ val FilePart.isImage: Boolean
8585private fun decodeDataUri(data: String): ByteArray? {
8686 val comma = data.indexOf(',')
8787 if (comma < 0) return null
88+ val header = data.substring(0, comma)
8889 val payload = data.substring(comma + 1)
89− return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
90+ // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
91+ // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
92+ // which would throw and render the broken-image icon.
93+ return runCatching {
94+ if (header.contains(";base64", ignoreCase = true)) {
95+ Base64.decode(payload, Base64.DEFAULT)
96+ } else {
97+ android.net.Uri.decode(payload).toByteArray()
98+ }
99+ }.getOrNull()
90100}
91101
92102/**
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+5−1
@@ -99,7 +99,11 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
9999 // Preserve the session order (already recency-sorted by the server) in the results.
100100 val ordered = toSearch.mapNotNull { hits[it.id] }
101101 _state.update {
102− it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
102+ // If the query changed while this search was finishing (e.g. the user cleared the
103+ // box), cancelling this job can race the final write; only publish results for the
104+ // query still displayed so a stale result set can't land after the reset.
105+ if (it.query.trim() != query) it
106+ else it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
103107 }
104108 }
105109
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+5−3
@@ -87,9 +87,11 @@ fun ServerEditScreen(
8787 }
8888
8989 // Intercept the system back gesture when the form has unsaved changes so the user
90− // gets a chance to keep editing instead of losing their work silently.
91− BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
92− showDiscardConfirm = true
90+ // gets a chance to keep editing instead of losing their work silently. Stays enabled
91+ // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
92+ // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
93+ BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
94+ if (!state.saving) showDiscardConfirm = true
9395 }
9496
9597 Scaffold(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−2
@@ -226,8 +226,10 @@ class ServerEditViewModel(
226226 // FIX 11: remember which URL we probed so a stale result is dropped if the
227227 // user edited the base URL away while the probe was in flight.
228228 val probedUrl = s.baseUrl.trim()
229+ val probedRequireHttps = s.requireHttps
230+ val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
229231 viewModelScope.launch {
230− val result = runCatchingCancellable { container.probeServer(probedUrl) }
232+ val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
231233 result.onSuccess { pr ->
232234 _state.update {
233235 // Stale result (URL edited away mid-probe): drop the reachability verdict but
@@ -280,8 +282,12 @@ class ServerEditViewModel(
280282 val probedUrl = s.baseUrl.trim()
281283 val probedUser = s.username.trim()
282284 val probedPass = s.password.trim()
285+ val probedRequireHttps = s.requireHttps
286+ val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
283287 viewModelScope.launch {
284− val result = runCatchingCancellable { container.probeWithCredentials(probedUrl, probedUser, probedPass) }
288+ val result = runCatchingCancellable {
289+ container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
290+ }
285291 result.onSuccess { ok ->
286292 _state.update {
287293 // Stale result: clear the spinner but drop the verdict (see probe()).
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+9−4
@@ -158,14 +158,18 @@ fun ServerListScreen(
158158 }
159159 },
160160 ) { padding ->
161− Column(modifier = Modifier.fillMaxSize()) {
161+ // Consume the top inset on the Column so the reconnecting indicator (its first child)
162+ // renders just below the TopAppBar instead of at y=0 under it. The children below then
163+ // drop their own top padding (folding the bottom inset into the list's contentPadding)
164+ // so the list isn't double-padded at the top.
165+ Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
162166 if (reconnecting && profiles.isNotEmpty()) {
163167 LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
164168 }
165169 if (profiles.isEmpty()) {
166170 EmptyServers(
167171 onAdd = onAddProfile,
168− modifier = Modifier.fillMaxSize().padding(padding),
172+ modifier = Modifier.fillMaxSize(),
169173 )
170174 } else {
171175 PullToRefreshBox(
@@ -174,9 +178,10 @@ fun ServerListScreen(
174178 modifier = Modifier.fillMaxSize(),
175179 ) {
176180 LazyColumn(
177− modifier = Modifier.fillMaxSize().padding(padding),
181+ modifier = Modifier.fillMaxSize(),
178182 contentPadding = androidx.compose.foundation.layout.PaddingValues(
179− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
183+ start = 16.dp, end = 16.dp, top = 16.dp,
184+ bottom = 96.dp + padding.calculateBottomPadding(),
180185 ),
181186 verticalArrangement = Arrangement.spacedBy(12.dp),
182187 ) {
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+13
@@ -19,6 +19,7 @@ import androidx.compose.material3.RadioButton
1919import androidx.compose.material3.Text
2020import androidx.compose.material3.TextButton
2121import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.LaunchedEffect
2223import androidx.compose.runtime.getValue
2324import androidx.compose.runtime.mutableStateOf
2425import androidx.compose.runtime.remember
@@ -102,6 +103,18 @@ fun NewSessionDialog(
102103 }
103104 var customText by rememberSaveable { mutableStateOf("") }
104105
106+ // The initializer above may have run while options were still loading (serverDefault
107+ // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
108+ // arrives, knownDirs filters that path out (it has its own row), so no DirectoryRow
109+ // matches a Known(serverDefault) choice and every radio shows unselected. Collapse it
110+ // into ServerDefault so the selected radio always tracks a visible row.
111+ LaunchedEffect(options.serverDefault) {
112+ val current = choice
113+ if (current is DirChoice.Known && current.path == options.serverDefault) {
114+ choice = DirChoice.ServerDefault
115+ }
116+ }
117+
105118 val resolved: String? = when (val c = choice) {
106119 is DirChoice.ServerDefault -> null
107120 is DirChoice.Known -> c.path
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+17
@@ -101,6 +101,23 @@ fun TwoPaneSessionChat(
101101 }
102102 }
103103
104+ // The hasAppeared latch above can't survive process death (plain remember), so a
105+ // `selected` restored across process death whose session was deleted server-side while
106+ // dead never "appears" and is kept forever — the detail pane then renders ChatScreen for
107+ // a nonexistent session (permanent load error). Once the first list load completes
108+ // successfully without the restored target, clear it. Guarded to run once (non-saveable
109+ // flag, so it re-arms after process death) and only on a successful, completed load — not
110+ // during the initial load, and not for a freshly-created session whose refresh keeps
111+ // `loading` false while its row is still pending.
112+ var initialSelectionValidated by remember { mutableStateOf(false) }
113+ LaunchedEffect(sessionListState.loading, sessionListState.error, sessionListState.sessions) {
114+ if (initialSelectionValidated) return@LaunchedEffect
115+ if (sessionListState.loading || sessionListState.error != null) return@LaunchedEffect
116+ initialSelectionValidated = true
117+ val target = selected ?: return@LaunchedEffect
118+ if (sessionListState.sessions.none { it.id == target }) selected = null
119+ }
120+
104121 // Inject a pending share into the currently selected session's draft (if any).
105122 // Unlike single-pane mode where the session list is navigated to specifically for
106123 // the share, in two-pane mode the list is always visible — so we inject into the
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+14−5
@@ -102,16 +102,24 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
102102 val timeTick = rememberRelativeTimeTick()
103103
104104 // Report-delete undo events, shown via a single collectLatest collector rather than a
105− // per-confirm launch. A serialized showSnackbar would queue each report's undo behind
106− // the previous one's full window, so under rapid deletes a later report's delete timer
107− // (started at confirm time) fires before its snackbar is ever shown — a dead Undo
108− // button. collectLatest cancels the current snackbar and shows the newest immediately,
109− // keeping each Undo window aligned with its own timer. Mirrors ServerListScreen.
105+ // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
106+ // previous one's full window. collectLatest instead cancels the current snackbar and shows
107+ // the newest immediately. Because the actual delete is scheduled at confirm time (on the
108+ // CrashLogger's own scope, so it still commits if the user navigates away during the undo
109+ // window), a superseded report would otherwise be deleted with no reachable Undo; the
110+ // collector withdraws the previously-pending report's scheduled delete when a newer one
111+ // supersedes its snackbar, keeping every delete undoable for its whole window.
110112 val reportDeleteEvents = remember {
111113 MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
112114 }
113115 LaunchedEffect(Unit) {
116+ var pending: String? = null
114117 reportDeleteEvents.collectLatest { name ->
118+ // Only reached when a newer delete arrives (collectLatest supersedes the prior
119+ // snackbar) — navigating away tears down this collector without a new emission, so
120+ // that report's delete still commits. cancelScheduledDelete no-ops if it already fired.
121+ pending?.let { logger.cancelScheduledDelete(it) }
122+ pending = name
115123 coroutineScope {
116124 val dismisser = launch {
117125 delay(NetworkConfig.undoReportDeleteDelayMs)
@@ -125,6 +133,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
125133 dismisser.cancel()
126134 if (result == SnackbarResult.ActionPerformed) {
127135 logger.cancelScheduledDelete(name)
136+ pending = null
128137 }
129138 }
130139 }
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+10−4
@@ -372,9 +372,9 @@ class FakeAttachmentDraftStore : AttachmentDraftStore() {
372372class FakeMessageCacheStore : MessageCacheStore() {
373373 private val store = mutableMapOf<String, List<MessageWithParts>>()
374374
375− override suspend fun load(sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
375+ override suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
376376
377− override suspend fun save(sessionId: String, messages: List<MessageWithParts>) {
377+ override suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>) {
378378 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
379379 }
380380
@@ -448,12 +448,18 @@ class FakeAppContainer : AppContainer() {
448448 fakeActiveConnection.value = null
449449 }
450450
451− override suspend fun probeServer(baseUrl: String): ProbeResult {
451+ override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
452452 probeCalls = probeCalls + baseUrl
453453 return probeResult ?: ProbeResult.Reachable
454454 }
455455
456− override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
456+ override suspend fun probeWithCredentials(
457+ baseUrl: String,
458+ username: String,
459+ password: String,
460+ requireHttps: Boolean,
461+ certPin: String?,
462+ ): Boolean {
457463 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
458464 return probeWithCredentialsResult ?: true
459465 }