From 5877c2a4ee248ad43e452cf31269f44e89856373 Mon Sep 17 00:00:00 2001 From: freetlab Date: Tue, 30 Jun 2026 22:10:41 +0200 Subject: [PATCH] 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. --- .../freetimelab/pluriwave/AlarmScheduler.kt | 70 +++++++++++++++++++ .../pluriwave/PluriWaveAlarmReceiver.kt | 24 ++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 2efefe7..05d3cf1 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -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) ) diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index 3171d08..5902a78 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -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