git.iko.soy/opencode-android
on master help sign in
Fix wrong-server notification routing, MCP add-dialog data loss, ProGuard keep scope, and stale UI state bugs
commit9f5d1ba884fbe77acfa41553c6408f2e45b30bb7 authoriko <mail@iko.soy> dateSun 5 Jul 2026, 07:26 (3 months ago) parent2794fda
6 files changed+65−19
app/proguard-rules.pro+5−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−6 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+13−1 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+19−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5−1 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+12−5
app/proguard-rules.pro+5−1
@@ -14,7 +14,11 @@
1414 *** Companion;
1515}
1616# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17−-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
17+# Covers ALL @Serializable classes in the app — not just data.model — because several
18+# classes used for persistent storage live elsewhere (OutboxMessage/PersistedAttachment in
19+# data/repo, StoredProfile in ProfileStore, Backup* in BackupManager, RecentSession, …)
20+# and R8 stripping/renaming their fields would break on-disk deserialization in release.
21+-keep @kotlinx.serialization.Serializable class soy.iko.opencode.** { *; }
1822
1923# --- Ktor / OkHttp (HTTP client + SSE) ---
2024# Don't warn about missing optional dependencies; keep only the classes that are
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−6
@@ -1012,15 +1012,18 @@ open class AppContainer private constructor(
10121012 }
10131013
10141014 /** Abort the active run for [sessionId] from a notification Stop action. Routes to the
1015− * originating profile (or the active connection) so the abort reaches the server that
1016− * owns the session. Runs on the app scope so it survives the BroadcastReceiver's lifetime. */
1015+ * originating profile only — sending the abort to a different server would 404 (silently
1016+ * swallowed) and leave the original run stranded. A profile mismatch reports failure so the
1017+ * notification stays up and the user can switch back, mirroring
1018+ * [respondToPermissionFromNotification]'s routing guard. Runs on the app scope so it
1019+ * survives the BroadcastReceiver's lifetime. */
10171020 open fun abortRunFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
10181021 appScope.launch {
10191022 var success = false
10201023 try {
10211024 val conn = activeConnection.value?.takeIf {
10221025 originProfileId == null || it.profile.id == originProfileId
1023− } ?: activeConnection.value
1026+ }
10241027 if (conn != null) {
10251028 success = runCatchingCancellable { conn.repository.abort(sessionId) }.isSuccess
10261029 }
@@ -1034,8 +1037,10 @@ open class AppContainer private constructor(
10341037 * Retry the last user prompt for [sessionId] from an error notification's "Retry last"
10351038 * action. Fetches the session's messages, re-sends the most recent user prompt, and
10361039 * cancels the error notification on success. Routes to the originating profile's
1037− * connection (falling back to the active one) so the send reaches the server that owns
1038− * the session. A no-op (reported as false) when there's no connection or no prior prompt.
1040+ * connection only — sending the retry to a different server would 404 or start a fresh
1041+ * run on an unrelated session id. A profile mismatch reports failure so the notification
1042+ * stays up and the user can switch back, mirroring [respondToPermissionFromNotification]'s
1043+ * routing guard. A no-op (reported as false) when there's no connection or no prior prompt.
10391044 */
10401045 open fun retryLastFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
10411046 appScope.launch {
@@ -1043,7 +1048,7 @@ open class AppContainer private constructor(
10431048 try {
10441049 val conn = activeConnection.value?.takeIf {
10451050 originProfileId == null || it.profile.id == originProfileId
1046− } ?: activeConnection.value
1051+ }
10471052 if (conn != null) {
10481053 success = runCatchingCancellable {
10491054 val messages = conn.api.listMessages(sessionId)
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+13−1
@@ -6,6 +6,7 @@ import android.app.Service
66import android.content.Context
77import android.content.Intent
88import android.content.pm.ServiceInfo
9+import android.content.res.Configuration
910import android.os.Build
1011import android.os.IBinder
1112import android.util.Log
@@ -166,6 +167,15 @@ class RunForegroundService : Service() {
166167
167168 override fun onBind(intent: Intent?): IBinder? = null
168169
170+ // Reset the brand-color cache when the configuration (notably uiMode/night mode) changes
171+ // so the notification icon tint follows the app theme instead of staying pinned to whatever
172+ // the first build resolved. The service isn't recreated on a config change (it's a service,
173+ // not an Activity), so without this the cached color is stale until onDestroy.
174+ override fun onConfigurationChanged(newConfig: Configuration) {
175+ super.onConfigurationChanged(newConfig)
176+ brandColorCache = 0
177+ }
178+
169179 companion object {
170180 private const val TAG = "RunForegroundService"
171181 private const val NOTIF_ID = 1
@@ -176,7 +186,9 @@ class RunForegroundService : Service() {
176186 @Volatile private var runStartMillis: Long = 0L
177187 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
178188 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
179− // instead of a hardcoded constant. Lazily computed on first use from the app context.
189+ // instead of a hardcoded constant. Lazily computed on first use from the app context
190+ // and reset in onConfigurationChanged so a theme toggle mid-run doesn't pin the tint
191+ // to the old theme for the rest of the run.
180192 private var brandColorCache: Int = 0
181193 private fun brandColor(context: Context): Int {
182194 if (brandColorCache == 0) {
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+19−5
@@ -35,7 +35,14 @@ object SessionNotifications {
3535
3636 private const val NOTIF_ID_PREFIX = 4000
3737 private const val TAG = "SessionNotifications"
38− private const val UNREAD_BADGE_ID = NOTIF_ID_PREFIX + 9999
38+ // Fixed notification IDs use NEGATIVE space (mirroring the SUMMARY_*_ID constants below)
39+ // so they can never collide with the hash-derived per-session IDs returned by notifId(),
40+ // which are always positive (NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX)),
41+ // range [4000, 2147483646]). A collision would let a session-specific notification
42+ // overwrite or be canceled by the fixed one (~1 in 2 billion per session, but a real
43+ // ID-space flaw). Negative IDs are valid for NotificationManager.cancel/notify.
44+ private const val UNREAD_BADGE_ID = -10
45+ private const val CONNECTION_LOST_ID = -11
3946
4047 // Distinct namespaces so a session can have a completion, a permission, and an error
4148 // notification outstanding at once without their ids colliding.
@@ -395,7 +402,7 @@ object SessionNotifications {
395402 @SuppressLint("MissingPermission")
396403 fun postConnectionLost(context: Context, profileLabel: String, profileId: String?) {
397404 if (!canPost(context)) return
398− val notifId = NOTIF_ID_PREFIX + 7000
405+ val notifId = CONNECTION_LOST_ID
399406 // Tap reopens the app at the server list so the user can reconnect / fix credentials.
400407 val openIntent = Intent(context, MainActivity::class.java).apply {
401408 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -421,7 +428,7 @@ object SessionNotifications {
421428
422429 /** Cancel a previously-posted [postConnectionLost] notification (e.g. on reconnect). */
423430 fun cancelConnectionLost(context: Context) {
424− NotificationManagerCompat.from(context).cancel(NOTIF_ID_PREFIX + 7000)
431+ NotificationManagerCompat.from(context).cancel(CONNECTION_LOST_ID)
425432 }
426433
427434 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
@@ -463,9 +470,16 @@ object SessionNotifications {
463470 .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
464471 }
465472
466− /** Cancel a session's permission notification (on reply, or when the user opens it). */
473+ /** Cancel a session's permission notification (on reply, or when the user opens it). Also
474+ * cancels the auto-rejected-permission notification (posted under notifId + 1) so opening
475+ * the session by any route other than tapping that notification clears it too — without
476+ * this a stale "auto-rejected" heads-up lingers in the shade (setAutoCancel only dismisses
477+ * on a direct tap). */
467478 fun cancelPermission(context: Context, sessionId: String) {
468− NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
479+ val nm = NotificationManagerCompat.from(context)
480+ val baseId = notifId(NS_PERMISSION, sessionId)
481+ nm.cancel(baseId)
482+ nm.cancel(baseId + 1) // postPermissionAutoRejected uses baseId + 1
469483 maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
470484 }
471485
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5−1
@@ -445,7 +445,11 @@ private fun GoToLineLauncher(
445445
446446@Composable
447447private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
448− var text by rememberSaveable { mutableStateOf("") }
448+ // Use plain remember (not rememberSaveable) so each open starts with an empty field.
449+ // The dialog is conditionally composed (gated by `visible` in GoToLineLauncher); with
450+ // rememberSaveable the typed text would be restored from the saved-state registry on
451+ // re-open, pre-filling whatever the user typed — and dismissed without — last time.
452+ var text by remember { mutableStateOf("") }
449453 val parsed = text.trim().toIntOrNull()
450454 val valid = parsed != null && parsed in 1..maxLine
451455 AlertDialog(
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+12−5
@@ -84,6 +84,8 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
8484 val addFailedMsg = stringResource(R.string.mcp_add_failed)
8585 val notConnectedMsg = stringResource(R.string.not_connected)
8686
87+ var showAddDialog by rememberSaveable { mutableStateOf(false) }
88+
8789 LaunchedEffect(Unit) {
8890 vm.messages.collect { msg ->
8991 val text = when (msg) {
@@ -91,12 +93,16 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
9193 McpMessage.ADD_FAILED -> addFailedMsg
9294 McpMessage.NOT_CONNECTED -> notConnectedMsg
9395 }
96+ // Dismiss the Add dialog only on success (the reload makes the new server appear).
97+ // On failure keep it open with adding=false so the user can adjust and retry without
98+ // re-typing their input — the dialog's own spinner/disabled-state machinery was
99+ // designed for this; dismissing synchronously after the (async) POST would lose the
100+ // entered name/command/URL/env on every transient failure.
101+ if (msg == McpMessage.ADDED) showAddDialog = false
94102 snackbar.showSnackbar(text)
95103 }
96104 }
97105
98− var showAddDialog by rememberSaveable { mutableStateOf(false) }
99−
100106 Scaffold(
101107 topBar = {
102108 AppTopBar(
@@ -199,9 +205,10 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
199205 onDismiss = { if (!adding) showAddDialog = false },
200206 onAdd = { name, kind, target, env ->
201207 vm.addServer(name, kind, target, env)
202− // Close immediately; the snackbar reports the outcome and a successful add
203− // triggers a reload so the new server appears in the list.
204− showAddDialog = false
208+ // Don't dismiss synchronously: addServer launches a coroutine, so the POST is
209+ // still in flight. The dialog stays open showing the spinner (adding=true);
210+ // the messages collector above dismisses on ADDED, or re-enables the fields
211+ // on ADD_FAILED so the user can retry without re-typing.
205212 },
206213 )
207214 }