From f9f2bdc9a807bcd5ca7f3aae4459d756b3cf3c7e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:31:26 +0200 Subject: [PATCH] fix: survive a refused background service start A sticky restart of the timer or stopwatch service has no user action behind it, so Android 12+ can refuse its foreground start, and the crash repeated with every restart. The same could happen to the alarm ring after an inexact fire, after its state was written. Refused starts now stop the service, or post the ring screen's notification, instead of crashing. The ring service is stopped with stopService, which cannot throw from the background. --- .../alarm/android/ServiceRingCoordinator.kt | 18 ++++++++++++++++- .../clockula/alarm/ring/AlarmRingService.kt | 9 ++++++--- .../clockula/alarm/ring/RingNotifications.kt | 20 +++++++++++++++++++ .../stopwatch/service/StopwatchService.kt | 13 ++++++++++-- .../clockula/timer/service/TimerService.kt | 13 ++++++++++-- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt index 0b83ada..d2765a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt @@ -1,9 +1,13 @@ package de.jeanlucmakiola.clockula.alarm.android +import android.app.NotificationManager import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities import de.jeanlucmakiola.clockula.alarm.RingCoordinator import de.jeanlucmakiola.clockula.alarm.ring.AlarmRingService +import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications +import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,6 +22,7 @@ import javax.inject.Singleton @Singleton class ServiceRingCoordinator @Inject constructor( @param:ApplicationContext private val context: Context, + private val capabilities: AlarmCapabilities, ) : RingCoordinator { private val state = MutableStateFlow(null) @@ -26,11 +31,22 @@ class ServiceRingCoordinator @Inject constructor( override fun startRinging(alarmId: Long) { state.value = alarmId - AlarmRingService.start(context) + // Only an exact alarm exempts the start from Android 12's background + // limits. When the inexact fallback fired instead the start can be + // refused, and the ring screen is then the one thing left to show. + val started = runCatching { AlarmRingService.start(context) }.isSuccess + if (!started) { + context.getSystemService(NotificationManager::class.java).notify( + RingNotifications.RING_NOTIFICATION_ID, + RingNotifications.refused(context, RingPresentationPolicy.forCapabilities(capabilities.snapshot())), + ) + } } override fun stopRinging(alarmId: Long) { if (state.value == alarmId) state.value = null AlarmRingService.stop(context) + // The notification a refused start posted has no service to take it down. + context.getSystemService(NotificationManager::class.java).cancel(RingNotifications.RING_NOTIFICATION_ID) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt index a68beb5..828d27f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -205,10 +205,13 @@ class AlarmRingService : Service() { context.startForegroundService(Intent(context, AlarmRingService::class.java)) } + /** + * `stopService` rather than a stop command: a command is a service + * start, which throws from the background once the service is gone, + * and a dismiss must never fail halfway through its writes. + */ fun stop(context: Context) { - context.startService( - Intent(context, AlarmRingService::class.java).setAction(ACTION_STOP), - ) + context.stopService(Intent(context, AlarmRingService::class.java)) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt index 087d91c..dbe1bc9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt @@ -98,6 +98,26 @@ object RingNotifications { return builder.build() } + /** + * Posted in place of the service when Android refused to start it: the + * ring screen through the full-screen intent where that is allowed, and a + * heads-up on the alarm channel where it is not. Better a silent alarm on + * screen than none at all. + */ + fun refused(context: Context, presentation: RingPresentation): Notification { + val show = showPendingIntent(context) + val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.alarm_notification_title)) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(true) + .setContentIntent(show) + if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true) + return builder.build() + } + /** * The ringing notification, presented as [presentation] says the platform allows. * diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/service/StopwatchService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/service/StopwatchService.kt index 464c664..d2e3ac0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/service/StopwatchService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/service/StopwatchService.kt @@ -79,7 +79,16 @@ class StopwatchService : Service() { // Before any suspension point, and on the first path only: the platform // gives a foreground service a few seconds to post its notification. - if (!wentForeground) startForeground(StopwatchNotifications.preparing(this)) + if (!wentForeground) { + // A sticky restart after a process death has no user action behind + // it, and Android 12+ may refuse it a foreground start. The stored + // anchors keep the time, and the next resync brings the service back. + val started = runCatching { startForeground(StopwatchNotifications.preparing(this)) }.isSuccess + if (!started) { + stopSelf(startId) + return START_NOT_STICKY + } + } if (collector == null) { collector = scope.launch { @@ -117,7 +126,6 @@ class StopwatchService : Service() { } private fun startForeground(notification: Notification) { - wentForeground = true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // specialUse, and deliberately **not** systemExempted: that type's // justification is alarm functionality continuing in the background, @@ -131,6 +139,7 @@ class StopwatchService : Service() { } else { startForeground(StopwatchNotifications.STOPWATCH_NOTIFICATION_ID, notification) } + wentForeground = true } private fun stop() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index f4ec018..5010fbf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -136,7 +136,16 @@ class TimerService : Service() { // a database read is not something to spend them on — but a service that // is already foreground has a real notification up, and replacing it with // the placeholder on every verb is a visible flicker (D6). - if (!wentForeground) startForeground(TimerNotifications.preparing(this)) + if (!wentForeground) { + // A sticky restart after a process death has no user action behind + // it, and Android 12+ may refuse it a foreground start. The stored + // anchors keep the time, and the next resync brings the service back. + val started = runCatching { startForeground(TimerNotifications.preparing(this)) }.isSuccess + if (!started) { + stopSelf(startId) + return START_NOT_STICKY + } + } if (collector == null) { collector = scope.launch { @@ -295,7 +304,6 @@ class TimerService : Service() { } private fun startForeground(notification: Notification) { - wentForeground = true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // systemExempted, the same type and justification as the alarm's // ringing service: an app holding an exact-alarm permission, @@ -308,6 +316,7 @@ class TimerService : Service() { } else { startForeground(notificationId, notification) } + wentForeground = true } private fun acquireWakeLock() {