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
This commit is contained in:
makiolaj committed 2026-10-21 20:01:21 +02:00
1 parent 3f431a7f32
commit e43339c386
25 files changed
+785 -33

No files matched your search

+5
View File
@@ -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.
+1 -1
View File
@@ -203,7 +203,7 @@
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- The two AlarmManager slots arriving. Unexported: a PendingIntent the
<!-- The AlarmManager slots arriving. Unexported: a PendingIntent the
app created is delivered regardless, and nothing else may fake a fire. -->
<receiver
android:name=".alarm.receiver.AlarmFireReceiver"
@@ -4,6 +4,7 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
import de.jeanlucmakiola.clockula.timer.TimerEngine
@@ -59,6 +60,7 @@ class ClockulaApp : Application() {
)
RingNotifications.createChannels(this)
UpcomingNotifications.createChannel(this)
TimerNotifications.createChannel(this)
StopwatchNotifications.createChannel(this)
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotices
import de.jeanlucmakiola.clockula.domain.interop.InteropLimits
import de.jeanlucmakiola.clockula.domain.resolveSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
@@ -114,17 +115,29 @@ class AlarmEngine @Inject constructor(
// is written and the caller is told so.
if (!alarm.enabled) return@withLock DismissOutcome.NotFound
// "Dismissing a repeating alarm only prevents the upcoming instance from
// ringing"; dismissing a single occurrence "effectively disables" it.
if (alarm.repeatDays.isRepeating) {
alarms.setSkipNextOccurrence(alarmId, true)
rescheduleLocked()
return@withLock DismissOutcome.SkippedNext(alarmId)
}
dismissComingLocked(alarm)
}
disableOrDeleteLocked(alarm)
rescheduleLocked()
DismissOutcome.Disabled(alarmId)
/**
* The upcoming-alarm notification's Dismiss, and the row's chip (#16):
* [dismissUpcoming], but only for the occurrence at [fireAt] and only while
* it is still to come. A tap that lands after the alarm started ringing, or
* after it moved, is [DismissOutcome.Stale] and dismisses nothing — the ring
* screen and its dismiss challenge own a ringing alarm. No challenge is
* asked before the ring, as in AOSP's Clock: skipping tomorrow's alarm is
* already one switch away in the editor, unchallenged.
*/
suspend fun dismissUpcomingOccurrence(alarmId: Long, fireAt: Instant): DismissOutcome = lock.withLock {
val alarm = alarms.find(alarmId) ?: return@withLock DismissOutcome.NotFound
// Read-only: the resolver's state is discarded, and the reschedule below
// persists whatever it should.
val resolution = AlarmResolver.resolve(alarm, states.state(alarmId), wallClock.now(), zones.current())
if (resolution.source != FireSource.OCCURRENCE || resolution.nextFire != fireAt) {
// Whatever posted the stale notice is put right on the way out.
rescheduleLocked()
return@withLock DismissOutcome.Stale
}
dismissComingLocked(alarm)
}
suspend fun dismiss(alarmId: Long): Unit =
@@ -141,6 +154,9 @@ class AlarmEngine @Inject constructor(
suspend fun onPackageReplaced(): ScheduledFire? = reschedule()
/** An upcoming-alarm window opened (#16); the reschedule posts its notice. */
suspend fun onUpcomingWindow(): ScheduledFire? = reschedule()
/** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */
suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() }
@@ -209,10 +225,12 @@ class AlarmEngine @Inject constructor(
val zone = zones.current()
var earliest: Pair<Alarm, AlarmResolution>? = null
val resolved = mutableListOf<UpcomingAlarm>()
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<UpcomingAlarm>, 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 {
@@ -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<Int> = listOf(
REQUEST_FIRE,
@@ -34,5 +43,8 @@ object AlarmIntents {
REQUEST_SNOOZE,
REQUEST_DISMISS,
REQUEST_REPOST,
REQUEST_UPCOMING_CHECK,
REQUEST_DISMISS_UPCOMING,
REQUEST_SHOW_UPCOMING,
)
}
@@ -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<UpcomingNotice>)
}
@@ -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?
@@ -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<Long, UpcomingNotice>()
override fun showUpcoming(notices: List<UpcomingNotice>) {
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
}
}
}
}
@@ -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
@@ -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()
@@ -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()
@@ -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
}
@@ -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 {
@@ -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<UpcomingNotice>,
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<UpcomingAlarm>, 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)
}
}
@@ -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 },
)
}
}
}
}
@@ -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) },
)
}
}
@@ -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(),
@@ -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 {
+5
View File
@@ -10,6 +10,10 @@
<string name="channel_alarm_ring_description">The full-screen alarm while it is ringing. Turning this off does not silence an alarm.</string>
<string name="channel_alarm_snoozed_name">Snoozed alarms</string>
<string name="channel_alarm_snoozed_description">A quiet reminder that an alarm is snoozed, and when it comes back.</string>
<string name="channel_alarm_upcoming_name">Upcoming alarms</string>
<string name="channel_alarm_upcoming_description">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.</string>
<string name="alarm_upcoming_title">Upcoming alarm</string>
<string name="alarm_upcoming_text_labelled">%1$s · %2$s</string>
<string name="alarm_notification_title">Alarm</string>
<string name="alarm_action_snooze">Snooze</string>
<string name="alarm_action_dismiss">Dismiss</string>
@@ -102,6 +106,7 @@
<string name="alarms_add">Add alarm</string>
<string name="alarms_empty_hint">Tap + to set your first alarm</string>
<string name="alarm_row_toggle">Alarm at %1$s</string>
<string name="alarm_row_dismiss_upcoming">Dismiss the alarm at %1$s</string>
<string name="alarm_next_imminent">in less than a minute</string>
<plurals name="alarm_next_in_minutes">
<item quantity="one">in %1$d min</item>
@@ -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)
}
}
@@ -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,
)
}
@@ -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))
}
}
@@ -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<UpcomingNotice> = 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<UpcomingNotice>) {
check(!failUpcoming) { "notification service unavailable" }
upcoming = notices
}
}
class FakeAlarmCapabilities(
@@ -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()
}
}
+43 -7
View File
@@ -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