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