@@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat
16import androidx.core.app.ServiceCompat
16import androidx.core.app.ServiceCompat
17import soy.iko.opencode.MainActivity
17import soy.iko.opencode.MainActivity
18import soy.iko.opencode.R
18import soy.iko.opencode.R
19+import java.security.MessageDigest
21 * A foreground service kept alive while an opencode agent is actively running. Holding a
22 * A foreground service kept alive while an opencode agent is actively running. Holding a
@@ -37,7 +38,12 @@ class RunForegroundService : Service() {
37 // recreated service instance and leave the chronometer pinned to a previous run's start
38 // recreated service instance and leave the chronometer pinned to a previous run's start
38 // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
39 // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
39 // field it's reset by construction on every new Service instance.
40 // field it's reset by construction on every new Service instance.
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.
40− private var runStartMillis: Long = 0L
46+ @Volatile private var runStartMillis: Long = 0L
41 // Whether *this* service instance has successfully called startForeground. Tracked so
47 // Whether *this* service instance has successfully called startForeground. Tracked so
42 // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
48 // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
43 // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
49 // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
@@ -45,14 +51,14 @@ class RunForegroundService : Service() {
45 // foreground. After the first successful startForeground, progress updates refresh the
51 // foreground. After the first successful startForeground, progress updates refresh the
46 // notification via NotificationManager.notify directly (see [updateProgress]), which
52 // notification via NotificationManager.notify directly (see [updateProgress]), which
47 // never throws the background-start exception.
53 // never throws the background-start exception.
48− private var isInForeground: Boolean = false
54+ @Volatile private var isInForeground: Boolean = false
49 // The most recent session title/id/profile/progress handed to onStartCommand, retained so
55 // The most recent session title/id/profile/progress handed to onStartCommand, retained so
50 // [updateProgress] can rebuild the notification with a new progress string without a fresh
56 // [updateProgress] can rebuild the notification with a new progress string without a fresh
51 // startForegroundService intent (which can throw when backgrounded).
57 // startForegroundService intent (which can throw when backgrounded).
52− private var lastTitle: String? = null
58+ @Volatile private var lastTitle: String? = null
53− private var lastSessionId: String? = null
59+ @Volatile private var lastSessionId: String? = null
54− private var lastProfileId: String? = null
60+ @Volatile private var lastProfileId: String? = null
55− private var lastProgress: String? = null
61+ @Volatile private var lastProgress: String? = null
57 @android.annotation.SuppressLint("MissingPermission")
63 @android.annotation.SuppressLint("MissingPermission")
58 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
64 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -113,19 +119,33 @@ class RunForegroundService : Service() {
113 * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
119 * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
114 * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
120 * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
115 * the app is backgrounded — even if the service is already in the foreground). Called directly
121 * 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
122+ * by the companion [start] when a progress refresh is needed while the service is already
117− * already running; falls back to [start] (which goes through startForegroundService) when the
123+ * running; falls back to [start] (which goes through startForegroundService) when the
118− * service isn't alive yet, so the first progress update still lands. */
124+ * service isn't alive yet, so the first progress update still lands.
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). */
119 @SuppressLint("MissingPermission")
131 @SuppressLint("MissingPermission")
120− private fun updateProgress(progress: String?) {
132+ private fun updateProgress(
134+ sessionTitle: String? = lastTitle,
135+ sessionId: String? = lastSessionId,
136+ profileId: String? = lastProfileId,
121 // Guard against the companion start() → instance race: start() reads @Volatile instance
138 // Guard against the companion start() → instance race: start() reads @Volatile instance
122 // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
139 // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
123 // can null instance and call stopForeground between the read and this notify(). isInForeground
140 // can null instance and call stopForeground between the read and this notify(). isInForeground
124 // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
141 // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
125 // and avoids touching NotificationManager during teardown.
142 // and avoids touching NotificationManager during teardown.
126 if (!isInForeground) return
143 if (!isInForeground) return
144+ lastTitle = sessionTitle
145+ lastSessionId = sessionId
146+ lastProfileId = profileId
127 lastProgress = progress
147 lastProgress = progress
128− val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
148+ val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
129 runCatching {
149 runCatching {
130 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
150 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
131 }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
151 }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
@@ -181,8 +201,15 @@ class RunForegroundService : Service() {
181 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
201 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
182 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
202 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
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)
184 val stopPending = PendingIntent.getBroadcast(
211 val stopPending = PendingIntent.getBroadcast(
185− this, sessionId.hashCode(), stopIntent,
212+ this, requestCode, stopIntent,
186 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
213 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
188 builder.addAction(
215 builder.addAction(
@@ -263,6 +290,21 @@ class RunForegroundService : Service() {
263 private const val TAG = "RunForegroundService"
290 private const val TAG = "RunForegroundService"
264 private const val NOTIF_ID = 1
291 private const val NOTIF_ID = 1
265 private const val NOTIF_TIMEOUT_ID = 2
292 private const val NOTIF_TIMEOUT_ID = 2
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)
266 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
308 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
267 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
309 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
268 // instead of a hardcoded constant. Lazily computed on first use from the app context
310 // instead of a hardcoded constant. Lazily computed on first use from the app context
@@ -304,7 +346,11 @@ class RunForegroundService : Service() {
304 // notify never throws, so progress refreshes always land while the service runs.
346 // notify never throws, so progress refreshes always land while the service runs.
305 val live = instance
347 val live = instance
306 if (live != null) {
348 if (live != null) {
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.
307− live.updateProgress(progress)
353+ live.updateProgress(progress, sessionTitle, sessionId, profileId)
310 // startForegroundService can throw ForegroundServiceStartNotAllowedException
356 // startForegroundService can throw ForegroundServiceStartNotAllowedException