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:
1 parent
3f431a7f32
commit
e43339c386
25 files changed
+785
-33
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user