feat(alarm): add true per-minute live countdown to pre-notice
Mirror the shipped snooze-countdown chain for the 30-min pre-notice notification: re-arm ACTION_PRE_NOTICE at each minute boundary via slot 9, self-stop at remaining<=1, self-heal from wall clock on missed ticks. Wire cancellation at all 5 sites (cancelAlarm, scheduleSpec no-trigger branch, snooze-transition branch, ACTION_SKIP_NEXT, ACTION_POSTPONE_NEXT) using AlarmScheduler's own requestCode formula to keep PendingIntent identity consistent.
This commit is contained in:
@@ -90,6 +90,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
cancelPending("fire", pendingFireIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
||||
cancelPending("show", pendingShowIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
||||
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
||||
cancelPreNoticeCountdown(spec.id)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -139,6 +140,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (spec.snoozeUntilMillis != null) {
|
||||
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
||||
cancelPreNoticeCountdown(spec.id)
|
||||
Log.d(tag, "alarm.schedule preNotice skipped for snooze id=${spec.id}")
|
||||
return
|
||||
}
|
||||
@@ -471,6 +473,73 @@ class AlarmScheduler(private val context: Context) {
|
||||
cancelPending("snoozeCountdown", pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arms the pre-notice tick chain at the next minute boundary so the
|
||||
* notification keeps live-updating every minute, mirroring
|
||||
* [armNextSnoozeCountdownTick]. PUBLIC: called by the receiver after it
|
||||
* posts/updates the pre-notice notification for [id]. Self-stops once
|
||||
* [remaining] is down to the final minute, leaving the real fire alarm
|
||||
* (setAlarmClock) as the sole next trigger.
|
||||
*/
|
||||
fun armNextPreNoticeCountdownTick(
|
||||
id: String,
|
||||
title: String,
|
||||
snoozeMinutes: Int,
|
||||
triggerAtMillis: Long,
|
||||
occurrenceAtMillis: Long,
|
||||
remaining: Long
|
||||
) {
|
||||
// On the final minute the real fire alarm takes over, so there is no
|
||||
// further tick to schedule.
|
||||
if (remaining <= 1L) return
|
||||
val nextBoundary = triggerAtMillis - (remaining - 1L) * 60_000L
|
||||
val pending = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
requestCode(id, 9),
|
||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, triggerAtMillis)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, occurrenceAtMillis)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
try {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
nextBoundary,
|
||||
pending
|
||||
)
|
||||
Log.d(tag, "alarm.preNoticeCountdown next tick id=$id at=$nextBoundary")
|
||||
} catch (error: SecurityException) {
|
||||
Log.w(tag, "alarm.preNoticeCountdown tick SecurityException id=$id", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the pending pre-notice tick alarm (slot 9) for [id], mirroring
|
||||
* [cancelSnoozeCountdown]. PUBLIC: called both internally (cancelAlarm,
|
||||
* scheduleSpec no-trigger branch, snooze-transition branch) and by the
|
||||
* receiver (ACTION_SKIP_NEXT, ACTION_POSTPONE_NEXT) so every site that can
|
||||
* end the pre-notice chain uses the SAME requestCode formula (31*hash+9)
|
||||
* as [armNextPreNoticeCountdownTick] above. Using the receiver's own
|
||||
* requestCode formula here would produce a different value and the
|
||||
* PendingIntent cancel would silently fail, leaking a repeating alarm.
|
||||
*/
|
||||
fun cancelPreNoticeCountdown(id: String) {
|
||||
val pending = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
requestCode(id, 9),
|
||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
|
||||
},
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
cancelPending("preNoticeCountdown", pending)
|
||||
}
|
||||
|
||||
private fun postSnoozeCountdownNotification(spec: NativeAlarmSpec, remaining: Long) {
|
||||
ensurePreNoticeChannel()
|
||||
val text = AlarmNotificationStrings.snoozeCountdownText(appContext, remaining)
|
||||
@@ -559,6 +628,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
|
||||
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
|
||||
cancelSnoozeCountdown(id)
|
||||
cancelPreNoticeCountdown(id)
|
||||
NotificationManagerCompat.from(appContext).cancel(
|
||||
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
}
|
||||
ACTION_POSTPONE_NEXT -> {
|
||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
||||
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
|
||||
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
|
||||
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
@@ -76,6 +77,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
}
|
||||
ACTION_SKIP_NEXT -> {
|
||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
||||
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
|
||||
AlarmScheduler(context).skipNext(alarmId)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
@@ -197,14 +199,30 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
} catch (error: SecurityException) {
|
||||
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
|
||||
}
|
||||
|
||||
// Re-arm the next minute tick so the countdown keeps live-updating
|
||||
// until the real alarm fires. Reuses the SAME [remaining] computed
|
||||
// above for the notification text to avoid a second clock read that
|
||||
// could drift and cause an off-by-one between displayed text and the
|
||||
// next-boundary math.
|
||||
AlarmScheduler(context).armNextPreNoticeCountdownTick(
|
||||
id = alarmId,
|
||||
title = title,
|
||||
snoozeMinutes = snoozeMinutes,
|
||||
triggerAtMillis = triggerAtMillis,
|
||||
occurrenceAtMillis = occurrenceAtMillis,
|
||||
remaining = remaining
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the number of minutes remaining until [triggerAtMillis],
|
||||
* clamped to a minimum of 1. Handles Doze-delayed wakeups and clock drift.
|
||||
* Computes the number of minutes remaining until [triggerAtMillis] using
|
||||
* ceiling rounding (consistent with [AlarmScheduler]'s snooze-countdown
|
||||
* ceilMinutes), clamped to a minimum of 1. Handles Doze-delayed wakeups
|
||||
* and clock drift.
|
||||
*/
|
||||
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
|
||||
maxOf(1L, (triggerAtMillis - System.currentTimeMillis()) / 60_000L)
|
||||
maxOf(1L, (triggerAtMillis - System.currentTimeMillis() + 59_999L) / 60_000L)
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
Reference in New Issue
Block a user