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.
This commit is contained in:
makiolaj committed 2026-10-21 17:31:26 +02:00
1 parent 7ca8d54b4a
commit f9f2bdc9a8
5 files changed
+65 -8

No files matched your search

@@ -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<Long?>(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)
}
}
@@ -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))
}
}
}
@@ -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.
*
@@ -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() {
@@ -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() {