git.iko.soy/opencode-android
on master help sign in
Fix 10 bugs: reducer part-append permanent cache ghost, abort cancelled by nav, HTTPS auth cross-host leak, reply notification stuck spinner, mDNS phantom entry epoch-based, queue button attachment-only, duplicate day separator, indented closing fence, fence language tab-delimit, UnknownMessage hash duplicate
commitda36afb759050af1aad833f4cb1fcfbfa9e566f4 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 03:40 (3 months ago) parent4459e94
10 files changed+146−50
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+17 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+29−29 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+19−7 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+9−3 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+22 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+27−7 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+10−3 app/src/main/res/values-es/strings.xml+2 app/src/main/res/values/strings.xml+2
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+17
@@ -103,6 +103,23 @@ object HttpClientFactory {
103103 }
104104 }
105105 }
106+ // Defense in depth mirroring the HTTP branch below: sendWithoutRequest only gates
107+ // *eager* attachment on the outgoing request — it neither strips an Authorization
108+ // header that HttpRedirect copies from the original (configured-host) request onto
109+ // a cross-origin redirect target, nor stops the reactive Basic provider answering a
110+ // 401 from such a target. followRedirects is enabled by default, so re-check the
111+ // host on every physical send and remove the header whenever it isn't the pinned
112+ // host, guaranteeing credentials never leave the configured origin.
113+ install(
114+ createClientPlugin("HttpsBasicAuthHostScoped") {
115+ on(Send) { request ->
116+ if (pinHost == null || !request.url.host.equals(pinHost, ignoreCase = true)) {
117+ request.headers.remove(HttpHeaders.Authorization)
118+ }
119+ proceed(request)
120+ }
121+ },
122+ )
106123 } else if (pinHost != null) {
107124 // For HTTP profiles with auth, attach the Basic header proactively (the reactive
108125 // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+29−29
@@ -52,16 +52,21 @@ open class NsdDiscovery(context: Context?) {
5252 }
5353 // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
5454 val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
55− // Names reported lost before the serialized resolver reached them. Without this, a
56− // service that disappears between onServiceFound and the resolver dequeuing it would
57− // be re-inserted into `found` after onServiceLost already fired (and returned null),
58− // leaving a phantom entry that lingers until discovery stops — the lost callback never
59− // fires twice for the same name. The resolver consults this set and skips the insert.
60− val lostPending = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
61− val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
55+ // Per-name epoch, bumped on every onServiceLost. Each queued resolve is stamped with the
56+ // epoch that was current when it was enqueued; the resolver discards its result if the
57+ // epoch has advanced since. This makes loss ordering authoritative: a resolve queued
58+ // BEFORE a loss — a stale duplicate onServiceFound (stock NsdManager can emit these on
59+ // multi-homed devices), or a resolve still in flight when the service departs — is
60+ // dropped, while a resolve queued AFTER a loss (a genuine re-announcement of a restarted
61+ // server) still inserts. It fixes the phantom a plain "lost-pending" flag missed:
62+ // onServiceLost removing an already-resolved entry couldn't tell a second, still-queued
63+ // duplicate resolve to skip re-inserting the departed service — yet it also avoids that
64+ // flag's converse hazard of leaking a marker that suppresses a later legitimate re-appearance.
65+ val epoch = java.util.concurrent.ConcurrentHashMap<String, Int>()
66+ val toResolve = Channel<Pair<NsdServiceInfo, Int>>(Channel.UNLIMITED)
6267
6368 val resolver = launch {
64− for (info in toResolve) {
69+ for ((info, stampedEpoch) in toResolve) {
6570 // Key the map by the name from discovery: onServiceFound/onServiceLost both use
6671 // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
6772 // keying by the resolved name would leave a departed server that onServiceLost
@@ -72,13 +77,9 @@ open class NsdDiscovery(context: Context?) {
7277 // so every later-discovered service queued behind it is never processed and the
7378 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
7479 val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
75− // Consume any "lost while queued" marker for this name REGARDLESS of the resolve
76− // outcome, and skip resurrecting a service reported lost. If the resolve failed or
77− // timed out (e.g. the service really is gone), an early `?: continue` on the line
78− // above would skip this and leak the marker — which would then wrongly suppress the
79− // SAME name's next successful resolution when the server restarts and re-announces
80− // (onServiceLost won't fire again to clear it). Also drop a null resolve outright.
81− if (lostPending.remove(discoveredName) || resolved == null) continue
80+ // Drop the result if the service was reported lost since this resolve was queued
81+ // (its epoch advanced), or if the resolve failed/timed out (the service is gone).
82+ if ((epoch[discoveredName] ?: 0) != stampedEpoch || resolved == null) continue
8283 val name = resolved.serviceName.orEmpty()
8384 // Keep only opencode's own advertisements, not every _http._tcp service
8485 // (printers, routers, other web servers) on the network.
@@ -86,11 +87,11 @@ open class NsdDiscovery(context: Context?) {
8687 @Suppress("DEPRECATION")
8788 val host = resolved.host?.hostAddress ?: continue
8889 found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
89− // Close the check-then-insert race: an onServiceLost that fired in the non-suspending
90− // window since the marker check above couldn't remove this name from `found` (nothing
91− // was inserted yet), so it set a lostPending marker instead. Re-check and evict now,
92− // else the just-departed service lingers as a phantom onServiceLost won't clear.
93− if (lostPending.remove(discoveredName)) found.remove(discoveredName)
90+ // Close the check-then-insert race: an onServiceLost firing in the non-suspending
91+ // window since the check above bumped the epoch but couldn't remove this name from
92+ // `found` (nothing was inserted yet). Re-check and evict now — the epoch is
93+ // monotonic, so any loss that raced the insert is still visible here.
94+ if ((epoch[discoveredName] ?: 0) != stampedEpoch) found.remove(discoveredName)
9495 trySend(found.values.sortedBy { it.name })
9596 }
9697 }
@@ -98,20 +99,19 @@ open class NsdDiscovery(context: Context?) {
9899 val listener = object : NsdManager.DiscoveryListener {
99100 override fun onDiscoveryStarted(serviceType: String?) {}
100101 override fun onServiceFound(serviceInfo: NsdServiceInfo) {
101− toResolve.trySend(serviceInfo)
102+ // Stamp the resolve with the name's current epoch so a loss after this point (but
103+ // before the serialized resolver reaches it) invalidates the result.
104+ val name = serviceInfo.serviceName.orEmpty()
105+ toResolve.trySend(serviceInfo to (epoch[name] ?: 0))
102106 }
103107 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
104108 val name = serviceInfo.serviceName ?: return
105− // If it was already resolved and in `found`, remove and emit now — and do NOT set a
106− // lostPending marker: the resolver already processed this name, so the marker would
107− // never be consumed and would instead suppress the service's NEXT re-appearance (a
108− // restarted server re-discovered later would hit `lostPending.remove` and be
109− // dropped). Only mark names lost *before* the serialized resolver reached them —
110− // those aren't in `found` yet, and the marker tells the resolver to skip the insert.
109+ // Advance the epoch so any resolve queued before now (including a duplicate
110+ // onServiceFound whose result hasn't landed yet) is discarded and can't resurrect
111+ // this service, then drop it from the emitted list if it was already resolved.
112+ epoch.merge(name, 1) { old, _ -> old + 1 }
111113 if (found.remove(name) != null) {
112114 trySend(found.values.sortedBy { it.name })
113− } else {
114− lostPending.add(name)
115115 }
116116 }
117117 override fun onDiscoveryStopped(serviceType: String?) {}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+19−7
@@ -427,8 +427,18 @@ internal class MessageStore {
427427 val ordered = m.parts.map { p ->
428428 if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
429429 }.toMutableList()
430+ // Append only parts that streamed in *since the pivot* and are absent from the
431+ // snapshot — i.e. genuinely newer than this REST fetch. A part in memory from
432+ // before the pivot that the snapshot omits was removed server-side (its
433+ // MessagePartRemoved missed during a disconnect gap, or it was deleted while the
434+ // app was closed and only survives from the cache seed); the authoritative REST
435+ // snapshot must be allowed to drop it, else it reappears as a ghost — and, once
436+ // the drain loop persists this merge, gets baked into the on-disk cache forever
437+ // (the message still exists, so pruneStaleCacheSeeded never removes it). This
438+ // mirrors the overlapping-part branch above and the info branch below, which both
439+ // already gate on the pivot.
430440 for (p in existing.parts.values) {
431− if (p.id !in snapshotIds) ordered.add(p)
441+ if (p.id !in snapshotIds && p.id in streamedSincePivot) ordered.add(p)
432442 }
433443 // Adopt the authoritative REST info unless the in-memory info was itself
434444 // updated live since the pivot (i.e. during this fetch), in which case it's
@@ -532,13 +542,15 @@ internal class MessageStore {
532542 val info = event.properties.info
533543 if (info.sessionID != sessionId) return false
534544 // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
535− // field) would collide with other such messages in the map. Derive a synthetic key from
536− // the message's own content (it carries no id, only sessionID/time) so each distinct
537− // unknown message gets its own entry, while a byte-identical re-send (a redundant event or
538− // a post-reconnect re-seed) coalesces via the existing==info guard below instead of piling
539− // up duplicate holders — which a fresh per-event counter caused (existing was always null).
545+ // field) would collide with other such messages in the map. It carries no id, only
546+ // sessionID/time, so derive a synthetic key. Prefer the immutable creation timestamp: it's
547+ // stable across updates, so a later update to the SAME unknown message (whose `time.updated`
548+ // or `time.completed` changes) coalesces onto its holder via the existing==info guard below
549+ // instead of forking a duplicate — the failure mode a full-content hash has, since any
550+ // content change moves the hash. Distinct unknown messages still separate by their distinct
551+ // creation times; only when even `created` is absent do we fall back to the content hash.
540552 val key = if (info.id.isEmpty() && info is UnknownMessage) {
541− "unknown-${info.hashCode()}"
553+ info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${info.hashCode()}"
542554 } else {
543555 info.id
544556 }
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+9−3
@@ -70,9 +70,15 @@ class NotificationActionReceiver : BroadcastReceiver() {
7070 watchdog(finishOnce)
7171 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
7272 // The reply is durably queued (and flushed now if online); clear the
73− // "session ready" notification. If the enqueue failed, leave it up so the
74− // reply isn't silently lost and the user can retry.
75− if (enqueued) SessionNotifications.cancel(context, sessionId)
73+ // "session ready" notification. If the enqueue failed, cancel it anyway and
74+ // post a "reply not sent" error: leaving the completion notification up strands
75+ // SystemUI's RemoteInput field in a permanent "sending…" spinner that can't be
76+ // resubmitted, so "leave it up to retry" doesn't actually let the user retry.
77+ if (enqueued) {
78+ SessionNotifications.cancel(context, sessionId)
79+ } else {
80+ SessionNotifications.postReplyFailed(context, sessionId)
81+ }
7682 finishOnce()
7783 }
7884 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+22
@@ -205,6 +205,28 @@ object SessionNotifications {
205205 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
206206 }
207207
208+ /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
209+ * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
210+ * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
211+ * notification so the reply isn't silently lost and the user can reopen the session to retry. */
212+ @SuppressLint("MissingPermission")
213+ fun postReplyFailed(context: Context, sessionId: String) {
214+ NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
215+ if (!canPost(context)) return
216+ val notifId = notifId(NS_ERROR, sessionId)
217+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
218+ .setSmallIcon(R.drawable.ic_launcher_foreground)
219+ .setContentTitle(context.getString(R.string.notif_reply_failed_title))
220+ .setContentText(context.getString(R.string.notif_reply_failed_text))
221+ .setAutoCancel(true)
222+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
223+ .setCategory(NotificationCompat.CATEGORY_ERROR)
224+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
225+ .build()
226+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
227+ .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
228+ }
229+
208230 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
209231 private fun mutableFlag(): Int =
210232 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+27−7
@@ -1224,8 +1224,16 @@ private fun ChatInputBar(
12241224 capitalization = KeyboardCapitalization.Sentences,
12251225 ),
12261226 keyboardActions = KeyboardActions(onSend = {
1227− if (enabled && hasContent) {
1228− if (running) onQueueFollowUp(value) else onSend()
1227+ if (enabled) {
1228+ // During a run the Send IME action queues a follow-up, but only when
1229+ // there's typed text — queuing "" would cancel an existing queued
1230+ // follow-up (see the Queue button gate). When not running, an
1231+ // attachment-only send is valid, so gate that path on hasContent.
1232+ if (running) {
1233+ if (value.isNotBlank()) onQueueFollowUp(value)
1234+ } else if (hasContent) {
1235+ onSend()
1236+ }
12291237 }
12301238 }),
12311239 )
@@ -1233,6 +1241,7 @@ private fun ChatInputBar(
12331241 running = running,
12341242 aborting = aborting,
12351243 canSend = enabled && hasContent,
1244+ canQueue = enabled && value.isNotBlank(),
12361245 onSend = onSend,
12371246 onAbort = onAbort,
12381247 onQueue = { onQueueFollowUp(value) },
@@ -1264,6 +1273,7 @@ private fun ComposerTrailingButton(
12641273 running: Boolean,
12651274 aborting: Boolean,
12661275 canSend: Boolean,
1276+ canQueue: Boolean,
12671277 onSend: () -> Unit,
12681278 onAbort: () -> Unit,
12691279 onQueue: () -> Unit,
@@ -1274,9 +1284,12 @@ private fun ComposerTrailingButton(
12741284 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
12751285 // That leaves a touch-only user with the setting off no way to queue a follow-up
12761286 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
1277− // there's typed content so queuing is always reachable, without changing the user's
1278− // Enter-inserts-a-newline preference.
1279− if (canSend) {
1287+ // there's typed TEXT so queuing is always reachable, without changing the user's
1288+ // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
1289+ // canSend, which is also true for an attachment-only composer — because queuing "" just
1290+ // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
1291+ // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
1292+ if (canQueue) {
12801293 IconButton(
12811294 onClick = onQueue,
12821295 modifier = Modifier.testTag("queue_button"),
@@ -1593,13 +1606,20 @@ private fun buildMessageListItems(
15931606 var lastDayKey: String? = null
15941607 var sepOrdinal = 0
15951608 var emptyIdOrdinal = 0
1609+ var first = true
15961610 for (message in messages) {
15971611 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
15981612 val dayKey = ts?.let { dayKey(it) } ?: ""
1599− if (dayKey != lastDayKey) {
1613+ // Emit a separator before the very first message (so a leading un-timestamped message
1614+ // still gets the first divider, empty label and all), and thereafter only when crossing
1615+ // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
1616+ // neither emits its own separator nor resets the day — otherwise a following same-day
1617+ // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
1618+ if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
16001619 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1601− lastDayKey = dayKey
16021620 }
1621+ if (dayKey.isNotEmpty()) lastDayKey = dayKey
1622+ first = false
16031623 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
16041624 // empty id — and the reducer intentionally keeps several such messages as distinct
16051625 // holders — so give each a stable synthetic key and defensively de-collide any remaining
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−1
@@ -54,10 +54,12 @@ import kotlinx.coroutines.flow.stateIn
5454import kotlinx.coroutines.flow.update
5555import kotlinx.coroutines.Dispatchers
5656import kotlinx.coroutines.Job
57+import kotlinx.coroutines.NonCancellable
5758import kotlinx.coroutines.delay
5859import kotlinx.coroutines.isActive
5960import kotlinx.coroutines.currentCoroutineContext
6061import kotlinx.coroutines.launch
62+import kotlinx.coroutines.withContext
6163
6264/**
6365 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
@@ -1035,7 +1037,13 @@ class ChatViewModel(
10351037 if (!_aborting.compareAndSet(false, true)) return
10361038 viewModelScope.launch {
10371039 try {
1038− runCatchingCancellable { conn.repository.abort(sessionId) }
1040+ // Run the abort POST under NonCancellable so it survives this scope being
1041+ // cancelled. The "Stop and exit" path calls abort() then navigates back
1042+ // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1043+ // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1044+ // before the request even reaches the server) and the run keeps burning tokens
1045+ // server-side despite the user explicitly stopping it.
1046+ runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
10391047 .onSuccess { _running.value = false }
10401048 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
10411049 } finally {
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+10−3
@@ -261,8 +261,10 @@ internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
261261 // ````kotlin), not a fixed 3 chars, before reading the info string.
262262 val info = first.trimStart('`', '~').trim()
263263 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
264− // first whitespace-delimited token is the language.
265− return info.substringBefore(' ').takeIf { it.isNotBlank() }
264+ // first whitespace-delimited token is the language. Split on ANY whitespace (CommonMark
265+ // ends the language at the first whitespace char), not just a space, so a tab-delimited
266+ // info string ("```ts\ttitle=foo") still yields "ts" rather than the whole run.
267+ return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
266268}
267269
268270/**
@@ -280,7 +282,12 @@ internal fun extractCodeText(raw: String, isFenced: Boolean): String {
280282 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
281283 // displayed/copied code).
282284 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
283− if (lastNonBlank >= 0 && (body[lastNonBlank].startsWith("```") || body[lastNonBlank].startsWith("~~~"))) {
285+ // CommonMark allows the closing fence to be indented up to 3 spaces, and a fence nested inside
286+ // a list item keeps that indentation in the node's raw text — so trim leading whitespace before
287+ // the marker check. Without it, an indented closing ```/~~~ fails startsWith and leaks into the
288+ // displayed/copied code (the same leak a prior fix closed for the trailing-newline case).
289+ val closer = if (lastNonBlank >= 0) body[lastNonBlank].trimStart() else ""
290+ if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
284291 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
285292 }
286293 return body.joinToString("\n").trimEnd()
app/src/main/res/values-es/strings.xml+2
@@ -254,6 +254,8 @@
254254 <string name="notif_action_reply">Responder</string>
255255 <string name="notif_reply_hint">Envía un seguimiento…</string>
256256 <string name="notif_reply_sent">Enviado</string>
257+ <string name="notif_reply_failed_title">Respuesta no enviada</string>
258+ <string name="notif_reply_failed_text">No se pudo poner en cola tu seguimiento. Toca para abrir la sesión e inténtalo de nuevo.</string>
257259
258260 <!-- UX additions -->
259261 <string name="tool_input">Entrada</string>
app/src/main/res/values/strings.xml+2
@@ -254,6 +254,8 @@
254254 <string name="notif_action_reply">Reply</string>
255255 <string name="notif_reply_hint">Send a follow-up…</string>
256256 <string name="notif_reply_sent">Sent</string>
257+ <string name="notif_reply_failed_title">Reply not sent</string>
258+ <string name="notif_reply_failed_text">Your follow-up couldn\'t be queued. Tap to open the session and try again.</string>
257259
258260 <!-- UX additions -->
259261 <string name="tool_input">Input</string>