fix: bring back a swiped-away ring notification

From Android 14 a foreground service's notification can be swiped away on
an unlocked phone, which left a sounding alarm with no controls. The ring
notification now reposts itself when it is deleted, and tapping a ringing
alarm in the list opens its ring screen.
This commit is contained in:
makiolaj committed 2026-10-21 17:28:08 +02:00
1 parent 9cd40d6813
commit c99106321a
4 files changed
+53 -3

No files matched your search

@@ -25,6 +25,7 @@ object AlarmIntents {
const val REQUEST_SHOW: Int = 3
const val REQUEST_SNOOZE: Int = 4
const val REQUEST_DISMISS: Int = 5
const val REQUEST_REPOST: Int = 6
val ALL_REQUEST_CODES: List<Int> = listOf(
REQUEST_FIRE,
@@ -32,5 +33,6 @@ object AlarmIntents {
REQUEST_SHOW,
REQUEST_SNOOZE,
REQUEST_DISMISS,
REQUEST_REPOST,
)
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -51,6 +53,9 @@ class AlarmRingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var ringing: Job? = null
/** The notification last posted, put back as it was when the user swipes it away. */
private var posted: Notification? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -58,6 +63,16 @@ class AlarmRingService : Service() {
stop()
return START_NOT_STICKY
}
if (intent?.action == ACTION_REPOST) {
// The ring is untouched; only its notification comes back. A
// service the system recreated has nothing to repost yet, so it
// starts the ring as any other start would.
posted?.let {
getSystemService(NotificationManager::class.java)
.notify(RingNotifications.RING_NOTIFICATION_ID, it)
return START_REDELIVER_INTENT
}
}
// The whole ring is released, not just the coroutine: cancelling the job
// alone would orphan a MediaPlayer the previous start had already
@@ -115,7 +130,7 @@ class AlarmRingService : Service() {
* screen.
*/
private fun startForegroundPreparing() {
startForeground(RingNotifications.preparing(this, presentation()))
startForeground(RingNotifications.preparing(this, presentation(), repostPendingIntent()))
}
private fun startForeground(session: RingSession) {
@@ -124,13 +139,22 @@ class AlarmRingService : Service() {
context = this,
session = session,
presentation = presentation(),
repost = repostPendingIntent(),
),
)
}
private fun presentation() = RingPresentationPolicy.forCapabilities(capabilities.snapshot())
private fun repostPendingIntent(): PendingIntent = PendingIntent.getService(
this,
AlarmIntents.REQUEST_REPOST,
Intent(this, AlarmRingService::class.java).setAction(ACTION_REPOST),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun startForeground(notification: Notification) {
posted = notification
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted: Android's own guidance names an app holding an
// exact-alarm permission and continuing an alarm in the background.
@@ -164,12 +188,14 @@ class AlarmRingService : Service() {
private fun stop() {
releaseRing()
posted = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
companion object {
private const val ACTION_STOP = AlarmIntents.ACTION_DISMISS + ".SERVICE_STOP"
private const val ACTION_REPOST = AlarmIntents.ACTION_DISMISS + ".SERVICE_REPOST"
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
/** A backstop on the backstop: the ring window is ten minutes. */
@@ -82,7 +82,7 @@ object RingNotifications {
* closes when there is none, so a dismiss that lands before the service's
* read costs one brief screen wake, never a ring screen left up.
*/
fun preparing(context: Context, presentation: RingPresentation): Notification {
fun preparing(context: Context, presentation: RingPresentation, repost: PendingIntent): Notification {
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@@ -93,6 +93,7 @@ object RingNotifications {
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(show)
.setDeleteIntent(repost)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
}
@@ -104,11 +105,16 @@ object RingNotifications {
* while snoozes are left, since a refused snooze dismisses; and an alarm
* with a dismiss challenge has its Dismiss open the ring screen, where the
* challenge is, rather than end the alarm from a heads-up or the lock screen.
*
* From Android 14 a foreground service's notification can be swiped away
* on an unlocked phone, ongoing or not. [repost] puts it back, so a
* sounding alarm never loses its controls.
*/
fun ringing(
context: Context,
session: RingSession,
presentation: RingPresentation,
repost: PendingIntent,
): Notification {
val alarm = session.alarm
val show = showPendingIntent(context)
@@ -122,6 +128,7 @@ object RingNotifications {
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(show)
.setDeleteIntent(repost)
if (session.canSnooze) {
builder.addAction(
R.drawable.ic_notification,
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.ui.alarms
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@@ -19,11 +20,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -53,6 +56,7 @@ fun AlarmsScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val rows = (state as? AlarmsUiState.Loaded)?.rows
val context = LocalContext.current
val pillInset = LocalLivePillInset.current
val addAlarm = stringResource(R.string.alarms_add)
@@ -99,7 +103,18 @@ fun AlarmsScreen(
items(rows.orEmpty(), key = { row -> row.id }) { row ->
AlarmRow(
row = row,
onClick = { onEditAlarm(row.id) },
// A ringing alarm's row leads back to its ring screen, the
// way out if its notification was swiped away.
onClick = {
if (row.ringing) {
context.startActivity(
Intent(context, AlarmRingActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
} else {
onEditAlarm(row.id)
}
},
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
// An alarm whose time changed slides to its new place
// instead of teleporting.