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:
1 parent
7ca8d54b4a
commit
f9f2bdc9a8
5 files changed
+65
-8
No files matched your search
+17
-1
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user