fix(notification): defer shutdown until foreground promotion

This commit is contained in:
AlexDev_ committed 2026-09-06 14:24:41 +02:00
1 parent b9ff160ae4
commit 7223c60a97
4 files changed
+223 -9

No files matched your search

@@ -66,6 +66,7 @@ class ChatNotificationService : Service() {
internal const val PENDING_NOTIFICATION_ID = 2
private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle()
fun setAppForeground(foreground: Boolean) {
isAppInForeground.set(foreground)
@@ -80,7 +81,6 @@ class ChatNotificationService : Service() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
startForeground(NOTIFICATION_ID, buildForegroundNotification(getString(R.string.notif_waiting_replies)))
startEventCollection()
}
@@ -89,6 +89,7 @@ class ChatNotificationService : Service() {
serviceScope.launch {
HermesWsClient.events.collect { event ->
if (!isAppInForeground.get()) {
val generation = lifecycle.generation
launch {
delay(500)
if (!isAppInForeground.get()) {
@@ -111,8 +112,8 @@ class ChatNotificationService : Service() {
// HermesWsClient's own collector, so the
// service is not restarted on the next
// ON_STOP (issue #794).
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
// A delayed completion must not retire a newer turn/start.
if (!HermesWsClient.pendingReply) lifecycle.complete(generation)
}
is WsEvent.ClarifyRequest -> {
@@ -260,11 +261,23 @@ class ChatNotificationService : Service() {
intent: Intent?,
flags: Int,
startId: Int,
): Int = START_NOT_STICKY
): Int {
lifecycle.onStart(
owner = this,
promote = {
startForeground(NOTIFICATION_ID, buildForegroundNotification(getString(R.string.notif_waiting_replies)))
},
// Do not remove foreground status before Android accepts retirement:
// a newer start may already be queued, but not delivered to us yet.
stopLatest = { stopSelfResult(startId) },
)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
lifecycle.onDestroyed(this)
eventCollector?.cancel()
serviceScope.cancel()
super.onDestroy()
@@ -283,15 +296,19 @@ object NotificationHelper {
// when nothing is in flight.
if (!HermesWsClient.pendingReply) return
val intent = Intent(context, ChatNotificationService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
ChatNotificationService.lifecycle.start {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
fun stop(context: Context) {
context.stopService(Intent(context, ChatNotificationService::class.java))
// Rotation can resume the activity before the queued service is created.
// Direct stopService here crashes Android while foreground promotion is owed.
ChatNotificationService.lifecycle.stop()
}
fun setAppForeground(
@@ -0,0 +1,72 @@
package com.m57.hermescontrol.notification
/**
* Orders foreground-service requests and retirement (rotation/start-stop regression).
* A stop before creation is deferred until promotion. Android's stopSelfResult(startId)
* protects a delivered start from tearing down a newer start still queued by the system.
*/
internal class ForegroundServiceLifecycle {
private var requested = false
private var owner: Any? = null
private var stopCurrent: (() -> Boolean)? = null
var generation = 0L
@Synchronized get
private set
@Synchronized
fun start(request: () -> Unit) {
val wasRequested = requested
val previousGeneration = generation
generation++
requested = true
try {
request()
} catch (error: Throwable) {
requested = wasRequested
generation = previousGeneration
throw error
}
}
@Synchronized
fun stop() {
requested = false
retire()
}
@Synchronized
fun onStart(
owner: Any,
promote: () -> Unit,
stopLatest: () -> Boolean,
) {
// Fulfil each startForegroundService request before allowing any retirement.
promote()
this.owner = owner
stopCurrent = stopLatest
if (!requested) retire()
}
@Synchronized
fun complete(expectedGeneration: Long) {
if (generation != expectedGeneration) return
requested = false
retire()
}
@Synchronized
fun onDestroyed(owner: Any) {
if (this.owner === owner) {
this.owner = null
stopCurrent = null
}
}
private fun retire() {
if (stopCurrent?.invoke() == true) {
owner = null
stopCurrent = null
}
}
}