git.iko.soy/opencode-android
on master help sign in
Fix 11 bugs: TTS data race, notif goAsync ANR, share/unshare idempotency, PartTimeInfo, re-share nav, mDNS stale-entry, delete-reschedule race, network handoff offline, activeRuns eviction, notif permission re-prompt
commitca30f73217fb6c6e87a79a836e8365161fe33211 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 18:12 (3 months ago) parent49dff6c
10 files changed+159−63
app/src/main/java/soy/iko/opencode/MainActivity.kt+33−3 app/src/main/java/soy/iko/opencode/data/model/Common.kt+13 app/src/main/java/soy/iko/opencode/data/model/Part.kt+2−2 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+3−3 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt−2 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+12 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+21−6 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+62−45 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+9−1 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+4−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+33−3
@@ -38,6 +38,13 @@ class MainActivity : FragmentActivity() {
3838 private var shareIntentHandled = false
3939 private var openSessionHandled = false
4040 private var newSessionHandled = false
41+ // Track whether the notification-permission prompt has been shown for this Activity
42+ // instance. onCreate only requests when savedInstanceState == null (a fresh process),
43+ // but an Activity recreated by a config change NOT listed in the manifest's configChanges
44+ // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
45+ // prompt forever. onResume re-requests once per instance when still ungranted so the user
46+ // is never silently left without notifications after such a recreation.
47+ private var notificationPermissionRequested = false
4148
4249 override fun onCreate(savedInstanceState: Bundle?) {
4350 val splash = installSplashScreen()
@@ -50,7 +57,14 @@ class MainActivity : FragmentActivity() {
5057 }
5158 val container = (application as OpencodeApp).container
5259 handleIntent(intent)
53− if (savedInstanceState == null) maybeRequestNotificationPermission()
60+ if (savedInstanceState == null) {
61+ maybeRequestNotificationPermission()
62+ } else {
63+ // Restored instance: the permission may have been requested pre-recreation, but
64+ // we can't know the result from here. onResume will re-check and request if still
65+ // ungranted (once per Activity instance).
66+ notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
67+ }
5468 // Hold the splash until the persisted theme and app-lock settings load, so we never
5569 // paint a frame with the defaults (dynamicColor=true / SYSTEM, app lock off) that then
5670 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
@@ -112,6 +126,15 @@ class MainActivity : FragmentActivity() {
112126 (application as OpencodeApp).container.setForeground(false)
113127 }
114128
129+ override fun onResume() {
130+ super.onResume()
131+ // Re-check on resume: an Activity recreated by a config change not in configChanges
132+ // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
133+ // ensures the user is prompted once per Activity instance when still ungranted rather
134+ // than silently losing all notifications.
135+ maybeRequestNotificationPermission()
136+ }
137+
115138 /** Capture text shared from another app so it can be prefilled into a session draft,
116139 * and session ids from notification taps / deep links so we can open them. */
117140 private fun handleIntent(intent: Intent?) {
@@ -183,6 +206,7 @@ class MainActivity : FragmentActivity() {
183206 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
184207 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
185208 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
209+ private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
186210 }
187211
188212 override fun onSaveInstanceState(outState: Bundle) {
@@ -190,13 +214,19 @@ class MainActivity : FragmentActivity() {
190214 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
191215 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
192216 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
217+ outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
193218 }
194219
195− /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
220+ /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
221+ * Idempotent per Activity instance via [notificationPermissionRequested]. */
196222 private fun maybeRequestNotificationPermission() {
223+ if (notificationPermissionRequested) return
197224 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
198225 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
199226 PackageManager.PERMISSION_GRANTED
200− if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
227+ if (!granted) {
228+ notificationPermissionRequested = true
229+ requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
230+ }
201231 }
202232}
app/src/main/java/soy/iko/opencode/data/model/Common.kt+13
@@ -11,6 +11,19 @@ data class TimeInfo(
1111 val completed: Long? = null,
1212)
1313
14+/** Timing carried by message parts and tool states. The opencode server sends `time: { start,
15+ * end?, compacted? }` for these (unlike Session/MessageInfo/Permission, which use the
16+ * created/updated/completed shape of [TimeInfo]). With `ignoreUnknownKeys`, modelling these
17+ * fields with [TimeInfo] would silently discard start/end/compacted — every part/tool time
18+ * would decode to all-nulls — so this separate shape exists for them. */
19+@Immutable
20+@Serializable
21+data class PartTimeInfo(
22+ val start: Long? = null,
23+ val end: Long? = null,
24+ val compacted: Long? = null,
25+)
26+
1427@Immutable
1528@Serializable
1629data class Tokens(
app/src/main/java/soy/iko/opencode/data/model/Part.kt+2−2
@@ -32,7 +32,7 @@ data class TextPart(
3232 val text: String = "",
3333 val synthetic: Boolean = false,
3434 val ignored: Boolean = false,
35− val time: TimeInfo? = null,
35+ val time: PartTimeInfo? = null,
3636) : Part
3737
3838@Immutable
@@ -43,7 +43,7 @@ data class ReasoningPart(
4343 override val messageID: String? = null,
4444 override val sessionID: String? = null,
4545 val text: String = "",
46− val time: TimeInfo? = null,
46+ val time: PartTimeInfo? = null,
4747) : Part
4848
4949@Immutable
app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+3−3
@@ -25,7 +25,7 @@ data object ToolPending : ToolState
2525data class ToolRunning(
2626 val input: JsonElement? = null,
2727 val title: String? = null,
28− val time: TimeInfo? = null,
28+ val time: PartTimeInfo? = null,
2929) : ToolState
3030
3131@Immutable
@@ -35,7 +35,7 @@ data class ToolCompleted(
3535 val input: JsonElement? = null,
3636 val output: String? = null,
3737 val title: String? = null,
38− val time: TimeInfo? = null,
38+ val time: PartTimeInfo? = null,
3939) : ToolState
4040
4141@Immutable
@@ -44,7 +44,7 @@ data class ToolCompleted(
4444data class ToolError(
4545 val input: JsonElement? = null,
4646 val error: String? = null,
47− val time: TimeInfo? = null,
47+ val time: PartTimeInfo? = null,
4848) : ToolState
4949
5050/** Fallback for an unrecognized `status`. */
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt−2
@@ -153,8 +153,6 @@ object NetworkConfig {
153153
154154 /** Delay before retrying the message-activity observer after a failure. */
155155 const val observerRetryDelayMs = 5_000L
156− /** Max concurrent assistant runs to track for completion notifications. */
157− const val activeRunsLimit = 200
158156 /** Debounce before the run foreground-service is started/stopped in response to
159157 * run-activity changes. A run that starts and then fails/idles almost immediately
160158 * would otherwise dispatch startForegroundService() then stopService() back-to-back;
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+12
@@ -52,6 +52,12 @@ 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>())
5561 val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
5662
5763 val resolver = launch {
@@ -66,6 +72,9 @@ open class NsdDiscovery(context: Context?) {
6672 // so every later-discovered service queued behind it is never processed and the
6773 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
6874 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
6978 val name = resolved.serviceName.orEmpty()
7079 // Keep only opencode's own advertisements, not every _http._tcp service
7180 // (printers, routers, other web servers) on the network.
@@ -84,6 +93,9 @@ open class NsdDiscovery(context: Context?) {
8493 }
8594 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
8695 val name = serviceInfo.serviceName ?: return
96+ // Mark the name lost so the resolver skips it if it hasn't been processed yet.
97+ // If it's already in `found` (resolved before the lost), remove and emit now.
98+ lostPending.add(name)
8799 if (found.remove(name) != null) trySend(found.values.sortedBy { it.name })
88100 }
89101 override fun onDiscoveryStopped(serviceType: String?) {}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+21−6
@@ -223,14 +223,29 @@ open class OpencodeApiClient private constructor(
223223 client!!.post("session/${encode(sessionId)}/unrevert").body()
224224 }
225225
226− /** Create a public share link for the session. Returns the session with [Session.share] set. */
227− open suspend fun shareSession(sessionId: String): Session = withRetry {
228− client!!.post("session/${encode(sessionId)}/share").body()
226+ /** Create a public share link for the session. Returns the session with [Session.share] set.
227+ * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
228+ * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
229+ open suspend fun shareSession(sessionId: String): Session {
230+ val idempotencyKey = java.util.UUID.randomUUID().toString()
231+ return withRetry {
232+ client!!.post("session/${encode(sessionId)}/share") {
233+ header("Idempotency-Key", idempotencyKey)
234+ }.body()
235+ }
229236 }
230237
231− /** Revoke the session's public share link. Returns the session with share cleared. */
232− open suspend fun unshareSession(sessionId: String): Session = withRetry {
233− client!!.delete("session/${encode(sessionId)}/share").body()
238+ /** Revoke the session's public share link. Returns the session with share cleared.
239+ * Like [deleteSession], a retried DELETE (first reached the server but the response was
240+ * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
241+ * effectively success, so swallow 404 rather than surfacing a spurious failure. */
242+ open suspend fun unshareSession(sessionId: String): Session {
243+ return try {
244+ withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
245+ } catch (t: ClientRequestException) {
246+ if (t.response.status.value != 404) throw t
247+ client!!.get("session/${encode(sessionId)}").body()
248+ }
234249 }
235250
236251 /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+62−45
@@ -535,12 +535,9 @@ open class AppContainer private constructor(
535535 // represent a finished run worth notifying about.
536536 if (isLiveRunActivity(event)) {
537537 // The add-under-lock is required both for thread-safety and for the
538− // LRU access-order refresh (keeps a continuously-streaming session
539− // from being evicted), so it runs per event.
538+ // access-order refresh (keeps a continuously-streaming session from
539+ // being evicted), so it runs per event.
540540 synchronized(activeRuns) {
541− if (activeRuns.size >= activeRunsLimit) {
542− activeRuns.remove(activeRuns.iterator().next())
543− }
544541 activeRuns.add(sid)
545542 // Derive the flag from the set state *inside* the same lock that
546543 // guards the set, so a concurrent reconnect sweep (which clears
@@ -577,9 +574,11 @@ open class AppContainer private constructor(
577574 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
578575 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
579576 * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
580− * NOT reorder). This keeps a long, continuously-streaming session from being evicted as
581− * the eldest once [activeRunsLimit] other sessions appear — which would drop its
582− * completion notification when its SessionIdle finally arrives. */
577+ * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
578+ * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
579+ * its completion notification when its SessionIdle eventually arrives — a busy server
580+ * with many concurrent runs is still bounded by the server's own concurrency, so growth
581+ * is not unbounded in practice. */
583582 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
584583 java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
585584 )
@@ -589,9 +588,6 @@ open class AppContainer private constructor(
589588 * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
590589 private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
591590
592− /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
593− private val activeRunsLimit = NetworkConfig.activeRunsLimit
594−
595591 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
596592 private val connectionMutex = Mutex()
597593
@@ -650,14 +646,22 @@ open class AppContainer private constructor(
650646 onDone: (Boolean) -> Unit,
651647 ) {
652648 appScope.launch {
653− val success = runCatchingCancellable {
654− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
655− api.respondPermission(sessionId, permissionId, response)
656− true
657− }.onFailure {
658− Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
659− }.getOrDefault(false)
660− onDone(success)
649+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
650+ // scope is cancelled mid-call (process shutdown cancels appScope first). Without
651+ // this, a cancelled scope leaves pending.finish() never called → a receiver ANR
652+ // and the permission response silently lost.
653+ var success = false
654+ try {
655+ success = runCatchingCancellable {
656+ val api = activeConnection.value?.api ?: return@runCatchingCancellable false
657+ api.respondPermission(sessionId, permissionId, response)
658+ true
659+ }.onFailure {
660+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
661+ }.getOrDefault(false)
662+ } finally {
663+ onDone(success)
664+ }
661665 }
662666 }
663667
@@ -671,30 +675,36 @@ open class AppContainer private constructor(
671675 * notification on success. */
672676 open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
673677 appScope.launch {
674− // Attribute to the active (or most-recent) profile so a reconnect to a different
675− // server doesn't misfire this against a session id that only exists elsewhere.
676− val profileId = activeConnection.value?.profile?.id
677− ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
678− val enqueued = if (profileId == null) {
679− false
680− } else {
681− runCatchingCancellable {
682− outboxStore.enqueue(
683− OutboxMessage(
684− id = java.util.UUID.randomUUID().toString(),
685− profileId = profileId,
686− sessionId = sessionId,
687− text = text,
688− model = null,
689− createdAt = System.currentTimeMillis(),
690− ),
691− )
692− }.onFailure {
693− Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
694− }.isSuccess
678+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
679+ // scope is cancelled mid-call — see respondToPermissionFromNotification above.
680+ var enqueued = false
681+ try {
682+ // Attribute to the active (or most-recent) profile so a reconnect to a different
683+ // server doesn't misfire this against a session id that only exists elsewhere.
684+ val profileId = activeConnection.value?.profile?.id
685+ ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
686+ enqueued = if (profileId == null) {
687+ false
688+ } else {
689+ runCatchingCancellable {
690+ outboxStore.enqueue(
691+ OutboxMessage(
692+ id = java.util.UUID.randomUUID().toString(),
693+ profileId = profileId,
694+ sessionId = sessionId,
695+ text = text,
696+ model = null,
697+ createdAt = System.currentTimeMillis(),
698+ ),
699+ )
700+ }.onFailure {
701+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
702+ }.isSuccess
703+ }
704+ if (enqueued) flushOutbox()
705+ } finally {
706+ onDone(enqueued)
695707 }
696− if (enqueued) flushOutbox()
697− onDone(enqueued)
698708 }
699709 }
700710
@@ -750,7 +760,12 @@ open class AppContainer private constructor(
750760 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
751761 val callback = object : ConnectivityManager.NetworkCallback() {
752762 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
753− override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
763+ // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
764+ // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
765+ // window — eagerly marking offline would skip outbox flushing and flash an offline
766+ // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
767+ override fun onLost(network: Network) { /* handled by onUnavailable */ }
768+ override fun onUnavailable() { _isOnline.value = false }
754769 }
755770 // Only match networks that actually provide internet — a capability-less request
756771 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -854,7 +869,8 @@ open class AppContainer private constructor(
854869 .onSuccess { onDeleted() }
855870 .onFailure { onError(it) }
856871 }
857− pendingProfileDeletes.put(id, job)?.cancel()
872+ pendingProfileDeletes[id]?.cancel()
873+ pendingProfileDeletes[id] = job
858874 }
859875
860876 /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
@@ -917,7 +933,8 @@ open class AppContainer private constructor(
917933 .onFailure { onError(it) }
918934 }
919935 }
920− pendingSessionDeletes.put(id, job)?.cancel()
936+ pendingSessionDeletes[id]?.cancel()
937+ pendingSessionDeletes[id] = job
921938 }
922939
923940 /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+9−1
@@ -68,10 +68,18 @@ fun OpencodeApp(container: AppContainer) {
6868 // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
6969 // carrying no new share (a bare server switch, which routes through a null connection, or
7070 // a share the user already navigated for) leaves them where they are.
71+ //
72+ // Reset the remembered media when the pending list is drained (the chosen ChatScreen
73+ // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
74+ // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
75+ // "already handled", silently skipping the session-list navigation the second time.
7176 var handledShareText by remember { mutableStateOf<String?>(null) }
7277 var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
7378 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
74− if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
79+ if (pendingShare == null && pendingSharedMedia.isEmpty()) {
80+ handledSharedMedia = emptyList()
81+ return@LaunchedEffect
82+ }
7583 if (connection == null) return@LaunchedEffect
7684 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
7785 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+4−1
@@ -36,7 +36,10 @@ fun rememberTtsController(): TtsController {
3636class TtsController(context: Context) : RememberObserver {
3737
3838 private val mainHandler = Handler(Looper.getMainLooper())
39− private var ready = false
39+ // Written from the TextToSpeech init callback (a binder thread) and read from the main
40+ // thread in toggle(); @Volatile guarantees the main thread observes a successful init
41+ // promptly instead of a stale false, which would silently drop the read-aloud request.
42+ @Volatile private var ready = false
4043 private var shutDown = false
4144
4245 private val _speakingId = mutableStateOf<String?>(null)