git.iko.soy/opencode-android
on master help sign in
Fix 9 bugs: backup cross-version import, mDNS marker leak, idempotency reuse, follow-up duplicate, unknown message coalesce, file refresh blank, markdown fence leak, provider default selection, code text extraction
commit4459e940e3a13f3f00aede22cbdcfab317645fd6 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 03:02 (3 months ago) parent807ef77
7 files changed+75−26
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+5−1 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+13−4 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+11−6 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+6−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−4 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+8−4 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+5−1
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+5−1
@@ -73,7 +73,11 @@ fun ProvidersResponse.toOptions(): List<ModelOption> =
7373
7474/** The default option to preselect: the server's default for some provider, else the first model. */
7575fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
76− default.entries.firstOrNull()?.let { (providerID, modelID) ->
76+ // Try each configured provider default in turn and take the first whose model actually
77+ // survives in `options` (the list may be filtered). Checking only the first `default` entry
78+ // would mispreselect an arbitrary first option whenever that one provider's default model
79+ // isn't present, ignoring the other configured defaults.
80+ for ((providerID, modelID) in default) {
7781 options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
7882 }
7983 return options.firstOrNull()
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+13−4
@@ -71,10 +71,14 @@ open class NsdDiscovery(context: Context?) {
7171 // timeout, one stuck resolve would hang this single serialized resolver forever,
7272 // so every later-discovered service queued behind it is never processed and the
7373 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
74− val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) } ?: continue
75− // The service was reported lost while queued behind other resolves: don't
76− // resurrect it. Clear the marker so the name is free for a future re-appearance.
77− if (lostPending.remove(discoveredName)) continue
74+ 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
7882 val name = resolved.serviceName.orEmpty()
7983 // Keep only opencode's own advertisements, not every _http._tcp service
8084 // (printers, routers, other web servers) on the network.
@@ -82,6 +86,11 @@ open class NsdDiscovery(context: Context?) {
8286 @Suppress("DEPRECATION")
8387 val host = resolved.host?.hostAddress ?: continue
8488 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)
8594 trySend(found.values.sortedBy { it.name })
8695 }
8796 }
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+11−6
@@ -22,12 +22,17 @@ data class BackupServer(
2222/** App preferences in a backup. */
2323@Serializable
2424data class BackupSettings(
25− val themeMode: String,
26− val dynamicColor: Boolean,
27− val sendOnEnter: Boolean,
28− val appLock: Boolean,
29− val chatTextScale: Float,
30− val codeWrap: Boolean,
25+ // Every field is defaulted (to SettingsStore's own defaults) so a backup written by an older
26+ // app version — one whose settings block predates a field added later — still decodes. Without
27+ // defaults a missing field throws MissingFieldException in import() BEFORE the server-restore
28+ // loop, aborting the ENTIRE restore (servers/pins/archives included) despite the best-effort
29+ // contract. ignoreUnknownKeys only tolerates EXTRA keys, not missing required ones.
30+ val themeMode: String = ThemeMode.SYSTEM.name,
31+ val dynamicColor: Boolean = true,
32+ val sendOnEnter: Boolean = true,
33+ val appLock: Boolean = false,
34+ val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
35+ val codeWrap: Boolean = false,
3136)
3237
3338/** The full backup document. [version] guards against future format changes. */
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+6−6
@@ -387,9 +387,6 @@ internal class MessageStore {
387387 messageInfoUpdatedSincePivot.clear()
388388 }
389389
390− /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
391− private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
392−
393390 /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
394391 internal val maxMessages = NetworkConfig.maxInMemoryMessages
395392
@@ -535,10 +532,13 @@ internal class MessageStore {
535532 val info = event.properties.info
536533 if (info.sessionID != sessionId) return false
537534 // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
538− // field) would collide with other such messages in the map. Generate a unique synthetic
539− // key so each unknown message gets its own entry instead of overwriting others.
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).
540540 val key = if (info.id.isEmpty() && info is UnknownMessage) {
541− "unknown-${unknownCounter.incrementAndGet()}"
541+ "unknown-${info.hashCode()}"
542542 } else {
543543 info.id
544544 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−4
@@ -775,11 +775,21 @@ class ChatViewModel(
775775 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
776776 // indicator lit (and a later SessionIdle re-closes it).
777777 runEndedByIdle = false
778+ // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
779+ // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
780+ // re-send stays deduplicated server-side instead of starting a duplicate agent run.
781+ // Capture the match BEFORE clearing _failedDraft just below.
782+ val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
783+ failedIdempotencyKey
784+ } else {
785+ null
786+ }
778787 _failedDraft.value = null
779788 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
780− // while retryFailed() re-submits with the key it stashed on the original failure — so a
781− // retry after a lost response is deduplicated server-side (no duplicate agent run).
782− val key = idempotencyKey ?: java.util.UUID.randomUUID().toString()
789+ // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
790+ // key it stashed on the original failure — so a retry after a lost response is deduplicated
791+ // server-side (no duplicate agent run).
792+ val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
783793 // Clear the staged attachments optimistically so the composer empties immediately;
784794 // restore them if the send fails (mirrors the draft handling below).
785795 if (attachments.isNotEmpty()) _attachments.value = emptyList()
@@ -822,6 +832,9 @@ class ChatViewModel(
822832 _running.value = false
823833 }.onSuccess {
824834 suppressDraftPersist.set(false)
835+ // This attempt reached the server, so the stashed retry key is spent — drop it so a
836+ // later unrelated send can't reuse it (retryFailed clears it on success too).
837+ failedIdempotencyKey = null
825838 // Send succeeded — the attachments rode along, so clear their persisted copy
826839 // (or keep any the user staged after the optimistic clear).
827840 persistAttachments()
@@ -946,7 +959,17 @@ class ChatViewModel(
946959 // The text now lives in the queued chip, so clear the input field just as a
947960 // normal send would. No-op for the cancel case (blank text), which must leave
948961 // whatever the user has since typed untouched.
949− if (trimmed.isNotEmpty()) _draft.value = ""
962+ if (trimmed.isNotEmpty()) {
963+ _draft.value = ""
964+ // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
965+ // persisted synchronously above, so if the process is killed before the debounced
966+ // draft-persist fires, the same text would remain under BOTH the draft and follow-up
967+ // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
968+ // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
969+ // this store change as our own echo and doesn't write the cleared value back.
970+ lastPersistedDraft = ""
971+ container.draftStore.flushDraft(sessionId, "")
972+ }
950973 }
951974
952975 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+8−4
@@ -274,10 +274,14 @@ internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
274274 */
275275internal fun extractCodeText(raw: String, isFenced: Boolean): String {
276276 if (!isFenced) return raw.trimIndent()
277− val lines = raw.lines()
278− val body = lines.drop(1).toMutableList()
279− if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
280− body.removeAt(body.lastIndex)
277+ val body = raw.lines().drop(1).toMutableList()
278+ // Strip the closing fence. getTextInNode can include a trailing newline, making the true last
279+ // line "" — so locate the last NON-blank line and drop from there, rather than only checking
280+ // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
281+ // displayed/copied code).
282+ val lastNonBlank = body.indexOfLast { it.isNotBlank() }
283+ if (lastNonBlank >= 0 && (body[lastNonBlank].startsWith("```") || body[lastNonBlank].startsWith("~~~"))) {
284+ while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
281285 }
282286 return body.joinToString("\n").trimEnd()
283287}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+5−1
@@ -147,7 +147,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
147147 textResults = emptyList(),
148148 symbolResults = emptyList(),
149149 searching = false,
150− loading = true,
150+ // Keep the current listing visible during a same-directory refresh (pull-to-refresh)
151+ // instead of blanking it behind a full-screen spinner; only show the spinner when
152+ // there's nothing to display yet or we're navigating to a DIFFERENT path (where the
153+ // existing entries belong to the old directory and shouldn't linger).
154+ loading = it.entries.isEmpty() || it.path != path,
151155 error = null,
152156 ) }
153157 openJob?.cancel()