feat(alarm): add true per-minute live countdown to pre-notice
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s

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:
2026-06-30 22:10:41 +02:00
parent f5cf261f12
commit 5877c2a4ee
2 changed files with 91 additions and 3 deletions
@@ -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