diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt index af65f07..a5876be 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt @@ -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 = listOf( REQUEST_FIRE, @@ -32,5 +33,6 @@ object AlarmIntents { REQUEST_SHOW, REQUEST_SNOOZE, REQUEST_DISMISS, + REQUEST_REPOST, ) } 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 9cccee9..a68beb5 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 @@ -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. */ 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 13301fa..087d91c 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 @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index 37c6abc..bdb6c1d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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.