From e43339c386545e20453c0bc07a08f03adb98cba4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:01:21 +0200 Subject: [PATCH] feat(alarms): notify before an alarm rings, with Dismiss Two hours before a natural occurrence (AOSP DeskClock's lead), a silent "Upcoming alarm" notification offers Dismiss, and the alarm's row shows the same Dismiss as a chip. Dismissing is dismissUpcoming's own branches: a repeating alarm skips that occurrence, a one-shot is switched off. The notices are synced at the end of every reschedule, after the real registration, and a third AlarmManager slot on its own request code wakes when the next window opens. The sync is wrapped so a notification failure can never stop a ring. Dismiss names the occurrence and is refused once the alarm is ringing or has moved, so it can't get round the dismiss challenge. No challenge is asked before the ring, as in Google Clock. Fixes #16 --- CHANGELOG.md | 5 + app/src/main/AndroidManifest.xml | 2 +- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 2 + .../clockula/alarm/AlarmEngine.kt | 87 +++++++-- .../clockula/alarm/AlarmIntents.kt | 12 ++ .../clockula/alarm/AlarmNotifier.kt | 9 +- .../clockula/alarm/AlarmScheduler.kt | 11 +- .../alarm/android/AndroidAlarmNotifier.kt | 52 +++++- .../alarm/android/AndroidAlarmScheduler.kt | 36 +++- .../alarm/receiver/AlarmActionReceiver.kt | 7 +- .../alarm/receiver/AlarmFireReceiver.kt | 4 +- .../alarm/ring/UpcomingNotifications.kt | 116 ++++++++++++ .../clockula/domain/alarm/UpcomingAlarm.kt | 6 + .../clockula/domain/alarm/UpcomingNotices.kt | 60 +++++++ .../clockula/ui/alarms/AlarmRow.kt | 26 +++ .../clockula/ui/alarms/AlarmRowState.kt | 8 + .../clockula/ui/alarms/AlarmsScreen.kt | 3 + .../clockula/ui/alarms/AlarmsViewModel.kt | 10 ++ app/src/main/res/values/strings.xml | 5 + .../clockula/alarm/AlarmEngineUpcomingTest.kt | 167 ++++++++++++++++++ .../clockula/alarm/AlarmIntentsTest.kt | 5 + .../domain/alarm/UpcomingNoticesTest.kt | 91 ++++++++++ .../clockula/testing/FakeAlarmSeams.kt | 27 ++- .../clockula/ui/alarms/AlarmListRowsTest.kt | 17 ++ docs/ARCHITECTURE.md | 50 +++++- 25 files changed, 785 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/UpcomingNotifications.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNotices.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineUpcomingTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNoticesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..b48fdc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. Changes apply as you make them, with no Save button. - Repeat days that start on your locale's first day of the week, and a "skip next alarm" switch so you can sleep in once without turning the alarm off. +- An "Upcoming alarm" notification two hours before an alarm rings, with a + Dismiss button, so you can turn off today's alarm when you're already awake. + A repeating alarm skips just that once; a one-time alarm is switched off. + The same Dismiss shows on the alarm in the list. The notification is silent, + and turning it off in the system settings doesn't affect the alarm. - Per-alarm sound, vibration, gradual volume, snooze length and limit, and a dismiss challenge (a quick sum or a two-second hold). Each one can follow the app default, so changing a default later still reaches it. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfe76b4..fd9442c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -203,7 +203,7 @@ android:exported="false" android:foregroundServiceType="systemExempted" /> - ? = null + val resolved = mutableListOf() for (alarm in alarms.alarms().first()) { val state = states.state(alarm.id) val resolution = AlarmResolver.resolve(alarm, state, now, zone) persist(alarm, state, resolution) + resolved += UpcomingAlarm(alarm, resolution.nextFire, resolution.source) val fireAt = resolution.nextFire ?: continue val best = earliest @@ -222,13 +240,52 @@ class AlarmEngine @Inject constructor( if (isEarlier) earliest = alarm to resolution } - val winner = earliest ?: run { + val winner = earliest + val scheduled = if (winner == null) { scheduler.cancelNext() - return null + null + } else { + val fireAt = winner.second.nextFire!! + val mode = scheduler.scheduleNext(fireAt, winner.first.id) + ScheduledFire(winner.first.id, fireAt, winner.second.source, mode) } - val fireAt = winner.second.nextFire!! - val mode = scheduler.scheduleNext(fireAt, winner.first.id) - return ScheduledFire(winner.first.id, fireAt, winner.second.source, mode) + // After the real registration, never before it. + syncUpcomingLocked(resolved, now) + return scheduled + } + + /** + * The upcoming-alarm notices and the wake-up for the next window (#16). + * Every path that moves an alarm — an edit, a delete, a switch, a skip, a + * snooze, a fire, a boot, a clock or zone change — already ends in + * [rescheduleLocked], so this is the one place they are kept true. + * + * Nothing here may cost an alarm: [onFireLocked] reschedules *before* it + * starts the ring, so a failure on the way is swallowed rather than thrown. + */ + private fun syncUpcomingLocked(resolved: List, now: Instant) { + runCatching { + val plan = UpcomingNotices.plan(resolved, now) + notifier.showUpcoming(plan.notices) + val next = plan.nextCheckAt + if (next == null) scheduler.cancelUpcomingCheck() else scheduler.scheduleUpcomingCheck(next) + } + } + + /** + * "Dismissing a repeating alarm only prevents the upcoming instance from + * ringing"; dismissing a single occurrence "effectively disables" it. + */ + private suspend fun dismissComingLocked(alarm: Alarm): DismissOutcome { + if (alarm.repeatDays.isRepeating) { + alarms.setSkipNextOccurrence(alarm.id, true) + rescheduleLocked() + return DismissOutcome.SkippedNext(alarm.id) + } + + disableOrDeleteLocked(alarm) + rescheduleLocked() + return DismissOutcome.Disabled(alarm.id) } private suspend fun onFireLocked(alarmId: Long, scheduledFor: Instant): FireOutcome { 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 a5876be..5b79f4c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt @@ -14,6 +14,12 @@ object AlarmIntents { const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE" const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS" + /** The upcoming-alarm window opening: re-resolve and post the notices (#16). */ + const val ACTION_UPCOMING_CHECK: String = PREFIX + "action.ALARM_UPCOMING_CHECK" + + /** The upcoming notification's Dismiss, which carries [EXTRA_FIRE_AT] (#16). */ + const val ACTION_DISMISS_UPCOMING: String = PREFIX + "action.ALARM_DISMISS_UPCOMING" + /** Internal: "show me the alarms tab", the transport for `SHOW_ALARMS` and the next-alarm show intent. */ const val ACTION_SHOW_ALARMS: String = PREFIX + "action.SHOW_ALARMS" @@ -26,6 +32,9 @@ object AlarmIntents { const val REQUEST_SNOOZE: Int = 4 const val REQUEST_DISMISS: Int = 5 const val REQUEST_REPOST: Int = 6 + const val REQUEST_UPCOMING_CHECK: Int = 7 + const val REQUEST_DISMISS_UPCOMING: Int = 8 + const val REQUEST_SHOW_UPCOMING: Int = 9 val ALL_REQUEST_CODES: List = listOf( REQUEST_FIRE, @@ -34,5 +43,8 @@ object AlarmIntents { REQUEST_SNOOZE, REQUEST_DISMISS, REQUEST_REPOST, + REQUEST_UPCOMING_CHECK, + REQUEST_DISMISS_UPCOMING, + REQUEST_SHOW_UPCOMING, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt index 2aeda4b..b91fbc2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt @@ -1,11 +1,18 @@ package de.jeanlucmakiola.clockula.alarm import de.jeanlucmakiola.clockula.domain.Alarm +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice import kotlin.time.Instant -/** The snoozed notification — the one surface the engine posts directly. */ +/** The snoozed and upcoming notifications — the surfaces the engine posts directly. */ interface AlarmNotifier { fun showSnoozed(alarm: Alarm, until: Instant) fun clearSnoozed(alarmId: Long) + + /** + * Makes the upcoming-alarm notifications exactly [notices]: posts the new + * ones and removes any that are no longer due (#16). Never throws. + */ + fun showUpcoming(notices: List) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt index e18f065..a83b245 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt @@ -3,7 +3,7 @@ package de.jeanlucmakiola.clockula.alarm import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode import kotlin.time.Instant -/** The AlarmManager seam. Two slots, deliberately separate. */ +/** The AlarmManager seam. Three slots, deliberately separate. */ interface AlarmScheduler { /** Replaces the single next-alarm registration. Returns the mode actually used. */ fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode @@ -14,6 +14,15 @@ interface AlarmScheduler { fun cancelAutoSilence() + /** + * When the next upcoming-alarm window opens (#16). Its own PendingIntent and + * request code, never `setAlarmClock`: a housekeeping wake-up must not + * replace what the system shows as the user's next alarm. + */ + fun scheduleUpcomingCheck(at: Instant) + + fun cancelUpcomingCheck() + /** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */ fun systemNextAlarm(): Instant? diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmNotifier.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmNotifier.kt index 541d4f4..113440d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmNotifier.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.alarm.android import android.Manifest +import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager import android.os.Build @@ -9,15 +10,18 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.clockula.alarm.AlarmNotifier import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications +import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications import de.jeanlucmakiola.clockula.domain.Alarm +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice import kotlin.time.Instant import javax.inject.Inject import javax.inject.Singleton /** - * The snoozed notification — the one surface the engine posts directly. A denied - * notification permission costs the user this line and nothing else: the snooze - * itself is a row in `alarm_states` and an AlarmManager registration. + * The snoozed and upcoming notifications — the surfaces the engine posts + * directly. A denied notification permission costs the user these lines and + * nothing else: the snooze itself is a row in `alarm_states` and an + * AlarmManager registration, and the upcoming Dismiss is also on the alarm row. */ @Singleton class AndroidAlarmNotifier @Inject constructor( @@ -47,4 +51,46 @@ class AndroidAlarmNotifier @Inject constructor( override fun clearSnoozed(alarmId: Long) { notifications.cancel(RingNotifications.snoozedNotificationId(alarmId)) } + + /** + * What this process last posted, per alarm. The engine syncs on every + * reschedule — each app start, each edit — and re-posting an unchanged + * notice would bring back one the user swiped away. Only the engine calls + * this, under its lock, so the map needs no lock of its own. + */ + private val postedUpcoming = mutableMapOf() + + override fun showUpcoming(notices: List) { + runCatching { + val wanted = notices.associateBy { it.alarm.id } + // What is on screen, including what an earlier process posted and + // this one has no memory of. + val onScreen = context.getSystemService(NotificationManager::class.java) + .activeNotifications + .filter { it.tag == UpcomingNotifications.TAG } + .map { it.id } + val wantedIds = wanted.keys.map(UpcomingNotifications::notificationId).toSet() + (onScreen + postedUpcoming.keys.map(UpcomingNotifications::notificationId)) + .filterNot { it in wantedIds } + .forEach { notifications.cancel(UpcomingNotifications.TAG, it) } + postedUpcoming.keys.retainAll(wanted.keys) + + // Denied, nothing is recorded, so a later grant posts on the next sync. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return@runCatching + } + for (notice in notices) { + if (postedUpcoming[notice.alarm.id] == notice) continue + notifications.notify( + UpcomingNotifications.TAG, + UpcomingNotifications.notificationId(notice.alarm.id), + UpcomingNotifications.build(context, notice), + ) + postedUpcoming[notice.alarm.id] = notice + } + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt index 98ace1e..2beaabb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt @@ -17,11 +17,13 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Two AlarmManager slots, deliberately separate. The user's next alarm goes + * Three AlarmManager slots, deliberately separate. The user's next alarm goes * through `setAlarmClock`, which is the only variant that populates * `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The * auto-silence backstop goes through its own PendingIntent, so an internal - * housekeeping time can never overwrite what the system shows the user. + * housekeeping time can never overwrite what the system shows the user. The + * upcoming-alarm check (#16) is the third, on its own PendingIntent for the + * same reason. * * Translation only: which mode to use is [SchedulingPolicy]'s decision. */ @@ -64,6 +66,27 @@ class AndroidAlarmScheduler @Inject constructor( override fun cancelAutoSilence() = manager.cancel(autoSilencePendingIntent(alarmId = 0L)) + /** + * Exact where allowed, so the notice is up when its window opens; inexact + * otherwise, since a late notice costs nothing an alarm needs. Never + * `setAlarmClock`, and wrapped whole: nothing about the notice may throw + * into the engine's reschedule (#16). + */ + override fun scheduleUpcomingCheck(at: Instant) { + val pendingIntent = upcomingCheckPendingIntent() + val millis = at.toEpochMilliseconds() + runCatching { + val exact = runCatching { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + }.isSuccess + if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + } + } + + override fun cancelUpcomingCheck() { + runCatching { manager.cancel(upcomingCheckPendingIntent()) } + } + override fun systemNextAlarm(): Instant? = manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) } @@ -94,6 +117,15 @@ class AndroidAlarmScheduler @Inject constructor( PENDING_INTENT_FLAGS, ) + private fun upcomingCheckPendingIntent(): PendingIntent = PendingIntent.getBroadcast( + context, + AlarmIntents.REQUEST_UPCOMING_CHECK, + Intent(context, AlarmFireReceiver::class.java).apply { + action = AlarmIntents.ACTION_UPCOMING_CHECK + }, + PENDING_INTENT_FLAGS, + ) + /** * What the system opens when the user taps the status-bar alarm icon or the * lockscreen's next-alarm line: the **alarms list**, which is what "show me diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmActionReceiver.kt index f7b9b99..687e239 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmActionReceiver.kt @@ -8,9 +8,10 @@ import de.jeanlucmakiola.clockula.alarm.AlarmIntents import de.jeanlucmakiola.floret.di.ApplicationScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlin.time.Instant import javax.inject.Inject -/** The notification's Snooze and Dismiss buttons. */ +/** The notifications' Snooze and Dismiss buttons, the upcoming notice's included. */ @AndroidEntryPoint class AlarmActionReceiver : HiltBroadcastReceiver() { @@ -32,6 +33,10 @@ class AlarmActionReceiver : HiltBroadcastReceiver() { when (action) { AlarmIntents.ACTION_SNOOZE -> engine.snooze(alarmId) AlarmIntents.ACTION_DISMISS -> engine.dismiss(alarmId) + AlarmIntents.ACTION_DISMISS_UPCOMING -> engine.dismissUpcomingOccurrence( + alarmId = alarmId, + fireAt = Instant.fromEpochMilliseconds(intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L)), + ) } } finally { pendingResult.finish() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmFireReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmFireReceiver.kt index 176d0c6..637e583 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmFireReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/AlarmFireReceiver.kt @@ -13,7 +13,7 @@ import kotlin.time.Instant import javax.inject.Inject /** - * The two AlarmManager slots arriving. Thin by design: every decision the engine + * The AlarmManager slots arriving: the fire, the backstop and the upcoming check. Thin by design: every decision the engine * could make is the engine's, so the receiver only unpacks and dispatches. */ @AndroidEntryPoint @@ -48,6 +48,8 @@ class AlarmFireReceiver : HiltBroadcastReceiver() { ) AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId) + + AlarmIntents.ACTION_UPCOMING_CHECK -> engine.onUpcomingWindow() } } finally { pendingResult.finish() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/UpcomingNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/UpcomingNotifications.kt new file mode 100644 index 0000000..981a842 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/UpcomingNotifications.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.clockula.alarm.ring + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice +import java.time.Instant as JavaInstant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * "Upcoming alarm" with a Dismiss, posted while an alarm is inside its window + * (#16). Silent and low-importance: it is a way out for someone already awake, + * not a second alarm. Its Dismiss is the engine's `dismissUpcoming` for the one + * occurrence it names, so it can never end a ring that has already started. + */ +object UpcomingNotifications { + + const val CHANNEL_ID: String = "alarm_upcoming" + + /** Tagged, so the per-alarm ids cannot collide with any other notification's. */ + const val TAG: String = "alarm_upcoming" + + /** Idempotent; called from the app class on every start. */ + fun createChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.channel_alarm_upcoming_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.channel_alarm_upcoming_description) + } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + fun notificationId(alarmId: Long): Int = alarmId.toInt() + + fun build(context: Context, notice: UpcomingNotice): Notification { + val alarm = notice.alarm + val time = formatInstant(context, notice.fireAt) + val text = if (alarm.label.isBlank()) { + time + } else { + context.getString(R.string.alarm_upcoming_text_labelled, time, alarm.label) + } + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.alarm_upcoming_title)) + .setContentText(text) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + // Several alarms in the window read in the order they ring. + .setSortKey(notice.fireAt.toEpochMilliseconds().toString().padStart(SORT_KEY_WIDTH, '0')) + .setContentIntent(showAlarmsPendingIntent(context)) + .addAction( + R.drawable.ic_notification, + context.getString(R.string.alarm_action_dismiss), + dismissPendingIntent(context, notice), + ) + .build() + } + + /** The alarms list, the same door the status-bar alarm icon opens. */ + private fun showAlarmsPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( + context, + AlarmIntents.REQUEST_SHOW_UPCOMING, + Intent(context, MainActivity::class.java).apply { + action = AlarmIntents.ACTION_SHOW_ALARMS + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun dismissPendingIntent(context: Context, notice: UpcomingNotice): PendingIntent = + PendingIntent.getBroadcast( + context, + // Per alarm, so two notices' Dismiss buttons keep their own extras. + AlarmIntents.REQUEST_DISMISS_UPCOMING * REQUEST_CODE_STRIDE + + (notice.alarm.id % REQUEST_CODE_STRIDE).toInt(), + Intent(context, AlarmActionReceiver::class.java).apply { + action = AlarmIntents.ACTION_DISMISS_UPCOMING + putExtra(AlarmIntents.EXTRA_ALARM_ID, notice.alarm.id) + putExtra(AlarmIntents.EXTRA_FIRE_AT, notice.fireAt.toEpochMilliseconds()) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + /** The device's 12/24-hour choice, then the locale's own short form. */ + private fun formatInstant(context: Context, instant: kotlin.time.Instant): String { + val time = JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime() + val pattern = if (android.text.format.DateFormat.is24HourFormat(context)) { + DateTimeFormatter.ofPattern("HH:mm") + } else { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + } + return pattern.format(time) + } + + /** Matches `RingNotifications`: keeps a derived request code clear of the small ones. */ + private const val REQUEST_CODE_STRIDE = 100_000 + + private const val SORT_KEY_WIDTH = 20 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt index 581ee36..b0c7e32 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt @@ -43,6 +43,12 @@ sealed interface DismissOutcome { /** Unknown id, or an alarm with no upcoming instance. Nothing was written. */ data object NotFound : DismissOutcome + + /** + * The occurrence a notice or row named is no longer the one to come: it + * started ringing, or the alarm moved. Nothing was dismissed (#16). + */ + data object Stale : DismissOutcome } sealed interface SnoozeOutcome { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNotices.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNotices.kt new file mode 100644 index 0000000..1b366d6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNotices.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import de.jeanlucmakiola.clockula.domain.Alarm +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +/** One alarm about to ring, offered for dismissal before it does (#16). */ +data class UpcomingNotice( + val alarm: Alarm, + /** The occurrence the notice is about; a dismissal names it, so a stale tap cannot reach a later one. */ + val fireAt: Instant, +) + +/** + * What the upcoming-alarm surface should show now, and when it next has to + * look again. [nextCheckAt] is null when no alarm's window is still to open. + */ +data class UpcomingNoticePlan( + val notices: List, + val nextCheckAt: Instant?, +) + +/** + * The "upcoming alarm" window: the stretch before an alarm rings in which it + * can be dismissed without opening the app (#16). Pure; the engine and the + * alarm list both ask it, so the notification and the row's chip agree. + */ +object UpcomingNotices { + /** + * How long before the ring the notice appears. AOSP DeskClock posts its + * "Upcoming alarm" notification two hours ahead, which is also what Google + * Clock is reported to do. Long enough to cover waking early; the notice + * is silent, so being early costs nothing. A value, not a setting: the + * notification channel is the off switch. + */ + val LEAD: Duration = 2.hours + + /** + * Only a natural occurrence still to come. A snoozed alarm already has its + * own notification with a Dismiss, and a ringing one is the ring screen's, + * where the dismiss challenge is. + */ + fun isInWindow(nextFire: Instant?, source: FireSource, now: Instant, lead: Duration = LEAD): Boolean = + source == FireSource.OCCURRENCE && nextFire != null && nextFire > now && nextFire - now <= lead + + /** Notices ordered by when they ring, then id; the next check is the earliest window still to open. */ + fun plan(upcoming: List, now: Instant, lead: Duration = LEAD): UpcomingNoticePlan { + val notices = upcoming + .filter { isInWindow(it.nextFire, it.source, now, lead) } + .map { UpcomingNotice(it.alarm, it.nextFire!!) } + .sortedWith(compareBy({ it.fireAt }, { it.alarm.id })) + val nextCheckAt = upcoming + .filter { it.source == FireSource.OCCURRENCE } + .mapNotNull { entry -> entry.nextFire?.let { it - lead } } + .filter { it > now } + .minOrNull() + return UpcomingNoticePlan(notices, nextCheckAt) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt index c47eb5a..c0cc71c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -5,6 +5,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AlarmOff +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -48,6 +54,7 @@ internal fun AlarmRow( onClick: () -> Unit, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, + onDismissComing: () -> Unit = {}, ) { val formatTime = rememberAlarmTimeFormatter() val time = formatTime(row.time) @@ -113,6 +120,25 @@ internal fun AlarmRow( style = MaterialTheme.typography.bodyMedium, color = supportingColor, ) + // Inside the upcoming notification's window, its Dismiss is here + // too, for whoever opens the app instead (#16). + if (row.enabled && row.dismissibleFireAt != null) { + val dismissDescription = stringResource(R.string.alarm_row_dismiss_upcoming, time) + AssistChip( + onClick = onDismissComing, + label = { Text(stringResource(R.string.alarm_action_dismiss)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.AlarmOff, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + // "Dismiss the alarm at 07:00": the bare word names neither + // the row nor that it is the coming ring, not this one. + modifier = Modifier.semantics { contentDescription = dismissDescription }, + ) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt index bfa9310..28ff9cd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.domain.alarm.FireSource import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotices import de.jeanlucmakiola.clockula.domain.format.NextFireFormat import de.jeanlucmakiola.clockula.domain.format.NextFireLabel import kotlin.time.Instant @@ -22,6 +23,11 @@ data class AlarmRowState( val skippingNext: Boolean, /** The earliest non-null next fire. Never a ringing alarm. */ val isNext: Boolean, + /** + * The occurrence the row's Dismiss chip would dismiss, while the alarm is + * inside the same window as the upcoming notification; null outside it (#16). + */ + val dismissibleFireAt: Instant? = null, ) sealed interface AlarmsUiState { @@ -58,6 +64,8 @@ internal object AlarmListRows { // only a repeating alarm can be "skipping next" (M5 D14). skippingNext = alarm.skipNextOccurrence && alarm.repeatDays.isRepeating, isNext = !ringing && next?.alarm?.id == alarm.id, + dismissibleFireAt = entry.nextFire + ?.takeIf { UpcomingNotices.isInWindow(it, entry.source, now) }, ) } } 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 bdb6c1d..ed3a195 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 @@ -116,6 +116,9 @@ fun AlarmsScreen( } }, onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) }, + onDismissComing = { + row.dismissibleFireAt?.let { viewModel.onDismissComing(row.id, it) } + }, // An alarm whose time changed slides to its new place // instead of teleporting. modifier = animateItemMotion(), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt index d17e9fe..3df75c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.time.Instant import javax.inject.Inject /** The alarm list: what it shows, the "+" that creates a row, and the row's switch. */ @@ -76,6 +77,15 @@ class AlarmsViewModel @Inject internal constructor( viewModelScope.launch { engine.dismissUpcoming(alarmId) } } + /** + * The row's Dismiss chip (#16): the upcoming notification's own verb, so + * a chip tapped as the alarm starts ringing dismisses nothing rather than + * getting round the ring screen's challenge. + */ + fun onDismissComing(alarmId: Long, fireAt: Instant) { + viewModelScope.launch { engine.dismissUpcomingOccurrence(alarmId, fireAt) } + } + /** Dismisses a ring first when switching off (M5 D11), then re-resolves. */ fun onToggleEnabled(alarmId: Long, enabled: Boolean) { viewModelScope.launch { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..6ca0762 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,10 @@ The full-screen alarm while it is ringing. Turning this off does not silence an alarm. Snoozed alarms A quiet reminder that an alarm is snoozed, and when it comes back. + Upcoming alarms + A silent note up to two hours before an alarm rings, so you can dismiss it if you are already up. Turning this off does not affect the alarm. + Upcoming alarm + %1$s · %2$s Alarm Snooze Dismiss @@ -102,6 +106,7 @@ Add alarm Tap + to set your first alarm Alarm at %1$s + Dismiss the alarm at %1$s in less than a minute in %1$d min diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineUpcomingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineUpcomingTest.kt new file mode 100644 index 0000000..82e3bc8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineUpcomingTest.kt @@ -0,0 +1,167 @@ +package de.jeanlucmakiola.clockula.alarm + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome +import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotices +import de.jeanlucmakiola.clockula.testing.RingEvent +import de.jeanlucmakiola.clockula.testing.alarmEngineHarness +import de.jeanlucmakiola.clockula.testing.offsetInstant +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The upcoming-alarm notification (#16). The harness starts at 05:00 Berlin, so + * a 06:30 alarm is inside the two-hour window and an 08:00 one is not yet. + */ +class AlarmEngineUpcomingTest { + + private val sixThirty = offsetInstant("2026-06-10T06:30+02:00") + private val eight = offsetInstant("2026-06-10T08:00+02:00") + private val sixThirtyTomorrow = offsetInstant("2026-06-11T06:30+02:00") + + @Test + fun `a reschedule notices the alarm in its window and wakes for the next window`(@TempDir tempDir: Path) = + runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30) + harness.givenAlarm(id = 2L, hour = 8) + + harness.engine.reschedule() + + assertThat(harness.notifier.upcoming.map { it.alarm.id to it.fireAt }).containsExactly(1L to sixThirty) + assertThat(harness.scheduler.upcomingCheck).isEqualTo(eight - UpcomingNotices.LEAD) + // The real slot is the earliest alarm, as it always was. + assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirty) + } + + @Test + fun `the window opening posts the later alarm's notice too`(@TempDir tempDir: Path) = runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30) + harness.givenAlarm(id = 2L, hour = 8) + harness.engine.reschedule() + + harness.wallClock.instant = eight - UpcomingNotices.LEAD + harness.engine.onUpcomingWindow() + + assertThat(harness.notifier.upcoming.map { it.alarm.id }).containsExactly(1L, 2L).inOrder() + assertThat(harness.scheduler.upcomingCheck).isNull() + } + + @Test + fun `dismissing a repeating alarm's notice skips today and leaves it on`(@TempDir tempDir: Path) = + runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30, repeatDays = RepeatDays.EVERY_DAY) + harness.engine.reschedule() + + val outcome = harness.engine.dismissUpcomingOccurrence(1L, sixThirty) + + val alarm = harness.alarms.find(1L)!! + assertThat(outcome).isEqualTo(DismissOutcome.SkippedNext(1L)) + assertThat(alarm.enabled).isTrue() + assertThat(alarm.skipNextOccurrence).isTrue() + assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow) + assertThat(harness.notifier.upcoming).isEmpty() + assertThat(harness.scheduler.upcomingCheck).isEqualTo(sixThirtyTomorrow - UpcomingNotices.LEAD) + } + + @Test + fun `dismissing a one-shot alarm's notice switches it off`(@TempDir tempDir: Path) = runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30) + harness.engine.reschedule() + + val outcome = harness.engine.dismissUpcomingOccurrence(1L, sixThirty) + + assertThat(outcome).isEqualTo(DismissOutcome.Disabled(1L)) + assertThat(harness.alarms.find(1L)!!.enabled).isFalse() + assertThat(harness.scheduler.next).isNull() + assertThat(harness.notifier.upcoming).isEmpty() + assertThat(harness.scheduler.upcomingCheck).isNull() + } + + @Test + fun `a Dismiss that lands once the alarm is ringing dismisses nothing`(@TempDir tempDir: Path) = + runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30, repeatDays = RepeatDays.EVERY_DAY) + harness.engine.reschedule() + harness.givenRinging(alarmId = 1L, at = sixThirty) + + val outcome = harness.engine.dismissUpcomingOccurrence(1L, sixThirty) + + // The ring screen and its challenge own a ringing alarm. + assertThat(outcome).isEqualTo(DismissOutcome.Stale) + assertThat(harness.states.state(1L).ringingSince).isEqualTo(sixThirty) + assertThat(harness.ring.events.last()).isEqualTo(RingEvent.Started(1L)) + assertThat(harness.alarms.find(1L)!!.skipNextOccurrence).isFalse() + } + + @Test + fun `a Dismiss for an occurrence the alarm no longer has dismisses nothing`(@TempDir tempDir: Path) = + runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30) + harness.engine.reschedule() + + val outcome = harness.engine.dismissUpcomingOccurrence(1L, eight) + + assertThat(outcome).isEqualTo(DismissOutcome.Stale) + assertThat(harness.alarms.find(1L)!!.enabled).isTrue() + assertThat(harness.notifier.upcoming.map { it.alarm.id }).containsExactly(1L) + } + + @Test + fun `an unknown alarm is not found`(@TempDir tempDir: Path) = runTest { + val harness = alarmEngineHarness(tempDir) + + assertThat(harness.engine.dismissUpcomingOccurrence(9L, sixThirty)).isEqualTo(DismissOutcome.NotFound) + } + + @Test + fun `the notice goes when the alarm fires, and a snooze does not bring it back`(@TempDir tempDir: Path) = + runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30) + harness.engine.reschedule() + + harness.givenRinging(alarmId = 1L, at = sixThirty) + assertThat(harness.notifier.upcoming).isEmpty() + + harness.engine.snooze(1L) + assertThat(harness.notifier.upcoming).isEmpty() + } + + @Test + fun `switching the alarm off takes its notice away`(@TempDir tempDir: Path) = runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30) + harness.engine.reschedule() + + harness.alarms.setEnabled(1L, false) + harness.engine.reschedule() + + assertThat(harness.notifier.upcoming).isEmpty() + assertThat(harness.scheduler.upcomingCheck).isNull() + } + + @Test + fun `a failing notification never costs the alarm its ring`(@TempDir tempDir: Path) = runTest { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 6, minute = 30, repeatDays = RepeatDays.EVERY_DAY) + harness.notifier.failUpcoming = true + + harness.engine.reschedule() + harness.wallClock.instant = sixThirty + val outcome = harness.engine.onFire(1L, sixThirty) + + assertThat(outcome).isEqualTo(FireOutcome.Ringing(1L)) + assertThat(harness.ring.events).containsExactly(RingEvent.Started(1L)) + assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt index 95e67ff..d2e96de 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt @@ -15,6 +15,8 @@ class AlarmIntentsTest { AlarmIntents.ACTION_AUTO_SILENCE, AlarmIntents.ACTION_SNOOZE, AlarmIntents.ACTION_DISMISS, + AlarmIntents.ACTION_UPCOMING_CHECK, + AlarmIntents.ACTION_DISMISS_UPCOMING, ) @Test @@ -28,6 +30,9 @@ class AlarmIntentsTest { AlarmIntents.REQUEST_SHOW, AlarmIntents.REQUEST_SNOOZE, AlarmIntents.REQUEST_DISMISS, + AlarmIntents.REQUEST_UPCOMING_CHECK, + AlarmIntents.REQUEST_DISMISS_UPCOMING, + AlarmIntents.REQUEST_SHOW_UPCOMING, ) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNoticesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNoticesTest.kt new file mode 100644 index 0000000..f13e755 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingNoticesTest.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.testing.offsetInstant +import de.jeanlucmakiola.clockula.testing.upcomingAlarm +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +/** + * The upcoming-alarm window (#16): which alarms get a notice, and when the + * engine next has to look. The engine and the alarm row both read it, so this + * is where "the notification and the chip agree" is decided. + */ +class UpcomingNoticesTest { + + private val now = offsetInstant("2026-06-10T05:30+02:00") + + @Test + fun `the lead is two hours, as in AOSP's clock`() { + assertThat(UpcomingNotices.LEAD).isEqualTo(2.hours) + } + + @Test + fun `an occurrence inside the window gets a notice naming it`() { + val fireAt = now + 90.minutes + val plan = UpcomingNotices.plan(listOf(upcomingAlarm(id = 1L, nextFire = fireAt)), now) + + assertThat(plan.notices.map { it.alarm.id to it.fireAt }).containsExactly(1L to fireAt) + } + + @Test + fun `the window includes its opening instant and excludes the ring itself`() { + assertThat(UpcomingNotices.isInWindow(now + 2.hours, FireSource.OCCURRENCE, now)).isTrue() + assertThat(UpcomingNotices.isInWindow(now + 2.hours + 1.milliseconds, FireSource.OCCURRENCE, now)) + .isFalse() + assertThat(UpcomingNotices.isInWindow(now, FireSource.OCCURRENCE, now)).isFalse() + assertThat(UpcomingNotices.isInWindow(now - 1.minutes, FireSource.OCCURRENCE, now)).isFalse() + } + + @Test + fun `a snooze, a ring and a switched-off alarm get no notice`() { + val soon = now + 5.minutes + listOf(FireSource.SNOOZE, FireSource.RESUMED_RING, FireSource.NONE).forEach { source -> + assertThat(UpcomingNotices.isInWindow(soon, source, now)).isFalse() + } + assertThat(UpcomingNotices.isInWindow(null, FireSource.OCCURRENCE, now)).isFalse() + } + + @Test + fun `several alarms in the window are each noticed, in the order they ring`() { + val plan = UpcomingNotices.plan( + listOf( + upcomingAlarm(id = 3L, nextFire = now + 60.minutes), + upcomingAlarm(id = 1L, nextFire = now + 100.minutes), + upcomingAlarm(id = 2L, nextFire = now + 60.minutes), + ), + now, + ) + + assertThat(plan.notices.map { it.alarm.id }).containsExactly(2L, 3L, 1L).inOrder() + } + + @Test + fun `the next check is when the earliest window still to open opens`() { + val plan = UpcomingNotices.plan( + listOf( + // Already open: noticed now, needs no check. + upcomingAlarm(id = 1L, nextFire = now + 30.minutes), + upcomingAlarm(id = 2L, nextFire = now + 5.hours), + upcomingAlarm(id = 3L, nextFire = now + 3.hours), + // A snooze's own notification covers it; no window to wait for. + upcomingAlarm(id = 4L, nextFire = now + 150.minutes, source = FireSource.SNOOZE), + ), + now, + ) + + assertThat(plan.nextCheckAt).isEqualTo(now + 1.hours) + } + + @Test + fun `nothing to come means no notice and no check`() { + val plan = UpcomingNotices.plan( + listOf(upcomingAlarm(id = 1L, enabled = false, nextFire = null, source = FireSource.NONE)), + now, + ) + + assertThat(plan).isEqualTo(UpcomingNoticePlan(notices = emptyList(), nextCheckAt = null)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt index 591ec13..856aaa0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -18,6 +18,7 @@ import de.jeanlucmakiola.clockula.domain.Timer import de.jeanlucmakiola.clockula.domain.TimerDraft import de.jeanlucmakiola.clockula.domain.alarm.CapabilitySnapshot import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice import de.jeanlucmakiola.clockula.domain.selfcheck.DeviceState import de.jeanlucmakiola.clockula.domain.time.BootId import de.jeanlucmakiola.clockula.domain.time.BootIdProvider @@ -32,7 +33,7 @@ import java.time.ZoneId import kotlin.time.Duration import kotlin.time.Instant -/** One registration in one of the two AlarmManager slots. */ +/** One registration in one of the AlarmManager slots. */ data class Registration(val fireAt: Instant, val alarmId: Long) /** @@ -80,6 +81,18 @@ class FakeAlarmScheduler( autoSilence = null } + /** The upcoming-alarm check (#16), held as the instant it would wake at. */ + var upcomingCheck: Instant? = null + private set + + override fun scheduleUpcomingCheck(at: Instant) { + upcomingCheck = at + } + + override fun cancelUpcomingCheck() { + upcomingCheck = null + } + override fun systemNextAlarm(): Instant? = next?.fireAt var nextOwnerPackage: String? = null @@ -131,6 +144,18 @@ class FakeAlarmNotifier : AlarmNotifier { override fun clearSnoozed(alarmId: Long) { cleared += alarmId } + + /** The upcoming notices as the shade would show them now (#16). */ + var upcoming: List = emptyList() + private set + + /** Set to make the next sync throw, as a broken notification service might. */ + var failUpcoming: Boolean = false + + override fun showUpcoming(notices: List) { + check(!failUpcoming) { "notification service unavailable" } + upcoming = notices + } } class FakeAlarmCapabilities( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt index eb3aaf0..f66e03b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt @@ -186,4 +186,21 @@ class AlarmListRowsTest { .containsExactly(NextFireLabel.InMinutes(30), NextFireLabel.InHoursMinutes(1, 30)) .inOrder() } + + @Test + fun `a row offers its Dismiss only inside the upcoming window`() { + val inWindow = now + 90.minutes + val upcoming = listOf( + upcomingAlarm(id = 1L, hour = 21, minute = 30, nextFire = inWindow), + upcomingAlarm(id = 2L, hour = 7, nextFire = sevenTomorrow), + upcomingAlarm(id = 3L, hour = 20, minute = 30, nextFire = now + 30.minutes, source = FireSource.SNOOZE), + ) + + val rows = AlarmListRows.from(upcoming, now).associateBy { it.id } + + assertThat(rows.getValue(1L).dismissibleFireAt).isEqualTo(inWindow) + assertThat(rows.getValue(2L).dismissibleFireAt).isNull() + // A snoozed alarm is dismissed from its own notification, not as "upcoming". + assertThat(rows.getValue(3L).dismissibleFireAt).isNull() + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..e9f8c17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,7 +111,7 @@ kit module: the kit's roadmap keeps schedulers app-local. | `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) | | `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks; no schema or version change), `BackupRepository(+Impl)`, mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy | | `alarm/` | `AlarmEngine` and the four seams it talks to (`AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier`), plus `AlarmIntents` | -| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification | +| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed and upcoming notifications | | `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` | | `alarm/ring/` | the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to `ring/` in M6, shared with the timer's ring | | `alarm/di/` | `AlarmModule`: `@Binds` for the four seams | @@ -729,7 +729,7 @@ and a TalkBack-shaped accessibility click completing the hold challenge. throws, ring state for an alarm that is not there fails the foreign key, and an explicit id moves the row-id allocator past it as SQLite's would. - **Six fakes for the engine's seams.** `FakeAlarmScheduler` holds each of the - two AlarmManager slots as the value it currently holds, so a test asserts on + AlarmManager slots as the value it currently holds, so a test asserts on what the system would be showing rather than on a call log; `FakeRingCoordinator` records start/stop *in order*, because the order is the take-over contract; `FakeAlarmNotifier`, `FakeAlarmCapabilities`, @@ -964,7 +964,7 @@ declared because a specific path would otherwise fail, silently, at 07:00. | `SCHEDULE_EXACT_ALARM` `android:maxSdkVersion="32"` | covers 31–32, where it is pre-granted. Bounded at 32 so it never overlaps the one above; that is the documented pattern and it keeps the store listing's story clean. Below 31 an exact alarm needs no permission at all | | `RECEIVE_BOOT_COMPLETED` | AlarmManager keeps nothing across a reboot | | `USE_FULL_SCREEN_INTENT` | the ring screen over the lock screen | -| `POST_NOTIFICATIONS` | the ring and snoozed notifications | +| `POST_NOTIFICATIONS` | the ring, snoozed and upcoming-alarm notifications | | `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the two ringing services: the alarm's, and (since M6) the timers' | | `WAKE_LOCK` | keep the CPU up for the length of a ring | | `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens | @@ -1172,7 +1172,8 @@ user later makes repeating is safe by construction (§17). The engine's verbs, for a reader looking for the one to call: `reschedule`, `onFire`, `onAutoSilence`, `snooze(id, minutesOverride = null)`, `dismiss`, -`dismissUpcoming`, `onBootCompleted`, `resumeRingingIfAny`, `ringSession` and +`dismissUpcoming`, `dismissUpcomingOccurrence` (#16), `onUpcomingWindow`, +`onBootCompleted`, `resumeRingingIfAny`, `ringSession` and the read-only `upcoming`. M9 added the last of the dismissals and the snooze's parameter: `dismissUpcoming(id)` is the `AlarmClock` contract's dismissal *and* the chooser dialog's, so the user's dismissal and an assistant's are the same @@ -1200,17 +1201,18 @@ back the moment the slot is re-resolved. unlimited, and "0 means infinite" is a trap. A refused snooze *dismisses*; it never leaves the user with a ringing alarm they cannot silence. -### Two AlarmManager slots, deliberately separate +### Three AlarmManager slots, deliberately separate | Slot | Registered with | Purpose | |---|---|---| | next alarm | `setAlarmClock(AlarmClockInfo(fireAt, showIntent), …)` | the user's next alarm. Doze-exempt, and the only variant that populates `getNextAlarmClock()` | | auto-silence backstop | `setExactAndAllowWhileIdle` | stops a ring nobody dismissed | +| upcoming check (#16) | `setExactAndAllowWhileIdle`, else `setAndAllowWhileIdle` | wakes when the next upcoming-alarm window opens; see below | They are separate because `getNextAlarmClock()` is what draws the status-bar icon and the lockscreen line: a backstop sharing that slot would overwrite the user's -07:00 with an internal 07:10. Two `PendingIntent`s, two request codes, two -independent cancels. A test asserts no two request codes collide, because +07:00 with an internal 07:10. One `PendingIntent`, one request code and one +independent cancel per slot. A test asserts no two request codes collide, because that collision is exactly how a backstop eats a user's alarm. A **snooze does** go through the next-alarm slot: a snoozed alarm *is* the next alarm. @@ -1218,6 +1220,40 @@ If exact alarms are unavailable the alarm is still registered, with `setAndAllowWhileIdle`. There is no third branch: late is survivable, silent is not. +### The upcoming-alarm notification (#16) + +For someone already awake, an alarm about to ring can be dismissed without +opening the app: a silent "Upcoming alarm" notification with a **Dismiss**, +posted while the alarm is inside its window, and the same Dismiss as a chip on +the alarm's row for whoever opens the app instead. + +- **The window** is `UpcomingNotices.LEAD` = **two hours** before a natural + occurrence, AOSP DeskClock's own lead for its upcoming notification and what + Google Clock is reported to use. The notice is silent and low-importance, so + being early costs nothing. It is a value, not a setting: the `alarm_upcoming` + channel is the off switch. Only `FireSource.OCCURRENCE` qualifies: a snoozed + alarm has its own notification with a Dismiss, and a ringing one belongs to + the ring screen. +- **Kept true in one place.** Every path that moves an alarm already ends in + `rescheduleLocked`, so that is where the notices are synced and the third + slot is armed for the earliest window still to open, *after* the real + registration. The sync is wrapped: `onFire` reschedules before it starts the + ring, and a notification failure must not cost the ring. The check's + receiver just calls `onUpcomingWindow()`, which is `reschedule()`. +- **Dismiss is `dismissUpcoming`, pinned to one occurrence.** + `dismissUpcomingOccurrence(id, fireAt)` re-resolves under the lock and acts + only if the alarm's next fire is still that natural occurrence; otherwise it + is `DismissOutcome.Stale` and writes nothing. Without the pin a tap landing + as the alarm starts would close the ring and get round its challenge. When it + matches, it takes `dismissUpcoming`'s own branches: a repeating alarm skips + that occurrence, a one-shot is switched off (or deleted, if transient). +- **No dismiss challenge before the ring**, as in Google Clock. The challenge + proves you are awake while an alarm sounds; before it, skipping is already one + unchallenged switch away in the editor. +- **Denied notifications** cost the notice and nothing else; the row's chip + still works. The notifier remembers what it posted in this process, so a + reschedule does not bring back a notice the user swiped away. + **The `showIntent` was wrong until M9.** It is what the system opens when the user taps the status-bar alarm icon or the lockscreen's next-alarm line, and it pointed at the ring screen with `FLAG_ACTIVITY_CLEAR_TASK`, so tapping "my next