@@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat
1616import androidx.core.app.ServiceCompat
1717import soy.iko.opencode.MainActivity
1818import soy.iko.opencode.R
19+import java.security.MessageDigest
1920
2021/**
2122 * A foreground service kept alive while an opencode agent is actively running. Holding a
@@ -37,7 +38,12 @@ class RunForegroundService : Service() {
3738 // recreated service instance and leave the chronometer pinned to a previous run's start
3839 // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
3940 // field it's reset by construction on every new Service instance.
40− private var runStartMillis: Long = 0L
41+ //
42+ // @Volatile: onStartCommand/onDestroy write these on the main thread, but updateProgress
43+ // (reached via the companion start() from AppContainer.observeRunForegroundService, which
44+ // runs on Dispatchers.IO) reads them. Without @Volatile there's no happens-before edge,
45+ // so a 64-bit Long (runStartMillis) can be torn and the booleans/strings can be stale.
46+ @Volatile private var runStartMillis: Long = 0L
4147 // Whether *this* service instance has successfully called startForeground. Tracked so
4248 // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
4349 // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
@@ -45,14 +51,14 @@ class RunForegroundService : Service() {
4551 // foreground. After the first successful startForeground, progress updates refresh the
4652 // notification via NotificationManager.notify directly (see [updateProgress]), which
4753 // never throws the background-start exception.
48− private var isInForeground: Boolean = false
54+ @Volatile private var isInForeground: Boolean = false
4955 // The most recent session title/id/profile/progress handed to onStartCommand, retained so
5056 // [updateProgress] can rebuild the notification with a new progress string without a fresh
5157 // startForegroundService intent (which can throw when backgrounded).
52− private var lastTitle: String? = null
53− private var lastSessionId: String? = null
54− private var lastProfileId: String? = null
55− private var lastProgress: String? = null
58+ @Volatile private var lastTitle: String? = null
59+ @Volatile private var lastSessionId: String? = null
60+ @Volatile private var lastProfileId: String? = null
61+ @Volatile private var lastProgress: String? = null
5662
5763 @android.annotation.SuppressLint("MissingPermission")
5864 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -113,19 +119,33 @@ class RunForegroundService : Service() {
113119 * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
114120 * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
115121 * the app is backgrounded — even if the service is already in the foreground). Called directly
116− * by the companion [updateProgress] when a progress refresh is needed while the service is
117− * already running; falls back to [start] (which goes through startForegroundService) when the
118− * service isn't alive yet, so the first progress update still lands. */
122+ * by the companion [start] when a progress refresh is needed while the service is already
123+ * running; falls back to [start] (which goes through startForegroundService) when the
124+ * service isn't alive yet, so the first progress update still lands.
125+ *
126+ * Forwards the full set of run fields (title/sessionId/profileId/progress), not just the
127+ * progress string, so a late-resolved session title or a second run starting after the
128+ * first updates the notification text AND the Stop action's target session/profile.
129+ * Without this, the Stop action would keep targeting the originally-captured session even
130+ * after a profile switch (mirroring the SessionNotifications abort-routing guard). */
119131 @SuppressLint("MissingPermission")
120− private fun updateProgress(progress: String?) {
132+ private fun updateProgress(
133+ progress: String?,
134+ sessionTitle: String? = lastTitle,
135+ sessionId: String? = lastSessionId,
136+ profileId: String? = lastProfileId,
137+ ) {
121138 // Guard against the companion start() → instance race: start() reads @Volatile instance
122139 // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
123140 // can null instance and call stopForeground between the read and this notify(). isInForeground
124141 // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
125142 // and avoids touching NotificationManager during teardown.
126143 if (!isInForeground) return
144+ lastTitle = sessionTitle
145+ lastSessionId = sessionId
146+ lastProfileId = profileId
127147 lastProgress = progress
128− val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
148+ val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
129149 runCatching {
130150 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
131151 }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
@@ -181,8 +201,15 @@ class RunForegroundService : Service() {
181201 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
182202 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
183203 }
204+ // Derive the request code from a SHA-256 of the session id (truncated to 31 bits),
205+ // NOT String.hashCode(): two distinct session ids can share a 32-bit hashCode, and
206+ // with FLAG_UPDATE_CURRENT the second registration overwrites the first's extras —
207+ // so a hash collision between two concurrent runs would make tapping Stop on one
208+ // session's notification broadcast the OTHER session's id. Mirrors SessionNotifications.
209+ // notifId's collision-resistant derivation.
210+ val requestCode = stopRequestCode(sessionId)
184211 val stopPending = PendingIntent.getBroadcast(
185− this, sessionId.hashCode(), stopIntent,
212+ this, requestCode, stopIntent,
186213 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
187214 )
188215 builder.addAction(
@@ -263,6 +290,21 @@ class RunForegroundService : Service() {
263290 private const val TAG = "RunForegroundService"
264291 private const val NOTIF_ID = 1
265292 private const val NOTIF_TIMEOUT_ID = 2
293+
294+ /** Derive a stable, collision-resistant PendingIntent request code from a session id.
295+ * String.hashCode() can collide for distinct session ids; a SHA-256 truncated to 31
296+ * bits makes accidental collisions astronomically unlikely, so two concurrent runs'
297+ * Stop actions can't overwrite each other's PendingIntent extras. Mirrors
298+ * [SessionNotifications.notifId]'s derivation. */
299+ private fun stopRequestCode(sessionId: String): Int {
300+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
301+ .getOrNull()?.digest(sessionId.toByteArray()) ?: return sessionId.hashCode()
302+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
303+ ((digest[1].toInt() and 0xFF) shl 16) or
304+ ((digest[2].toInt() and 0xFF) shl 8) or
305+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
306+ return hash
307+ }
266308 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
267309 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
268310 // instead of a hardcoded constant. Lazily computed on first use from the app context
@@ -304,7 +346,11 @@ class RunForegroundService : Service() {
304346 // notify never throws, so progress refreshes always land while the service runs.
305347 val live = instance
306348 if (live != null) {
307− live.updateProgress(progress)
349+ // Forward the full set of run fields (not just progress) so a late-resolved
350+ // session title or a second run updates the notification text AND the Stop
351+ // action's target session/profile — otherwise the Stop action would keep
352+ // targeting the originally-captured session even after a profile switch.
353+ live.updateProgress(progress, sessionTitle, sessionId, profileId)
308354 return
309355 }
310356 // startForegroundService can throw ForegroundServiceStartNotAllowedException