Merge branch 'feat/16-upcoming-alarm-notification' into release/v1.0.0
# Conflicts: # app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
This commit is contained in:
commit
440d098c77
30 files changed
+1117
-49
No files matched your search
@@ -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.
|
||||
|
||||
@@ -209,7 +209,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.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
@@ -64,6 +65,7 @@ class ClockulaApp : Application() {
|
||||
)
|
||||
|
||||
RingNotifications.createChannels(this)
|
||||
UpcomingNotifications.createChannel(this)
|
||||
TimerNotifications.createChannel(this)
|
||||
StopwatchNotifications.createChannel(this)
|
||||
AndroidBedtimeNotifier.createChannel(this)
|
||||
|
||||
@@ -11,11 +11,11 @@ import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
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 +114,36 @@ 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)?.takeIf { it.enabled } ?: run {
|
||||
// Its notice, if one is still up, is put right on the way out.
|
||||
rescheduleLocked()
|
||||
return@withLock DismissOutcome.NotFound
|
||||
}
|
||||
val now = wallClock.now()
|
||||
// Read-only: the resolver's state is discarded, and the reschedule below
|
||||
// persists whatever it should. `fireAt > now` because the resolver keeps
|
||||
// a just-passed occurrence through the fire grace: one already due is
|
||||
// the fire's, not the notice's.
|
||||
val resolution = AlarmResolver.resolve(alarm, states.state(alarmId), now, zones.current())
|
||||
if (resolution.nextFire != fireAt || !UpcomingNotices.isInWindow(fireAt, resolution.source, now)) {
|
||||
// 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 +160,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() }
|
||||
|
||||
@@ -204,15 +226,24 @@ class AlarmEngine @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun rescheduleLocked(): ScheduledFire? {
|
||||
/**
|
||||
* [deferUpcomingTo] collects what the upcoming notices would be synced from
|
||||
* instead of syncing them, for a caller with something more urgent to do
|
||||
* first (the fire path).
|
||||
*/
|
||||
private suspend fun rescheduleLocked(deferUpcomingTo: MutableList<UpcomingSync>? = null): ScheduledFire? {
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
|
||||
var earliest: Pair<Alarm, AlarmResolution>? = null
|
||||
val resolved = mutableListOf<UpcomingAlarm>()
|
||||
val pendingSkips = mutableListOf<Instant>()
|
||||
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)
|
||||
resolution.state.skippedOccurrence?.let { pendingSkips += it }
|
||||
|
||||
val fireAt = resolution.nextFire ?: continue
|
||||
val best = earliest
|
||||
@@ -222,13 +253,57 @@ 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.
|
||||
val upcoming = UpcomingSync(resolved, pendingSkips)
|
||||
if (deferUpcomingTo == null) syncUpcomingLocked(upcoming, now) else deferUpcomingTo += upcoming
|
||||
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, so a failure is swallowed rather than
|
||||
* thrown into whichever entry point is rescheduling.
|
||||
*/
|
||||
private fun syncUpcomingLocked(upcoming: UpcomingSync, now: Instant) {
|
||||
runCatching {
|
||||
val plan = UpcomingNotices.plan(upcoming.resolved, now, upcoming.pendingSkips)
|
||||
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) {
|
||||
// Persist the resolution first: an earlier skip that has gone by but
|
||||
// not yet been spent in storage would otherwise absorb this one,
|
||||
// and the occurrence just dismissed would ring.
|
||||
rescheduleLocked()
|
||||
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 {
|
||||
@@ -240,9 +315,15 @@ class AlarmEngine @Inject constructor(
|
||||
// delivered late (after a reboot, or by Doze) still rings. Rejecting it
|
||||
// here would only re-register the same past instant and fire again.
|
||||
val lateBy = if (isSnoozeFire) AlarmRing.AUTO_SILENCE_AFTER else AlarmRing.FIRE_GRACE
|
||||
// Deleted, switched off, or a PendingIntent so stale the moment has gone:
|
||||
// re-resolve the slot and stay quiet.
|
||||
if (alarm == null || !alarm.enabled || now - scheduledFor > lateBy) {
|
||||
// A skip armed on this very occurrence after its fire was already on its
|
||||
// way: the user dismissed it in advance (#16), so it must not ring.
|
||||
// Only the armed skip, not the handled-occurrence watermark: the
|
||||
// resolver deliberately re-arms a handled occurrence that a clock moved
|
||||
// backwards has put back in the future, and that one must ring.
|
||||
val skipped = !isSnoozeFire && scheduledFor == state.skippedOccurrence
|
||||
// Deleted, switched off, skipped, or a PendingIntent so stale the moment
|
||||
// has gone: re-resolve the slot and stay quiet.
|
||||
if (alarm == null || !alarm.enabled || skipped || now - scheduledFor > lateBy) {
|
||||
rescheduleLocked()
|
||||
return FireOutcome.Ignored
|
||||
}
|
||||
@@ -266,11 +347,18 @@ class AlarmEngine @Inject constructor(
|
||||
// it holds this alarm's *next* occurrence rather than nothing at all.
|
||||
states.save(opened)
|
||||
notifier.clearSnoozed(alarmId)
|
||||
rescheduleLocked()
|
||||
// The upcoming notices wait until the ring has started: their binder
|
||||
// calls must not stand between the fire and the sound.
|
||||
val upcoming = mutableListOf<UpcomingSync>()
|
||||
rescheduleLocked(deferUpcomingTo = upcoming)
|
||||
|
||||
states.save(states.state(alarmId).copy(ringingSince = now))
|
||||
ring.startRinging(alarmId)
|
||||
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
|
||||
try {
|
||||
states.save(states.state(alarmId).copy(ringingSince = now))
|
||||
ring.startRinging(alarmId)
|
||||
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
|
||||
} finally {
|
||||
upcoming.forEach { syncUpcomingLocked(it, now) }
|
||||
}
|
||||
return FireOutcome.Ringing(alarmId)
|
||||
}
|
||||
|
||||
@@ -363,3 +451,10 @@ class AlarmEngine @Inject constructor(
|
||||
rescheduleLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/** What one reschedule pass hands the upcoming-alarm sync (#16). */
|
||||
private class UpcomingSync(
|
||||
val resolved: List<UpcomingAlarm>,
|
||||
/** Armed skips' watermarks, so a check can spend each one on time. */
|
||||
val pendingSkips: List<Instant>,
|
||||
)
|
||||
@@ -14,6 +14,15 @@ 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"
|
||||
|
||||
/** The user swiped the upcoming notification away; it carries [EXTRA_FIRE_AT] (#16). */
|
||||
const val ACTION_UPCOMING_SWIPED: String = PREFIX + "action.ALARM_UPCOMING_SWIPED"
|
||||
|
||||
/** 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 +35,10 @@ 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
|
||||
const val REQUEST_UPCOMING_SWIPED: Int = 10
|
||||
|
||||
val ALL_REQUEST_CODES: List<Int> = listOf(
|
||||
REQUEST_FIRE,
|
||||
@@ -34,5 +47,9 @@ object AlarmIntents {
|
||||
REQUEST_SNOOZE,
|
||||
REQUEST_DISMISS,
|
||||
REQUEST_REPOST,
|
||||
REQUEST_UPCOMING_CHECK,
|
||||
REQUEST_DISMISS_UPCOMING,
|
||||
REQUEST_SHOW_UPCOMING,
|
||||
REQUEST_UPCOMING_SWIPED,
|
||||
)
|
||||
}
|
||||
@@ -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,52 @@ class AndroidAlarmNotifier @Inject constructor(
|
||||
override fun clearSnoozed(alarmId: Long) {
|
||||
notifications.cancel(RingNotifications.snoozedNotificationId(alarmId))
|
||||
}
|
||||
|
||||
/**
|
||||
* What this process last posted, per alarm, so an unchanged notice on
|
||||
* screen is not re-sent. The engine calls [showUpcoming] under its lock, so
|
||||
* the map needs no lock of its own.
|
||||
*/
|
||||
private val lastPosted = mutableMapOf<Long, UpcomingNotice>()
|
||||
|
||||
override fun showUpcoming(notices: List<UpcomingNotice>) {
|
||||
runCatching {
|
||||
val wantedIds = notices.map { UpcomingNotifications.notificationId(it.alarm.id) }.toSet()
|
||||
// What is on screen, including what an earlier process posted.
|
||||
val onScreen = context.getSystemService(NotificationManager::class.java)
|
||||
.activeNotifications
|
||||
.filter { it.tag == UpcomingNotifications.TAG }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
// Also what this process posted: a notify is asynchronous, so one
|
||||
// posted a moment ago may not be listed as active yet.
|
||||
(onScreen + lastPosted.keys.map(UpcomingNotifications::notificationId))
|
||||
.filterNot { it in wantedIds }
|
||||
.forEach { notifications.cancel(UpcomingNotifications.TAG, it) }
|
||||
lastPosted.keys.retainAll(notices.map { it.alarm.id }.toSet())
|
||||
|
||||
// A swipe is the user's word for that occurrence; the record keeps
|
||||
// only the occurrences still noticed.
|
||||
val stillSwiped = UpcomingNotifications.retainSwiped(
|
||||
context,
|
||||
notices.map(UpcomingNotifications::key).toSet(),
|
||||
)
|
||||
|
||||
// POST_NOTIFICATIONS is a runtime permission from API 33. Denied, the
|
||||
// user loses the notice and keeps the alarm and the row's Dismiss.
|
||||
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 (UpcomingNotifications.key(notice) in stillSwiped) continue
|
||||
val id = UpcomingNotifications.notificationId(notice.alarm.id)
|
||||
if (id in onScreen && lastPosted[notice.alarm.id] == notice) continue
|
||||
notifications.notify(UpcomingNotifications.TAG, id, UpcomingNotifications.build(context, notice))
|
||||
lastPosted[notice.alarm.id] = notice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-2
@@ -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
|
||||
|
||||
@@ -5,12 +5,14 @@ import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
|
||||
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() {
|
||||
|
||||
@@ -26,12 +28,21 @@ class AlarmActionReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
||||
val action = intent.action ?: return
|
||||
if (action == AlarmIntents.ACTION_UPCOMING_SWIPED) {
|
||||
// Bookkeeping only: the alarm itself is untouched.
|
||||
UpcomingNotifications.recordSwiped(context, alarmId, intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L))
|
||||
return
|
||||
}
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
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,8 +13,9 @@ import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The two AlarmManager slots arriving. Thin by design: every decision the engine
|
||||
* could make is the engine's, so the receiver only unpacks and dispatches.
|
||||
* 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
|
||||
class AlarmFireReceiver : HiltBroadcastReceiver() {
|
||||
@@ -48,6 +49,8 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
|
||||
)
|
||||
|
||||
AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId)
|
||||
|
||||
AlarmIntents.ACTION_UPCOMING_CHECK -> engine.onUpcomingWindow()
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -216,7 +216,8 @@ object RingNotifications {
|
||||
private fun formatTime(context: Context, hour: Int, minute: Int): String =
|
||||
format(context, LocalTime.of(hour, minute))
|
||||
|
||||
private fun formatInstant(context: Context, instant: Instant): String = format(
|
||||
/** Also the upcoming notice's (#16): one formatter for every alarm notification. */
|
||||
internal fun formatInstant(context: Context, instant: Instant): String = format(
|
||||
context,
|
||||
JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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 android.os.Build
|
||||
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
|
||||
|
||||
/**
|
||||
* "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 `dismissUpcomingOccurrence`
|
||||
* for the one occurrence it names, so it can never end a ring that has already
|
||||
* started; it asks for an unlock first (and below Android 12, where it cannot,
|
||||
* the notice is kept off the secure lock screen), so a locked phone cannot be
|
||||
* talked out of its alarm by whoever picks it up.
|
||||
*/
|
||||
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 = RingNotifications.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)
|
||||
// The Dismiss asks for an unlock from Android 12; below that the
|
||||
// platform cannot, so there the notice stays off a locked screen.
|
||||
.setVisibility(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
NotificationCompat.VISIBILITY_PUBLIC
|
||||
} else {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
},
|
||||
)
|
||||
.setOnlyAlertOnce(true)
|
||||
// Not bridged to a watch, where its Dismiss would skip the unlock.
|
||||
.setLocalOnly(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))
|
||||
.setDeleteIntent(swipedPendingIntent(context, notice))
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_notification,
|
||||
context.getString(R.string.alarm_action_dismiss),
|
||||
dismissPendingIntent(context, notice),
|
||||
).setAuthenticationRequired(true).build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `id@fireAt` notices the user swiped away. Recorded only from the
|
||||
* delete intent, which fires for a swipe or "clear all" and never for the
|
||||
* app's own cancel, a reboot or a blocked channel — so a reschedule, even in
|
||||
* a new process, never brings back a notice the user got rid of, and a
|
||||
* notice the system took away does come back.
|
||||
*/
|
||||
@Synchronized
|
||||
fun recordSwiped(context: Context, alarmId: Long, fireAtMillis: Long) {
|
||||
val keys = read(context) + key(alarmId, fireAtMillis)
|
||||
prefs(context).edit().putStringSet(SWIPED_KEY, keys).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the keys for occurrences no longer in [noticed], so the record stays
|
||||
* small, and returns what is left. One synchronised read-modify-write, so a
|
||||
* swipe recorded from the receiver meanwhile is never overwritten.
|
||||
*/
|
||||
@Synchronized
|
||||
fun retainSwiped(context: Context, noticed: Set<String>): Set<String> {
|
||||
val swiped = read(context)
|
||||
val kept = swiped.intersect(noticed)
|
||||
if (kept != swiped) prefs(context).edit().putStringSet(SWIPED_KEY, kept).apply()
|
||||
return kept
|
||||
}
|
||||
|
||||
private fun read(context: Context): Set<String> =
|
||||
prefs(context).getStringSet(SWIPED_KEY, emptySet()).orEmpty().toSet()
|
||||
|
||||
fun key(notice: UpcomingNotice): String = key(notice.alarm.id, notice.fireAt.toEpochMilliseconds())
|
||||
|
||||
private fun key(alarmId: Long, fireAtMillis: Long): String = "$alarmId@$fireAtMillis"
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(RECORD_FILE, Context.MODE_PRIVATE)
|
||||
|
||||
/** 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,
|
||||
)
|
||||
|
||||
private fun swipedPendingIntent(context: Context, notice: UpcomingNotice): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
AlarmIntents.REQUEST_UPCOMING_SWIPED * REQUEST_CODE_STRIDE +
|
||||
(notice.alarm.id % REQUEST_CODE_STRIDE).toInt(),
|
||||
Intent(context, AlarmActionReceiver::class.java).apply {
|
||||
action = AlarmIntents.ACTION_UPCOMING_SWIPED
|
||||
putExtra(AlarmIntents.EXTRA_ALARM_ID, notice.alarm.id)
|
||||
putExtra(AlarmIntents.EXTRA_FIRE_AT, notice.fireAt.toEpochMilliseconds())
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Excluded from backup and device transfer: it describes this device's shade. */
|
||||
private const val RECORD_FILE = "alarm_upcoming_notices"
|
||||
private const val SWIPED_KEY = "swiped"
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -120,7 +120,17 @@ object AlarmResolver {
|
||||
// cannot ring on its way out through the grace window.
|
||||
nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark },
|
||||
source = FireSource.OCCURRENCE,
|
||||
state = working.copy(skippedOccurrence = watermark.takeUnless { consumed }),
|
||||
// A consumed skip becomes the handled-occurrence watermark: with both
|
||||
// the flag and the skip watermark gone, nothing else would stop the
|
||||
// next resolution inside the fire grace from ringing it after all.
|
||||
state = if (consumed) {
|
||||
working.copy(
|
||||
skippedOccurrence = null,
|
||||
handledOccurrence = maxOf(watermark, working.handledOccurrence ?: watermark),
|
||||
)
|
||||
} else {
|
||||
working.copy(skippedOccurrence = watermark)
|
||||
},
|
||||
clearSkipFlag = consumed,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,84 @@
|
||||
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.Duration.Companion.seconds
|
||||
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 there is nothing left to look at.
|
||||
*/
|
||||
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 of three instants still to come:
|
||||
*
|
||||
* - a window opening, so its notice goes up;
|
||||
* - a noticed occurrence's fire grace running out, so a notice whose fire
|
||||
* came late (Doze, no exact alarms) does not outlive its alarm;
|
||||
* - an armed skip's ([pendingSkips]) fire grace running out, so a skip
|
||||
* dismissed in advance is spent on time and the list stops saying
|
||||
* "skipping next" about an occurrence that has gone by.
|
||||
*
|
||||
* Both of the last two wait out the grace rather than land on the
|
||||
* occurrence itself: a fire for it still in flight is refused as too late
|
||||
* by then, so the check can never race it.
|
||||
*/
|
||||
fun plan(
|
||||
upcoming: List<UpcomingAlarm>,
|
||||
now: Instant,
|
||||
pendingSkips: List<Instant> = emptyList(),
|
||||
lead: Duration = LEAD,
|
||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
||||
): 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 openings = upcoming
|
||||
.filter { it.source == FireSource.OCCURRENCE }
|
||||
.mapNotNull { entry -> entry.nextFire?.let { it - lead } }
|
||||
// Strictly past the grace: `onFire` still accepts a fire at exactly it.
|
||||
val afterGrace = fireGrace + 1.seconds
|
||||
val closings = notices.map { it.fireAt + afterGrace }
|
||||
val skipsSpent = pendingSkips.map { it + afterGrace }
|
||||
val nextCheckAt = (openings + closings + skipsSpent).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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,4 +5,6 @@
|
||||
otherwise prompt again after a restore. Keep in step with
|
||||
data_extraction_rules.xml. -->
|
||||
<exclude domain="file" path="crash/" />
|
||||
<!-- Which upcoming-alarm notices were swiped away: this device's shade only. -->
|
||||
<exclude domain="sharedpref" path="alarm_upcoming_notices.xml" />
|
||||
</full-backup-content>
|
||||
@@ -1,10 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!-- As backup_rules.xml: everything but a pending crash report. -->
|
||||
<!-- As backup_rules.xml: everything but a pending crash report and the
|
||||
upcoming-notice swipe record. -->
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="crash/" />
|
||||
<exclude domain="sharedpref" path="alarm_upcoming_notices.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="crash/" />
|
||||
<exclude domain="sharedpref" path="alarm_upcoming_notices.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,257 @@
|
||||
package de.jeanlucmakiola.clockula.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||
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 de.jeanlucmakiola.clockula.testing.ringState
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* 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()
|
||||
// Next: the first notice's alarm, gone by.
|
||||
assertThat(harness.scheduler.upcomingCheck).isEqualTo(sixThirty + AlarmRing.FIRE_GRACE + 1.seconds)
|
||||
}
|
||||
|
||||
@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()
|
||||
// Back once the skipped occurrence's grace is over, to spend the skip.
|
||||
assertThat(harness.scheduler.upcomingCheck).isEqualTo(sixThirty + AlarmRing.FIRE_GRACE + 1.seconds)
|
||||
harness.wallClock.instant = sixThirty + AlarmRing.FIRE_GRACE + 1.seconds
|
||||
harness.engine.onUpcomingWindow()
|
||||
assertThat(harness.alarms.find(1L)!!.skipNextOccurrence).isFalse()
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fire already on its way when the notice was dismissed does not ring`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 6, minute = 30, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.engine.reschedule()
|
||||
harness.engine.dismissUpcomingOccurrence(1L, sixThirty)
|
||||
|
||||
// The broadcast for 06:30 was sent before the skip was armed.
|
||||
harness.wallClock.instant = sixThirty
|
||||
val outcome = harness.engine.onFire(1L, sixThirty)
|
||||
|
||||
assertThat(outcome).isEqualTo(FireOutcome.Ignored)
|
||||
assertThat(harness.ring.events).isEmpty()
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
|
||||
// The skip is spent on the one occurrence, not on tomorrow's.
|
||||
assertThat(harness.alarms.find(1L)!!.skipNextOccurrence).isFalse()
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Dismiss once the occurrence is due belongs to the fire, not the notice`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 6, minute = 30, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.engine.reschedule()
|
||||
|
||||
// Inside the fire grace, with the fire itself running late.
|
||||
harness.wallClock.instant = sixThirty + 1.minutes
|
||||
val outcome = harness.engine.dismissUpcomingOccurrence(1L, sixThirty)
|
||||
|
||||
assertThat(outcome).isEqualTo(DismissOutcome.Stale)
|
||||
assertThat(harness.alarms.find(1L)!!.skipNextOccurrence).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switched-off alarm has nothing to dismiss`(@TempDir tempDir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 6, minute = 30, enabled = false)
|
||||
|
||||
assertThat(harness.engine.dismissUpcomingOccurrence(1L, sixThirty)).isEqualTo(DismissOutcome.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a handled occurrence a clock moved backwards re-armed still rings`(@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)
|
||||
harness.engine.dismiss(1L)
|
||||
|
||||
// The clock is set back under the dismissed alarm; the resolver re-arms it.
|
||||
harness.wallClock.instant = sixThirty - 15.minutes
|
||||
harness.engine.onSystemTimeOrZoneChanged()
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirty)
|
||||
|
||||
harness.wallClock.instant = sixThirty
|
||||
assertThat(harness.engine.onFire(1L, sixThirty)).isEqualTo(FireOutcome.Ringing(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an old skip gone by but not yet spent does not swallow the new dismissal`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(
|
||||
id = 1L,
|
||||
hour = 6,
|
||||
minute = 30,
|
||||
repeatDays = RepeatDays.EVERY_DAY,
|
||||
skipNextOccurrence = true,
|
||||
)
|
||||
// Yesterday's skip, never spent because nothing rescheduled since.
|
||||
harness.states.save(ringState(alarmId = 1L, skippedOccurrence = offsetInstant("2026-06-09T06:30+02:00")))
|
||||
|
||||
val outcome = harness.engine.dismissUpcomingOccurrence(1L, sixThirty)
|
||||
|
||||
assertThat(outcome).isEqualTo(DismissOutcome.SkippedNext(1L))
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ class AlarmIntentsTest {
|
||||
AlarmIntents.ACTION_AUTO_SILENCE,
|
||||
AlarmIntents.ACTION_SNOOZE,
|
||||
AlarmIntents.ACTION_DISMISS,
|
||||
AlarmIntents.ACTION_UPCOMING_CHECK,
|
||||
AlarmIntents.ACTION_DISMISS_UPCOMING,
|
||||
AlarmIntents.ACTION_UPCOMING_SWIPED,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -28,6 +31,10 @@ class AlarmIntentsTest {
|
||||
AlarmIntents.REQUEST_SHOW,
|
||||
AlarmIntents.REQUEST_SNOOZE,
|
||||
AlarmIntents.REQUEST_DISMISS,
|
||||
AlarmIntents.REQUEST_UPCOMING_CHECK,
|
||||
AlarmIntents.REQUEST_DISMISS_UPCOMING,
|
||||
AlarmIntents.REQUEST_SHOW_UPCOMING,
|
||||
AlarmIntents.REQUEST_UPCOMING_SWIPED,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,23 @@ class AlarmResolverTest {
|
||||
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a consumed skip cannot ring on the next pass through the fire grace`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
|
||||
val watermark = offsetInstant("2026-06-10T07:00+02:00")
|
||||
val first = resolve(alarm, ringState(skippedOccurrence = watermark), now = watermark + 30.seconds)
|
||||
|
||||
// The engine persists the first pass: the flag cleared, the state saved.
|
||||
val second = resolve(
|
||||
alarm.copy(skipNextOccurrence = false),
|
||||
first.state,
|
||||
now = watermark + 40.seconds,
|
||||
)
|
||||
|
||||
assertThat(first.state.handledOccurrence).isEqualTo(watermark)
|
||||
assertThat(second.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipping a one-shot alarm disables it`() {
|
||||
val alarm = alarmAt(hour = 7, skipNextOccurrence = true)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* 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(
|
||||
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 `a noticed occurrence is looked at again once its fire grace has run out`() {
|
||||
val fireAt = now + 30.minutes
|
||||
val plan = UpcomingNotices.plan(
|
||||
listOf(
|
||||
upcomingAlarm(id = 1L, nextFire = fireAt),
|
||||
upcomingAlarm(id = 2L, nextFire = now + 5.hours),
|
||||
),
|
||||
now,
|
||||
)
|
||||
|
||||
// So a notice whose fire came late does not outlive its alarm.
|
||||
assertThat(plan.nextCheckAt).isEqualTo(fireAt + AlarmRing.FIRE_GRACE + 1.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an armed skip is looked at again once its occurrence has gone by`() {
|
||||
val skipped = now + 40.minutes
|
||||
val plan = UpcomingNotices.plan(
|
||||
listOf(upcomingAlarm(id = 1L, nextFire = now + 1.days)),
|
||||
now,
|
||||
pendingSkips = listOf(skipped),
|
||||
)
|
||||
|
||||
// So the skip is spent then, not at tomorrow's window.
|
||||
assertThat(plan.nextCheckAt).isEqualTo(skipped + AlarmRing.FIRE_GRACE + 1.seconds)
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
+75
-10
@@ -112,7 +112,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 |
|
||||
@@ -253,9 +253,10 @@ so the skip eats a second alarm. With the watermark:
|
||||
one after;
|
||||
- watermark still in the future ⇒ fire the first candidate after it; write
|
||||
nothing;
|
||||
- watermark at or before now ⇒ consumed: clear the flag and the watermark, and
|
||||
*still* return the first candidate after it, so the skipped occurrence cannot
|
||||
ring on its way out through the grace window.
|
||||
- watermark at or before now ⇒ consumed: clear the flag and the watermark,
|
||||
record the skipped occurrence as the handled one, and *still* return the
|
||||
first candidate after it, so the skipped occurrence cannot ring on its way
|
||||
out through the grace window, on this pass or the next (#16).
|
||||
|
||||
Arming is deferred while a snooze is pending, and a **non-repeating** alarm with
|
||||
the flag set is *disabled* rather than skipped: a one-shot has exactly one
|
||||
@@ -734,7 +735,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`,
|
||||
@@ -969,7 +970,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 |
|
||||
@@ -1179,7 +1180,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
|
||||
@@ -1207,17 +1209,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.
|
||||
|
||||
@@ -1225,6 +1228,68 @@ 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 check also wakes once a noticed occurrence's fire grace has
|
||||
run out (so a notice cannot outlive a fire that came late) and once an armed
|
||||
skip's has (so a skip dismissed in advance is spent on time, and the list
|
||||
stops saying "skipping next" about an occurrence already gone). Both wait out
|
||||
the grace rather than land on the occurrence itself: a fire for it still in
|
||||
flight is refused as too late by then, so the check never races it. The sync is wrapped so a notification failure costs nothing,
|
||||
and on the fire path it is deferred until the ring has started, so its binder
|
||||
calls never stand between the fire and the sound. 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 *and still in
|
||||
the future*; otherwise it is `DismissOutcome.Stale` and dismisses 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). A fire broadcast already in flight when a skip
|
||||
was armed is `Ignored` by `onFire`, which checks the armed skip watermark
|
||||
against the instant it carries. Only the skip: the handled-occurrence
|
||||
watermark is left to the resolver, which deliberately re-arms a handled
|
||||
occurrence that a clock moved backwards has put back in the future. A
|
||||
repeating alarm's resolution is
|
||||
persisted before the skip is armed, so an old skip that has gone by but was
|
||||
never spent cannot absorb the new one. One window is left open on purpose: a Dismiss
|
||||
whose handling straddles the occurrence by milliseconds, with another
|
||||
reschedule spending the skip before the fire already in flight is handled,
|
||||
can still let that fire ring. Closing it means keeping a skip tombstone apart
|
||||
from the flag, and each attempt to do so opened a worse window elsewhere (a
|
||||
skip switched off or re-armed inside the grace); a dismissed alarm ringing in
|
||||
a millisecond race costs less than either.
|
||||
- **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. The notification's Dismiss does ask
|
||||
for an **unlock** (`setAuthenticationRequired`), so a locked phone cannot be
|
||||
talked out of its alarm by whoever picks it up. That flag needs Android 12;
|
||||
below it the notice is `VISIBILITY_SECRET`, kept off the secure lock screen.
|
||||
It is local-only, so it is never bridged to a watch, where the unlock would
|
||||
not apply.
|
||||
- **Denied notifications** cost the notice and nothing else; the row's chip
|
||||
still works. A swipe is recorded as an `id@fireAt` key in a small
|
||||
SharedPreferences file through the notification's delete intent, which fires
|
||||
for the user's swipe and never for the app's own cancel, a reboot or a
|
||||
blocked channel. So a reschedule, even in a new process, does not bring back
|
||||
a notice the user got rid of, and one the system took away does come back.
|
||||
|
||||
**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
|
||||
|
||||
Reference in new issue
Block a user