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() {