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
fac92bbiko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7−1
@@ -246,7 +246,13 @@ open class EventStreamClient(
246 // message carries the request URL and may include auth or paths.
246 // message carries the request URL and may include auth or paths.
247 Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
247 Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
248 _state.value = ConnectionState.Failed
248 _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+ }
250 // Don't complete the flow: a subscriber may fix the profile and call
256 // Don't complete the flow: a subscriber may fix the profile and call
251 // triggerReconnect(). Suspend until an explicit reconnect signal
257 // triggerReconnect(). Suspend until an explicit reconnect signal
252 // arrives (no backoff delay), then re-attempt the connection. This receive
258 // 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
3import android.content.Context
3import android.content.Context
4import android.net.nsd.NsdManager
4import android.net.nsd.NsdManager
5import android.net.nsd.NsdServiceInfo
5import android.net.nsd.NsdServiceInfo
6+import android.os.Build
6import android.util.Log
7import android.util.Log
7import kotlinx.coroutines.channels.Channel
8import kotlinx.coroutines.channels.Channel
8import kotlinx.coroutines.flow.Flow
9import kotlinx.coroutines.flow.Flow
@@ -16,9 +17,10 @@ import kotlin.coroutines.resume
16/** A server discovered on the local network via mDNS/DNS-SD. */
17/** A server discovered on the local network via mDNS/DNS-SD. */
17data class DiscoveredServer(val name: String, val host: String, val port: Int) {
18data class DiscoveredServer(val name: String, val host: String, val port: Int) {
18 val baseUrl: String get() {
19 val baseUrl: String get() {
19− // FIX 14: IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed and
20+ // IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed to form a valid
20− // have their scope id dropped to form a valid URL authority; IPv4 hosts are unchanged.
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.
21− val formattedHost = if (host.contains(':')) "[${host.substringBefore('%')}]" else host
23+ val formattedHost = if (host.contains(':')) "[${host.replace("%", "%25")}]" else host
22 return "http://$formattedHost:$port"
24 return "http://$formattedHost:$port"
23 }
25 }
24}
26}
@@ -54,6 +56,11 @@ open class NsdDiscovery(context: Context?) {
54
56
55 val resolver = launch {
57 val resolver = launch {
56 for (info in toResolve) {
58 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()
57 // NsdManager.resolveService occasionally never invokes its listener. Without a
64 // NsdManager.resolveService occasionally never invokes its listener. Without a
58 // timeout, one stuck resolve would hang this single serialized resolver forever,
65 // timeout, one stuck resolve would hang this single serialized resolver forever,
59 // so every later-discovered service queued behind it is never processed and the
66 // so every later-discovered service queued behind it is never processed and the
@@ -65,7 +72,7 @@ open class NsdDiscovery(context: Context?) {
65 if (!name.startsWith("opencode", ignoreCase = true)) continue
72 if (!name.startsWith("opencode", ignoreCase = true)) continue
66 @Suppress("DEPRECATION")
73 @Suppress("DEPRECATION")
67 val host = resolved.host?.hostAddress ?: continue
74 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)
69 trySend(found.values.sortedBy { it.name })
76 trySend(found.values.sortedBy { it.name })
70 }
77 }
71 }
78 }
@@ -99,16 +106,54 @@ open class NsdDiscovery(context: Context?) {
99
106
100 private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
107 private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
101 suspendCancellableCoroutine { cont ->
108 suspendCancellableCoroutine { cont ->
102− val listener = object : NsdManager.ResolveListener {
109+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
103− override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
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) }
104− if (cont.isActive) cont.resume(null)
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() {}
105 }
134 }
106− override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
135+ cont.invokeOnCancellation {
107− if (cont.isActive) cont.resume(serviceInfo)
136+ if (settled.compareAndSet(false, true)) {
137+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
138+ }
108 }
139 }
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) }
109 }
156 }
110− runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
111− .onFailure { if (cont.isActive) cont.resume(null) }
112 }
157 }
113
158
114 private companion object {
159 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(
98
98
99 /** Remove staged attachments for a session (call on session deletion). */
99 /** Remove staged attachments for a session (call on session deletion). */
100 open suspend fun remove(sessionId: String) {
100 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.
101 lockFor(sessionId).withLock {
107 lockFor(sessionId).withLock {
102 withContext(Dispatchers.IO) {
108 withContext(Dispatchers.IO) {
103 runCatchingCancellable { fileFor(sessionId)?.delete() }
109 runCatchingCancellable { fileFor(sessionId)?.delete() }
104 Unit
110 Unit
105 }
111 }
106 }
112 }
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)
111 }
113 }
112}
114}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+4−1
@@ -114,7 +114,10 @@ class BackupManager(
114 )
114 )
115 }
115 }
116 data.settings?.let { settings ->
116 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) }
118 settingsStore.setDynamicColor(settings.dynamicColor)
121 settingsStore.setDynamicColor(settings.dynamicColor)
119 settingsStore.setSendOnEnter(settings.sendOnEnter)
122 settingsStore.setSendOnEnter(settings.sendOnEnter)
120 settingsStore.setAppLock(settings.appLock)
123 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(
40 appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
40 appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
41 }
41 }
42
42
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+
43− private fun fileFor(sessionId: String): File? =
48+ private fun fileFor(profileId: String, sessionId: String): File? =
44− dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
49+ profileDir(profileId)?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
45
50
46 /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
51 /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
47 * panes of two-pane mode saving the same session, or a save racing a delete's remove)
52 * 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(
50 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
55 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
51 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
56 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
52
57
53− /** Load the cached messages for [sessionId], or empty if none / unreadable. */
58+ /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
54− open suspend fun load(sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
59+ open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
55 withContext(Dispatchers.IO) {
60 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()
57 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
62 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
58 .getOrDefault(emptyList())
63 .getOrDefault(emptyList())
59 }
64 }
60 }
65 }
61
66
62− /** Persist [messages] for [sessionId] (an empty list deletes the file). */
67+ /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
63− open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
68+ open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
64 withContext(Dispatchers.IO) {
69 withContext(Dispatchers.IO) {
65− val file = fileFor(sessionId) ?: return@withContext
70+ val file = fileFor(profileId, sessionId) ?: return@withContext
66 runCatchingCancellable {
71 runCatchingCancellable {
67 if (messages.isEmpty()) file.delete()
72 if (messages.isEmpty()) file.delete()
68 else {
73 else {
@@ -82,17 +87,24 @@ open class MessageCacheStore private constructor(
82 }
87 }
83 }
88 }
84
89
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. */
86 open suspend fun remove(sessionId: String) {
95 open suspend fun remove(sessionId: String) {
87 lockFor(sessionId).withLock {
96 lockFor(sessionId).withLock {
88 withContext(Dispatchers.IO) {
97 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+ }
90 Unit
106 Unit
91 }
107 }
92 }
108 }
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)
97 }
109 }
98}
110}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+12−4
@@ -77,12 +77,19 @@ open class OutboxStore private constructor(
77 * disk is read before any write, then a later [load] is a no-op. */
77 * disk is read before any write, then a later [load] is a no-op. */
78 private suspend fun loadLocked() {
78 private suspend fun loadLocked() {
79 if (loaded) return
79 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.
82 val diskList = withContext(Dispatchers.IO) {
90 val diskList = withContext(Dispatchers.IO) {
83 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
91 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
84− .getOrDefault(emptyList())
92+ }.getOrElse { return }
85− }
86 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
93 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
87 // the in-memory version on conflict (it's newer).
94 // the in-memory version on conflict (it's newer).
88 if (_messages.value.isEmpty()) {
95 if (_messages.value.isEmpty()) {
@@ -96,6 +103,7 @@ open class OutboxStore private constructor(
96 // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
103 // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
97 persist(merged)
104 persist(merged)
98 }
105 }
106+ loaded = true
99 }
107 }
100
108
101 open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
109 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
20import soy.iko.opencode.util.runCatchingCancellable
20import soy.iko.opencode.util.runCatchingCancellable
21import soy.iko.opencode.util.safeExceptionSummary
21import soy.iko.opencode.util.safeExceptionSummary
22import android.util.Log
22import android.util.Log
23+import kotlinx.coroutines.CoroutineScope
23import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.Job
25import kotlinx.coroutines.Job
25import kotlinx.coroutines.NonCancellable
26import kotlinx.coroutines.NonCancellable
@@ -46,6 +47,9 @@ open class SessionRepository(
46 // Optional on-disk cache so a conversation renders instantly on open and stays readable
47 // Optional on-disk cache so a conversation renders instantly on open and stays readable
47 // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
48 // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
48 private val messageCache: MessageCacheStore? = null,
49 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 = "",
49) {
53) {
50 open suspend fun listSessions() = api.listSessions()
54 open suspend fun listSessions() = api.listSessions()
51 open suspend fun createSession(title: String? = null, directory: String? = null) =
55 open suspend fun createSession(title: String? = null, directory: String? = null) =
@@ -78,6 +82,58 @@ open class SessionRepository(
78 * streaming tokens (each publishing a fresh snapshot) never back-pressures the
82 * streaming tokens (each publishing a fresh snapshot) never back-pressures the
79 * event reducer — a slow collector simply sees the most recent snapshot.
83 * event reducer — a slow collector simply sees the most recent snapshot.
80 */
84 */
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+
81 open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
137 open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
82 val store = MessageStore()
138 val store = MessageStore()
83 val lock = Mutex()
139 val lock = Mutex()
@@ -118,12 +174,12 @@ open class SessionRepository(
118 // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
174 // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
119 // (also generation 0) then overrides this with authoritative data when it succeeds.
175 // (also generation 0) then overrides this with authoritative data when it succeeds.
120 messageCache?.let { cache ->
176 messageCache?.let { cache ->
121− val cached = cache.load(sessionId)
177+ val cached = cache.load(profileId, sessionId)
122 if (cached.isNotEmpty()) {
178 if (cached.isNotEmpty()) {
123 val changed = lock.withLock {
179 val changed = lock.withLock {
124 if (seedGeneration == 0) {
180 if (seedGeneration == 0) {
125 cacheSeededIds = cached.mapTo(mutableSetOf()) { it.info.id }
181 cacheSeededIds = cached.mapTo(mutableSetOf()) { it.info.id }
126− store.seed(cached)
182+ store.seed(cached, clearPivot = false)
127 } else false
183 } else false
128 }
184 }
129 if (changed) publish()
185 if (changed) publish()
@@ -212,18 +268,10 @@ open class SessionRepository(
212 // outside the lock so a slow downstream collector can't stall the drain. This
268 // outside the lock so a slow downstream collector can't stall the drain. This
213 // loop also keeps the flow alive until the collector cancels; the launched jobs
269 // loop also keeps the flow alive until the collector cancels; the launched jobs
214 // above are children of this scope and are torn down with it.
270 // above are children of this scope and are torn down with it.
215− var lastCacheWrite = 0L
271+ // Per-subscription cache writer: throttled snapshot persistence + a NonCancellable
216− var pendingCacheSave: Job? = null
272+ // teardown flush. `this` is the channelFlow scope, so its scheduled saves are torn down
217− // Most recent published snapshot, and the most recent one whose on-disk write has
273+ // with the flow (the teardown flush re-persists a cancelled trailing write).
218− // actually completed. The teardown flush in the finally below persists a pending
274+ val cacheWriter = CacheWriter(this, sessionId)
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
227 // Guarantee at least one emission even when nothing seeds the store: with no on-disk
275 // Guarantee at least one emission even when nothing seeds the store: with no on-disk
228 // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
276 // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
229 // the seed paths publish, so the flow would emit nothing at all. The sole collector
277 // the seed paths publish, so the flow would emit nothing at all. The sole collector
@@ -236,46 +284,13 @@ open class SessionRepository(
236 dirty.consumeAsFlow().collect {
284 dirty.consumeAsFlow().collect {
237 val snapshot = lock.withLock { store.snapshot() }
285 val snapshot = lock.withLock { store.snapshot() }
238 send(snapshot)
286 send(snapshot)
239− // Persist the snapshot to the on-disk cache, throttled so a fast token stream
287+ // Persist to the on-disk cache, throttled so a fast token stream doesn't hammer
240− // doesn't hammer the disk — the cache only needs to be "recent enough" for an
288+ // the disk — the cache only needs to be "recent enough" for an instant/offline
241− // instant/offline first paint; the network corrects it on the next open.
289+ // first paint; the network corrects it on the next open.
242− if (messageCache != null && snapshot.isNotEmpty()) {
290+ cacheWriter.schedule(snapshot)
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− }
265 }
291 }
266 } finally {
292 } finally {
267− // FIX: both cache-saves above run as children of this inner flow scope, so when
293+ cacheWriter.flushOnTeardown()
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− }
279 }
294 }
280 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
295 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
281 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
296 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
@@ -380,7 +395,7 @@ internal class MessageStore {
380
395
381 fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
396 fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
382
397
383− fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
398+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
384 var changed = false
399 var changed = false
385 // On re-seed (after SSE reconnect), remove messages that are no longer in the
400 // On re-seed (after SSE reconnect), remove messages that are no longer in the
386 // server snapshot (e.g. deleted during the disconnection gap). This keeps the
401 // server snapshot (e.g. deleted during the disconnection gap). This keeps the
@@ -439,9 +454,14 @@ internal class MessageStore {
439 if (reorderByTime()) changed = true
454 if (reorderByTime()) changed = true
440 if (evictOldMessages()) changed = true
455 if (evictOldMessages()) changed = true
441 // Reset the pivot: everything just merged is now the baseline, so only parts/info
456 // 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.
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) {
443− streamedSincePivot.clear()
462+ streamedSincePivot.clear()
444− messageInfoUpdatedSincePivot.clear()
463+ messageInfoUpdatedSincePivot.clear()
464+ }
445 return changed
465 return changed
446 }
466 }
447
467
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−2
@@ -919,13 +919,18 @@ open class AppContainer private constructor(
919 * The probe client is always closed afterwards so no resources linger. This does not
919 * The probe client is always closed afterwards so no resources linger. This does not
920 * touch the active connection or the profile store.
920 * touch the active connection or the profile store.
921 */
921 */
922− open suspend fun probeServer(baseUrl: String): ProbeResult {
922+ open suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
923 return probeWithProfile(ServerProfile(
923 return probeWithProfile(ServerProfile(
924 id = "probe",
924 id = "probe",
925 label = "",
925 label = "",
926 baseUrl = baseUrl.trim(),
926 baseUrl = baseUrl.trim(),
927 username = null,
927 username = null,
928 password = null,
928 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,
929 ))
934 ))
930 }
935 }
931
936
@@ -935,13 +940,22 @@ open class AppContainer private constructor(
935 * on other failures (unreachable, timeout) so the caller can surface a friendly
940 * on other failures (unreachable, timeout) so the caller can surface a friendly
936 * message. Mirrors [probeServer]'s short-lived-client pattern.
941 * message. Mirrors [probeServer]'s short-lived-client pattern.
937 */
942 */
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 {
939 val probeProfile = ServerProfile(
950 val probeProfile = ServerProfile(
940 id = "probe-auth",
951 id = "probe-auth",
941 label = "",
952 label = "",
942 baseUrl = baseUrl.trim(),
953 baseUrl = baseUrl.trim(),
943 username = username.trim().takeIf { it.isNotBlank() },
954 username = username.trim().takeIf { it.isNotBlank() },
944 password = password.trim().takeIf { it.isNotEmpty() },
955 password = password.trim().takeIf { it.isNotEmpty() },
956+ // Match the effective URL/pinning save()/connect() will use (see probeServer).
957+ requireHttps = requireHttps,
958+ certPin = certPin,
945 )
959 )
946 val client = HttpClientFactory.create(probeProfile)
960 val client = HttpClientFactory.create(probeProfile)
947 return try {
961 return try {
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1
@@ -31,7 +31,7 @@ open class OpencodeConnection(
31
31
32 open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
32 open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
33 open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
33 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) }
35
35
36 /**
36 /**
37 * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
37 * 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
5import android.os.Build
5import android.os.Build
6import androidx.biometric.BiometricManager
6import androidx.biometric.BiometricManager
7import androidx.biometric.BiometricPrompt
7import androidx.biometric.BiometricPrompt
8−import androidx.compose.foundation.interaction.MutableInteractionSource
9−import androidx.compose.foundation.clickable
10import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
11−import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
@@ -25,6 +22,7 @@ import androidx.compose.runtime.DisposableEffect
25import androidx.compose.runtime.getValue
22import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
24import androidx.compose.runtime.remember
25+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
28import androidx.compose.runtime.setValue
26import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
27import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
28import androidx.compose.ui.Modifier
@@ -54,6 +52,20 @@ private val lockAuthenticators: Int
54fun canAuthenticateForAppLock(context: Context): Boolean =
52fun canAuthenticateForAppLock(context: Context): Boolean =
55 BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
53 BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
56
54
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+
57/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
69/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
58private fun Context.findFragmentActivity(): FragmentActivity? {
70private fun Context.findFragmentActivity(): FragmentActivity? {
59 var ctx: Context? = this
71 var ctx: Context? = this
@@ -83,10 +95,11 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
83 content()
95 content()
84 return
96 return
85 }
97 }
86− // Fail open if device authentication is no longer possible (e.g. the user enabled app-lock
98+ // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
87− // while a credential was enrolled and later removed their PIN/biometric): authenticate()
99+ // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
88 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
100 // 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)) {
90 content()
103 content()
91 return
104 return
92 }
105 }
@@ -146,7 +159,13 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
146 DisposableEffect(lifecycleOwner) {
159 DisposableEffect(lifecycleOwner) {
147 val observer = LifecycleEventObserver { _, event ->
160 val observer = LifecycleEventObserver { _, event ->
148 when (event) {
161 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+ }
150 Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
169 Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
151 else -> {}
170 else -> {}
152 }
171 }
@@ -155,43 +174,47 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
155 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
174 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
156 }
175 }
157
176
158− Box(modifier = Modifier.fillMaxSize()) {
177+ // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
159− content()
178+ // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
160− if (!unlocked) {
179+ // separate windows z-ordered above the Activity content, so an open permission dialog or picker
161− // Opaque overlay covering the content. The no-op clickable swallows touches so
180+ // sheet would stay visible and interactive above the lock — an authentication bypass. Not
162− // the hidden UI beneath can't be interacted with while locked.
181+ // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
163− val interaction = remember { MutableInteractionSource() }
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 {
164− Surface(
188+ Surface(
165− modifier = Modifier
189+ modifier = Modifier.fillMaxSize(),
166− .fillMaxSize()
167− .clickable(interactionSource = interaction, indication = null) {},
168− color = MaterialTheme.colorScheme.background,
190+ color = MaterialTheme.colorScheme.background,
191+ ) {
192+ Column(
193+ modifier = Modifier.fillMaxSize().padding(32.dp),
194+ horizontalAlignment = Alignment.CenterHorizontally,
195+ verticalArrangement = Arrangement.Center,
169 ) {
196 ) {
170− Column(
197+ Icon(
198+ Icons.Filled.Lock,
199+ contentDescription = null,
171− modifier = Modifier.fillMaxSize().padding(32.dp),
200+ modifier = Modifier.size(48.dp),
172− horizontalAlignment = Alignment.CenterHorizontally,
201+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
173− verticalArrangement = Arrangement.Center,
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),
174 ) {
212 ) {
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))
213+ Text(stringResource(R.string.app_unlock))
192− }
193 }
214 }
194 }
215 }
195 }
216 }
196 }
217 }
197}
218}
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(
1482 onDismiss: () -> Unit,
1482 onDismiss: () -> Unit,
1483 onConfirm: (String) -> Unit,
1483 onConfirm: (String) -> Unit,
1484) {
1484) {
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.
1485− var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1488+ var title by rememberSaveable { mutableStateOf(initialTitle) }
1486 AlertDialog(
1489 AlertDialog(
1487 onDismissRequest = onDismiss,
1490 onDismissRequest = onDismiss,
1488 title = { Text(stringResource(R.string.rename_session_chat)) },
1491 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
53import kotlinx.coroutines.flow.stateIn
53import kotlinx.coroutines.flow.stateIn
54import kotlinx.coroutines.flow.update
54import kotlinx.coroutines.flow.update
55import kotlinx.coroutines.Dispatchers
55import kotlinx.coroutines.Dispatchers
56+import kotlinx.coroutines.Job
56import kotlinx.coroutines.delay
57import kotlinx.coroutines.delay
57import kotlinx.coroutines.launch
58import kotlinx.coroutines.launch
58
59
@@ -242,6 +243,11 @@ class ChatViewModel(
242 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
243 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
243 @Volatile private var runEndedByIdle = false
244 @Volatile private var runEndedByIdle = false
244
245
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+
245 /** True while an abort REST call is in flight, so the Stop button can show a
251 /** True while an abort REST call is in flight, so the Stop button can show a
246 * spinner and prevent double-taps from firing a second abort. */
252 * spinner and prevent double-taps from firing a second abort. */
247 private val _aborting = MutableStateFlow(false)
253 private val _aborting = MutableStateFlow(false)
@@ -557,6 +563,7 @@ class ChatViewModel(
557 _running.value = false
563 _running.value = false
558 _aborting.value = false
564 _aborting.value = false
559 clearPermissions()
565 clearPermissions()
566+ sessionTitleJob?.cancel()
560 return@collectLatest
567 return@collectLatest
561 }
568 }
562 _running.value = false
569 _running.value = false
@@ -572,6 +579,24 @@ class ChatViewModel(
572 // restore path — would drop a legitimately queued/recovered follow-up.
579 // restore path — would drop a legitimately queued/recovered follow-up.
573 _selectedAgent.value = null
580 _selectedAgent.value = null
574 _sessionTitle.value = null
581 _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+ }
575 try {
600 try {
576 conn.events.events.collect { event ->
601 conn.events.events.collect { event ->
577 if (SessionRepository.isIdle(event, sessionId)) {
602 if (SessionRepository.isIdle(event, sessionId)) {
@@ -601,7 +626,9 @@ class ChatViewModel(
601 // Restore the run indicator if a live streaming event arrives while it's
626 // Restore the run indicator if a live streaming event arrives while it's
602 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
627 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
603 // agent is still working. Don't revive it once the user has hit Stop.
628 // agent is still working. Don't revive it once the user has hit Stop.
629+ val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
604− if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value && !runEndedByIdle) {
630+ !_aborting.value && !_running.value && !runEndedByIdle
631+ if (relightRunIndicator) {
605 _running.value = true
632 _running.value = true
606 }
633 }
607 when (event) {
634 when (event) {
@@ -671,24 +698,6 @@ class ChatViewModel(
671 onNull = { _commands.value = emptyList() },
698 onNull = { _commands.value = emptyList() },
672 reloadTrigger = _commandsReload,
699 reloadTrigger = _commandsReload,
673 )
700 )
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− }
692 // If the SSE stream drops mid-run, the run indicator would spin forever;
701 // If the SSE stream drops mid-run, the run indicator would spin forever;
693 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
702 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
694 viewModelScope.launch {
703 viewModelScope.launch {
@@ -952,6 +961,9 @@ class ChatViewModel(
952 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
961 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
953 return
962 return
954 }
963 }
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
955 viewModelScope.launch {
967 viewModelScope.launch {
956 runCatchingCancellable {
968 runCatchingCancellable {
957 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
969 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
@@ -1034,7 +1046,7 @@ class ChatViewModel(
1034 * Overwrites the current draft — this is an explicit edit action on that message. */
1046 * Overwrites the current draft — this is an explicit edit action on that message. */
1035 fun editMessage(messageId: String, text: String) {
1047 fun editMessage(messageId: String, text: String) {
1036 revertTo(messageId)
1048 revertTo(messageId)
1037− _draft.value = text
1049+ _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1038 }
1050 }
1039
1051
1040 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1052 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
@@ -1044,7 +1056,10 @@ class ChatViewModel(
1044 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1056 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1045 if (quoted.isBlank()) return
1057 if (quoted.isBlank()) return
1046 val current = _draft.value
1058 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)
1048 }
1063 }
1049
1064
1050 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1065 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
@@ -1118,6 +1133,9 @@ class ChatViewModel(
1118 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1133 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1119 return
1134 return
1120 }
1135 }
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
1121 viewModelScope.launch {
1139 viewModelScope.launch {
1122 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1140 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1123 .onFailure {
1141 .onFailure {
@@ -1134,6 +1152,9 @@ class ChatViewModel(
1134 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1152 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1135 return
1153 return
1136 }
1154 }
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
1137 viewModelScope.launch {
1158 viewModelScope.launch {
1138 runCatchingCancellable { conn.api.initSession(sessionId) }
1159 runCatchingCancellable { conn.api.initSession(sessionId) }
1139 .onFailure {
1160 .onFailure {
@@ -1157,6 +1178,9 @@ class ChatViewModel(
1157 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1178 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1158 return
1179 return
1159 }
1180 }
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
1160 viewModelScope.launch {
1184 viewModelScope.launch {
1161 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1185 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1162 .onFailure {
1186 .onFailure {
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+3−1
@@ -256,7 +256,9 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
256internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
256internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
257 if (!isFenced) return null
257 if (!isFenced) return null
258 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
258 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
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.
259− val info = first.removePrefix("```").removePrefix("~~~").trim()
261+ val info = first.trimStart('`', '~').trim()
260 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
262 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
261 // first whitespace-delimited token is the language.
263 // first whitespace-delimited token is the language.
262 return info.substringBefore(' ').takeIf { it.isNotBlank() }
264 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
85private fun decodeDataUri(data: String): ByteArray? {
85private fun decodeDataUri(data: String): ByteArray? {
86 val comma = data.indexOf(',')
86 val comma = data.indexOf(',')
87 if (comma < 0) return null
87 if (comma < 0) return null
88+ val header = data.substring(0, comma)
88 val payload = data.substring(comma + 1)
89 val payload = data.substring(comma + 1)
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)) {
89− return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
95+ Base64.decode(payload, Base64.DEFAULT)
96+ } else {
97+ android.net.Uri.decode(payload).toByteArray()
98+ }
99+ }.getOrNull()
90}
100}
91
101
92/**
102/**
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+5−1
@@ -99,7 +99,11 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
99 // Preserve the session order (already recency-sorted by the server) in the results.
99 // Preserve the session order (already recency-sorted by the server) in the results.
100 val ordered = toSearch.mapNotNull { hits[it.id] }
100 val ordered = toSearch.mapNotNull { hits[it.id] }
101 _state.update {
101 _state.update {
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
102− it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
106+ else it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
103 }
107 }
104 }
108 }
105
109
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+5−3
@@ -87,9 +87,11 @@ fun ServerEditScreen(
87 }
87 }
88
88
89 // Intercept the system back gesture when the form has unsaved changes so the user
89 // 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.
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.
91− BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
93+ BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
92− showDiscardConfirm = true
94+ if (!state.saving) showDiscardConfirm = true
93 }
95 }
94
96
95 Scaffold(
97 Scaffold(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−2
@@ -226,8 +226,10 @@ class ServerEditViewModel(
226 // FIX 11: remember which URL we probed so a stale result is dropped if the
226 // FIX 11: remember which URL we probed so a stale result is dropped if the
227 // user edited the base URL away while the probe was in flight.
227 // user edited the base URL away while the probe was in flight.
228 val probedUrl = s.baseUrl.trim()
228 val probedUrl = s.baseUrl.trim()
229+ val probedRequireHttps = s.requireHttps
230+ val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
229 viewModelScope.launch {
231 viewModelScope.launch {
230− val result = runCatchingCancellable { container.probeServer(probedUrl) }
232+ val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
231 result.onSuccess { pr ->
233 result.onSuccess { pr ->
232 _state.update {
234 _state.update {
233 // Stale result (URL edited away mid-probe): drop the reachability verdict but
235 // Stale result (URL edited away mid-probe): drop the reachability verdict but
@@ -280,8 +282,12 @@ class ServerEditViewModel(
280 val probedUrl = s.baseUrl.trim()
282 val probedUrl = s.baseUrl.trim()
281 val probedUser = s.username.trim()
283 val probedUser = s.username.trim()
282 val probedPass = s.password.trim()
284 val probedPass = s.password.trim()
285+ val probedRequireHttps = s.requireHttps
286+ val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
283 viewModelScope.launch {
287 viewModelScope.launch {
288+ val result = runCatchingCancellable {
284− val result = runCatchingCancellable { container.probeWithCredentials(probedUrl, probedUser, probedPass) }
289+ container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
290+ }
285 result.onSuccess { ok ->
291 result.onSuccess { ok ->
286 _state.update {
292 _state.update {
287 // Stale result: clear the spinner but drop the verdict (see probe()).
293 // 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(
158 }
158 }
159 },
159 },
160 ) { padding ->
160 ) { padding ->
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.
161− Column(modifier = Modifier.fillMaxSize()) {
165+ Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
162 if (reconnecting && profiles.isNotEmpty()) {
166 if (reconnecting && profiles.isNotEmpty()) {
163 LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
167 LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
164 }
168 }
165 if (profiles.isEmpty()) {
169 if (profiles.isEmpty()) {
166 EmptyServers(
170 EmptyServers(
167 onAdd = onAddProfile,
171 onAdd = onAddProfile,
168− modifier = Modifier.fillMaxSize().padding(padding),
172+ modifier = Modifier.fillMaxSize(),
169 )
173 )
170 } else {
174 } else {
171 PullToRefreshBox(
175 PullToRefreshBox(
@@ -174,9 +178,10 @@ fun ServerListScreen(
174 modifier = Modifier.fillMaxSize(),
178 modifier = Modifier.fillMaxSize(),
175 ) {
179 ) {
176 LazyColumn(
180 LazyColumn(
177− modifier = Modifier.fillMaxSize().padding(padding),
181+ modifier = Modifier.fillMaxSize(),
178 contentPadding = androidx.compose.foundation.layout.PaddingValues(
182 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(),
180 ),
185 ),
181 verticalArrangement = Arrangement.spacedBy(12.dp),
186 verticalArrangement = Arrangement.spacedBy(12.dp),
182 ) {
187 ) {
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+13
@@ -19,6 +19,7 @@ import androidx.compose.material3.RadioButton
19import androidx.compose.material3.Text
19import androidx.compose.material3.Text
20import androidx.compose.material3.TextButton
20import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
21import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.LaunchedEffect
22import androidx.compose.runtime.getValue
23import androidx.compose.runtime.getValue
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.remember
25import androidx.compose.runtime.remember
@@ -102,6 +103,18 @@ fun NewSessionDialog(
102 }
103 }
103 var customText by rememberSaveable { mutableStateOf("") }
104 var customText by rememberSaveable { mutableStateOf("") }
104
105
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+
105 val resolved: String? = when (val c = choice) {
118 val resolved: String? = when (val c = choice) {
106 is DirChoice.ServerDefault -> null
119 is DirChoice.ServerDefault -> null
107 is DirChoice.Known -> c.path
120 is DirChoice.Known -> c.path
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+17
@@ -101,6 +101,23 @@ fun TwoPaneSessionChat(
101 }
101 }
102 }
102 }
103
103
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+
104 // Inject a pending share into the currently selected session's draft (if any).
121 // Inject a pending share into the currently selected session's draft (if any).
105 // Unlike single-pane mode where the session list is navigated to specifically for
122 // Unlike single-pane mode where the session list is navigated to specifically for
106 // the share, in two-pane mode the list is always visible — so we inject into the
123 // 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) {
102 val timeTick = rememberRelativeTimeTick()
102 val timeTick = rememberRelativeTimeTick()
103
103
104 // Report-delete undo events, shown via a single collectLatest collector rather than a
104 // 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
105+ // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
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,
106+ // previous one's full window. collectLatest instead cancels the current snackbar and shows
109− // keeping each Undo window aligned with its own timer. Mirrors ServerListScreen.
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.
110 val reportDeleteEvents = remember {
112 val reportDeleteEvents = remember {
111 MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
113 MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
112 }
114 }
113 LaunchedEffect(Unit) {
115 LaunchedEffect(Unit) {
116+ var pending: String? = null
114 reportDeleteEvents.collectLatest { name ->
117 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
115 coroutineScope {
123 coroutineScope {
116 val dismisser = launch {
124 val dismisser = launch {
117 delay(NetworkConfig.undoReportDeleteDelayMs)
125 delay(NetworkConfig.undoReportDeleteDelayMs)
@@ -125,6 +133,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
125 dismisser.cancel()
133 dismisser.cancel()
126 if (result == SnackbarResult.ActionPerformed) {
134 if (result == SnackbarResult.ActionPerformed) {
127 logger.cancelScheduledDelete(name)
135 logger.cancelScheduledDelete(name)
136+ pending = null
128 }
137 }
129 }
138 }
130 }
139 }
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+10−4
@@ -372,9 +372,9 @@ class FakeAttachmentDraftStore : AttachmentDraftStore() {
372class FakeMessageCacheStore : MessageCacheStore() {
372class FakeMessageCacheStore : MessageCacheStore() {
373 private val store = mutableMapOf<String, List<MessageWithParts>>()
373 private val store = mutableMapOf<String, List<MessageWithParts>>()
374
374
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()
376
376
377− override suspend fun save(sessionId: String, messages: List<MessageWithParts>) {
377+ override suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>) {
378 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
378 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
379 }
379 }
380
380
@@ -448,12 +448,18 @@ class FakeAppContainer : AppContainer() {
448 fakeActiveConnection.value = null
448 fakeActiveConnection.value = null
449 }
449 }
450
450
451− override suspend fun probeServer(baseUrl: String): ProbeResult {
451+ override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
452 probeCalls = probeCalls + baseUrl
452 probeCalls = probeCalls + baseUrl
453 return probeResult ?: ProbeResult.Reachable
453 return probeResult ?: ProbeResult.Reachable
454 }
454 }
455
455
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 {
457 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
463 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
458 return probeWithCredentialsResult ?: true
464 return probeWithCredentialsResult ?: true
459 }
465 }