Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/notifications/ClockChangeReceiver.kt

54 lines
2.2 KiB
Kotlin
Raw Normal View History

fix: make a reminder arrive when the user asked for it Changing the reminder time did not move the reminder. WorkManager's UPDATE policy carries the previous request's lastEnqueueTime and periodCount forward, and a periodic request computes its next run as periodCount == 0 ? lastEnqueueTime + initialDelay : lastEnqueueTime + interval. Both halves bit: After the first run, a new initial delay is ignored entirely -- Morning to Evening did nothing at all. Before it, the delay is applied to the ORIGINAL enqueue time, and the coordinator reschedules at every process start, so asking for 19:00 at 09:00 on work enqueued at 08:00 produced 18:00, with every later period anchored off that. The scheduler reads the existing work first: KEEP when nothing is scheduled, leave an overdue run alone -- moving it skips today's reminder entirely -- leave a run already within five minutes alone, and otherwise UPDATE with an explicit setNextScheduleTimeOverride, which is the only way to say WHEN rather than how long from a moment WorkManager has its own opinion about. CANCEL_AND_REENQUEUE is wrong for a subtler reason: this runs at every process start including the one WorkManager started to run the worker, and cancelling the unique work there cancels the worker. A time zone or clock change now re-aims it. The delay was computed once, from the zone in force then, so flying east left the reminder arriving at the old wall-clock time indefinitely. WorkManager's own RescheduleReceiver declares BOOT_COMPLETED and nothing else -- which is why ClockChangeReceiver exists for the other two broadcasts, and why it does not duplicate boot. Unexported, no permission, checkPermissions still green. No flex window was added, and the screen's copy changed instead. Flex would have made "a few minutes either side" true and placed the first run nearly a full period out, skipping the reminder on the day the user set it -- to keep a sentence. It now says Android may deliver a few minutes after, never before, which is what actually happens. schedule() had no test; only the arithmetic beneath it did. Nine now, against WorkManager's own recorded next-run time, sharing one clock with it -- a test that fixes only the scheduler's measures a 2026 delay against a real System.currentTimeMillis(). Writing them was necessary rather than tidy: the first version of the change-the-time test passed with the defect still in place, because both schedules happened at the same instant and the bug only bites once time has moved. The test that catches it advances the clock an hour between them, which is what a real second process start does. closes #72 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:42:09 -05:00
package dev.privacyllc.period.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
/**
* The clock or the time zone moved; re-aim the reminder.
*
* The delay to the next reminder is computed once, when it is scheduled, from
* the zone in force at that moment. Nothing recomputed it afterwards, so flying
* to another continent left the reminder arriving at the old wall-clock time
* indefinitely and a manual clock change did the same.
*
* **Boot is deliberately not handled here.** WorkManager persists the periodic
* request and its own `RescheduleReceiver` restores it after a reboot; that
* receiver's manifest entry declares `BOOT_COMPLETED` and nothing else, which is
* exactly why the two broadcasts below need an entry of their own. A second boot
* receiver would duplicate work already done, and the coordinator's schedule at
* process start re-anchors any drift anyway.
*
* Unexported, like WorkManager's own: these are system broadcasts, and nothing
* else has any business sending them to this app.
*
* Reached through an entry point rather than `@AndroidEntryPoint`. The annotation
* generates a base class whose `onReceive` performs the injection and which the
* subclass must call through `super` and `BroadcastReceiver.onReceive` is
* abstract, so that call resolves against the wrong class whenever the generated
* one is not yet on the compile path. An accessor has no such ordering.
*/
class ClockChangeReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Dependencies {
fun reminderCoordinator(): ReminderCoordinator
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_TIME_CHANGED -> {
EntryPointAccessors
.fromApplication(context.applicationContext, Dependencies::class.java)
.reminderCoordinator()
.clockChanged()
}
}
}
}