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
da36afbiko
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+17
@@ -103,6 +103,23 @@ object HttpClientFactory {
103 }
103 }
104 }
104 }
105 }
105 }
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+ )
106 } else if (pinHost != null) {
123 } else if (pinHost != null) {
107 // For HTTP profiles with auth, attach the Basic header proactively (the reactive
124 // For HTTP profiles with auth, attach the Basic header proactively (the reactive
108 // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
125 // 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?) {
52 }
52 }
53 // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
53 // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
54 val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
54 val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
55− // Names reported lost before the serialized resolver reached them. Without this, a
55+ // Per-name epoch, bumped on every onServiceLost. Each queued resolve is stamped with the
56− // service that disappears between onServiceFound and the resolver dequeuing it would
56+ // epoch that was current when it was enqueued; the resolver discards its result if the
57− // be re-inserted into `found` after onServiceLost already fired (and returned null),
57+ // epoch has advanced since. This makes loss ordering authoritative: a resolve queued
58− // leaving a phantom entry that lingers until discovery stops — the lost callback never
58+ // BEFORE a loss — a stale duplicate onServiceFound (stock NsdManager can emit these on
59− // fires twice for the same name. The resolver consults this set and skips the insert.
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.
60− val lostPending = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
65+ val epoch = java.util.concurrent.ConcurrentHashMap<String, Int>()
61− val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
66+ val toResolve = Channel<Pair<NsdServiceInfo, Int>>(Channel.UNLIMITED)
62
67
63 val resolver = launch {
68 val resolver = launch {
64− for (info in toResolve) {
69+ for ((info, stampedEpoch) in toResolve) {
65 // Key the map by the name from discovery: onServiceFound/onServiceLost both use
70 // Key the map by the name from discovery: onServiceFound/onServiceLost both use
66 // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
71 // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
67 // keying by the resolved name would leave a departed server that onServiceLost
72 // keying by the resolved name would leave a departed server that onServiceLost
@@ -72,13 +77,9 @@ open class NsdDiscovery(context: Context?) {
72 // so every later-discovered service queued behind it is never processed and the
77 // so every later-discovered service queued behind it is never processed and the
73 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
78 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
74 val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
79 val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
75− // Consume any "lost while queued" marker for this name REGARDLESS of the resolve
80+ // Drop the result if the service was reported lost since this resolve was queued
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
81+ // (its epoch advanced), or if the resolve failed/timed out (the service is gone).
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
82+ if ((epoch[discoveredName] ?: 0) != stampedEpoch || resolved == null) continue
82 val name = resolved.serviceName.orEmpty()
83 val name = resolved.serviceName.orEmpty()
83 // Keep only opencode's own advertisements, not every _http._tcp service
84 // Keep only opencode's own advertisements, not every _http._tcp service
84 // (printers, routers, other web servers) on the network.
85 // (printers, routers, other web servers) on the network.
@@ -86,11 +87,11 @@ open class NsdDiscovery(context: Context?) {
86 @Suppress("DEPRECATION")
87 @Suppress("DEPRECATION")
87 val host = resolved.host?.hostAddress ?: continue
88 val host = resolved.host?.hostAddress ?: continue
88 found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
89 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+ // Close the check-then-insert race: an onServiceLost firing in the non-suspending
90− // window since the marker check above couldn't remove this name from `found` (nothing
91+ // window since the check above bumped the epoch but couldn't remove this name from
91− // was inserted yet), so it set a lostPending marker instead. Re-check and evict now,
92+ // `found` (nothing was inserted yet). Re-check and evict now — the epoch is
92− // else the just-departed service lingers as a phantom onServiceLost won't clear.
93+ // monotonic, so any loss that raced the insert is still visible here.
93− if (lostPending.remove(discoveredName)) found.remove(discoveredName)
94+ if ((epoch[discoveredName] ?: 0) != stampedEpoch) found.remove(discoveredName)
94 trySend(found.values.sortedBy { it.name })
95 trySend(found.values.sortedBy { it.name })
95 }
96 }
96 }
97 }
@@ -98,20 +99,19 @@ open class NsdDiscovery(context: Context?) {
98 val listener = object : NsdManager.DiscoveryListener {
99 val listener = object : NsdManager.DiscoveryListener {
99 override fun onDiscoveryStarted(serviceType: String?) {}
100 override fun onDiscoveryStarted(serviceType: String?) {}
100 override fun onServiceFound(serviceInfo: NsdServiceInfo) {
101 override fun onServiceFound(serviceInfo: NsdServiceInfo) {
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()
101− toResolve.trySend(serviceInfo)
105+ toResolve.trySend(serviceInfo to (epoch[name] ?: 0))
102 }
106 }
103 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
107 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
104 val name = serviceInfo.serviceName ?: return
108 val name = serviceInfo.serviceName ?: return
105− // If it was already resolved and in `found`, remove and emit now — and do NOT set a
109+ // Advance the epoch so any resolve queued before now (including a duplicate
106− // lostPending marker: the resolver already processed this name, so the marker would
110+ // onServiceFound whose result hasn't landed yet) is discarded and can't resurrect
107− // never be consumed and would instead suppress the service's NEXT re-appearance (a
111+ // this service, then drop it from the emitted list if it was already resolved.
108− // restarted server re-discovered later would hit `lostPending.remove` and be
112+ epoch.merge(name, 1) { old, _ -> old + 1 }
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.
111 if (found.remove(name) != null) {
113 if (found.remove(name) != null) {
112 trySend(found.values.sortedBy { it.name })
114 trySend(found.values.sortedBy { it.name })
113− } else {
114− lostPending.add(name)
115 }
115 }
116 }
116 }
117 override fun onDiscoveryStopped(serviceType: String?) {}
117 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 {
427 val ordered = m.parts.map { p ->
427 val ordered = m.parts.map { p ->
428 if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
428 if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
429 }.toMutableList()
429 }.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.
430 for (p in existing.parts.values) {
440 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)
432 }
442 }
433 // Adopt the authoritative REST info unless the in-memory info was itself
443 // Adopt the authoritative REST info unless the in-memory info was itself
434 // updated live since the pivot (i.e. during this fetch), in which case it's
444 // updated live since the pivot (i.e. during this fetch), in which case it's
@@ -532,13 +542,15 @@ internal class MessageStore {
532 val info = event.properties.info
542 val info = event.properties.info
533 if (info.sessionID != sessionId) return false
543 if (info.sessionID != sessionId) return false
534 // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
544 // 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
545+ // field) would collide with other such messages in the map. It carries no id, only
536− // the message's own content (it carries no id, only sessionID/time) so each distinct
546+ // sessionID/time, so derive a synthetic key. Prefer the immutable creation timestamp: it's
537− // unknown message gets its own entry, while a byte-identical re-send (a redundant event or
547+ // stable across updates, so a later update to the SAME unknown message (whose `time.updated`
538− // a post-reconnect re-seed) coalesces via the existing==info guard below instead of piling
548+ // or `time.completed` changes) coalesces onto its holder via the existing==info guard below
539− // up duplicate holders — which a fresh per-event counter caused (existing was always null).
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.
540 val key = if (info.id.isEmpty() && info is UnknownMessage) {
552 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()}"
542 } else {
554 } else {
543 info.id
555 info.id
544 }
556 }
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+9−3
@@ -70,9 +70,15 @@ class NotificationActionReceiver : BroadcastReceiver() {
70 watchdog(finishOnce)
70 watchdog(finishOnce)
71 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
71 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
72 // The reply is durably queued (and flushed now if online); clear the
72 // 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
73+ // "session ready" notification. If the enqueue failed, cancel it anyway and
74− // reply isn't silently lost and the user can retry.
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) {
75− if (enqueued) SessionNotifications.cancel(context, sessionId)
78+ SessionNotifications.cancel(context, sessionId)
79+ } else {
80+ SessionNotifications.postReplyFailed(context, sessionId)
81+ }
76 finishOnce()
82 finishOnce()
77 }
83 }
78 }
84 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+22
@@ -205,6 +205,28 @@ object SessionNotifications {
205 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
205 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
206 }
206 }
207
207
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+
208 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
230 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
209 private fun mutableFlag(): Int =
231 private fun mutableFlag(): Int =
210 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
232 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(
1224 capitalization = KeyboardCapitalization.Sentences,
1224 capitalization = KeyboardCapitalization.Sentences,
1225 ),
1225 ),
1226 keyboardActions = KeyboardActions(onSend = {
1226 keyboardActions = KeyboardActions(onSend = {
1227− if (enabled && hasContent) {
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) {
1228− if (running) onQueueFollowUp(value) else onSend()
1233+ if (value.isNotBlank()) onQueueFollowUp(value)
1234+ } else if (hasContent) {
1235+ onSend()
1236+ }
1229 }
1237 }
1230 }),
1238 }),
1231 )
1239 )
@@ -1233,6 +1241,7 @@ private fun ChatInputBar(
1233 running = running,
1241 running = running,
1234 aborting = aborting,
1242 aborting = aborting,
1235 canSend = enabled && hasContent,
1243 canSend = enabled && hasContent,
1244+ canQueue = enabled && value.isNotBlank(),
1236 onSend = onSend,
1245 onSend = onSend,
1237 onAbort = onAbort,
1246 onAbort = onAbort,
1238 onQueue = { onQueueFollowUp(value) },
1247 onQueue = { onQueueFollowUp(value) },
@@ -1264,6 +1273,7 @@ private fun ComposerTrailingButton(
1264 running: Boolean,
1273 running: Boolean,
1265 aborting: Boolean,
1274 aborting: Boolean,
1266 canSend: Boolean,
1275 canSend: Boolean,
1276+ canQueue: Boolean,
1267 onSend: () -> Unit,
1277 onSend: () -> Unit,
1268 onAbort: () -> Unit,
1278 onAbort: () -> Unit,
1269 onQueue: () -> Unit,
1279 onQueue: () -> Unit,
@@ -1274,9 +1284,12 @@ private fun ComposerTrailingButton(
1274 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
1284 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
1275 // That leaves a touch-only user with the setting off no way to queue a follow-up
1285 // That leaves a touch-only user with the setting off no way to queue a follow-up
1276 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
1286 // (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
1287+ // there's typed TEXT so queuing is always reachable, without changing the user's
1278− // Enter-inserts-a-newline preference.
1288+ // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
1279− if (canSend) {
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) {
1280 IconButton(
1293 IconButton(
1281 onClick = onQueue,
1294 onClick = onQueue,
1282 modifier = Modifier.testTag("queue_button"),
1295 modifier = Modifier.testTag("queue_button"),
@@ -1593,13 +1606,20 @@ private fun buildMessageListItems(
1593 var lastDayKey: String? = null
1606 var lastDayKey: String? = null
1594 var sepOrdinal = 0
1607 var sepOrdinal = 0
1595 var emptyIdOrdinal = 0
1608 var emptyIdOrdinal = 0
1609+ var first = true
1596 for (message in messages) {
1610 for (message in messages) {
1597 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1611 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1598 val dayKey = ts?.let { dayKey(it) } ?: ""
1612 val dayKey = ts?.let { dayKey(it) } ?: ""
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.
1599− if (dayKey != lastDayKey) {
1618+ if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
1600 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1619 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1601− lastDayKey = dayKey
1602 }
1620 }
1621+ if (dayKey.isNotEmpty()) lastDayKey = dayKey
1622+ first = false
1603 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
1623 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
1604 // empty id — and the reducer intentionally keeps several such messages as distinct
1624 // empty id — and the reducer intentionally keeps several such messages as distinct
1605 // holders — so give each a stable synthetic key and defensively de-collide any remaining
1625 // 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
54import kotlinx.coroutines.flow.update
54import kotlinx.coroutines.flow.update
55import kotlinx.coroutines.Dispatchers
55import kotlinx.coroutines.Dispatchers
56import kotlinx.coroutines.Job
56import kotlinx.coroutines.Job
57+import kotlinx.coroutines.NonCancellable
57import kotlinx.coroutines.delay
58import kotlinx.coroutines.delay
58import kotlinx.coroutines.isActive
59import kotlinx.coroutines.isActive
59import kotlinx.coroutines.currentCoroutineContext
60import kotlinx.coroutines.currentCoroutineContext
60import kotlinx.coroutines.launch
61import kotlinx.coroutines.launch
62+import kotlinx.coroutines.withContext
61
63
62/**
64/**
63 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
65 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
@@ -1035,7 +1037,13 @@ class ChatViewModel(
1035 if (!_aborting.compareAndSet(false, true)) return
1037 if (!_aborting.compareAndSet(false, true)) return
1036 viewModelScope.launch {
1038 viewModelScope.launch {
1037 try {
1039 try {
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.
1038− runCatchingCancellable { conn.repository.abort(sessionId) }
1046+ runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1039 .onSuccess { _running.value = false }
1047 .onSuccess { _running.value = false }
1040 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1048 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1041 } finally {
1049 } 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? {
261 // ````kotlin), not a fixed 3 chars, before reading the info string.
261 // ````kotlin), not a fixed 3 chars, before reading the info string.
262 val info = first.trimStart('`', '~').trim()
262 val info = first.trimStart('`', '~').trim()
263 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
263 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
264− // first whitespace-delimited token is the language.
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.
265− return info.substringBefore(' ').takeIf { it.isNotBlank() }
267+ return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
266}
268}
267
269
268/**
270/**
@@ -280,7 +282,12 @@ internal fun extractCodeText(raw: String, isFenced: Boolean): String {
280 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
282 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
281 // displayed/copied code).
283 // displayed/copied code).
282 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
284 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
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 ""
283− if (lastNonBlank >= 0 && (body[lastNonBlank].startsWith("```") || body[lastNonBlank].startsWith("~~~"))) {
290+ if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
284 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
291 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
285 }
292 }
286 return body.joinToString("\n").trimEnd()
293 return body.joinToString("\n").trimEnd()
app/src/main/res/values-es/strings.xml+2
@@ -254,6 +254,8 @@
254 <string name="notif_action_reply">Responder</string>
254 <string name="notif_action_reply">Responder</string>
255 <string name="notif_reply_hint">Envía un seguimiento…</string>
255 <string name="notif_reply_hint">Envía un seguimiento…</string>
256 <string name="notif_reply_sent">Enviado</string>
256 <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>
257
259
258 <!-- UX additions -->
260 <!-- UX additions -->
259 <string name="tool_input">Entrada</string>
261 <string name="tool_input">Entrada</string>
app/src/main/res/values/strings.xml+2
@@ -254,6 +254,8 @@
254 <string name="notif_action_reply">Reply</string>
254 <string name="notif_action_reply">Reply</string>
255 <string name="notif_reply_hint">Send a follow-up…</string>
255 <string name="notif_reply_hint">Send a follow-up…</string>
256 <string name="notif_reply_sent">Sent</string>
256 <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>
257
259
258 <!-- UX additions -->
260 <!-- UX additions -->
259 <string name="tool_input">Input</string>
261 <string name="tool_input">Input</string>