MaksForge

Binky

Android app

An Android app for keeping track of a bunny's health.

Live

Dose reminders use exact alarms; care reminders use WorkManager

Care reminders (nail trim, vaccination, weigh-in) are due “around” a date, so WorkManager is the right fit — it survives reboot and Doze with no special permission. Medication doses are different: a course is typically once or twice daily at set times, and a late or dropped reminder can mean a missed dose during treatment. Those therefore use AlarmManager.setExactAndAllowWhileIdle with a BOOT_COMPLETED receiver.

Dose reminders default to on when a course has a schedule, and can be switched off per course — an owner whose bunny is not in a risky condition, or who dislikes alarms, should not be forced into them.

Dose timing is wall-clock, not a fixed interval

A course’s clock times are wall-clock: an “08:00 dose” is 08:00 local time, because the owner gives the pill relative to their own day (breakfast, bedtime), not to an abstract 24-hour interval. The next dose’s absolute trigger is therefore resolved fresh each time — the LocalTime on the next date, in the device’s current zone and DST rules, converted to an Instant — so DST changes and travel are handled by construction rather than by arithmetic on a stored epoch value. Using java.time ZonedDateTime default resolution, a dose whose wall-clock time falls in a spring-forward gap fires once, shifted forward to the first valid instant; one in a fall-back overlap fires once, at the earlier offset. Never zero times, never twice.

A pre-computed absolute alarm points at the wrong wall-clock the moment the clock or zone changes, so ACTION_TIMEZONE_CHANGED and ACTION_TIME_CHANGED receivers recompute pending dose alarms, alongside the BOOT_COMPLETED receiver. Care reminders are day-granularity and all-day, so this applies to doses only.

Consequences

The exact-alarm permission is SCHEDULE_EXACT_ALARM, requested via a prompt into system settings — see ADR-0009 for why USE_EXACT_ALARM is not used.

Two scheduling mechanisms exist deliberately; don’t unify them. The exact-alarm path runs only while a medication course is active, which is expected to be rare.

Reliability on aggressive Android skins (the test device is Xiaomi HyperOS) depends on the app being exempted from battery optimisation and allowed to autostart. Neither mechanism fires reliably without that, so the app must detect it and ask, rather than assuming scheduling works.

A dose reminder that silently fails to fire is worse than none: the owner stops watching for the dose themselves, trusting a prompt that never comes, so unreliability doesn’t degrade the feature — it inverts it into a hazard. Asking for the exemption is therefore not enough:

  • Hard reliability gate. Before dose reminders are treated as trustworthy, a dose alarm must be proven to fire after the phone has sat idle in Doze overnight — screen off, app unopened for 12h+ — on the real Xiaomi. The two-minute happy path does not clear this gate.
  • Honest state, not a false alarm. While battery-optimisation exemption and autostart are not confirmed, a dose reminder shows as best-effort (“may not fire reliably on this phone until you enable X”), never as an armed alarm. If the overnight gate cannot be met, dose reminders ship explicitly as best-effort — not as a safety-critical alarm the app cannot stand behind on its own hardware.

Amendment (Phase 4a): autostart cannot gate the honest state, because nothing can read it

The condition above names two things — battery-optimisation exemption and autostart. Only the first has an API. PowerManager.isIgnoringBatteryOptimizations answers it without any permission; HyperOS autostart has no public state, and launching its settings screen returns no result. So “autostart confirmed” is permanently false on the one device this project tests on, and a strict reading of the sentence means every reminder in the app carries a best-effort hedge forever, no matter what the owner does.

That inverts the rule it comes from. A permanent hedge is wallpaper in exactly the way a permanent nag is: it stops carrying information, and by the time it wraps dose reminders it is supposed to mean something.

So, as built:

  • Armed depends on the detectable exemption. Autostart is offered once, alongside the exemption ask, where the Xiaomi intent resolves — and the app then claims nothing about it in either direction.
  • The owner is not asked to confirm autostart. A checkbox would have the app repeating the owner’s guess back to them as its own assurance, which is this ADR’s central hazard sourced from a new place.
  • The evidence is the overnight-Doze gate, run on the real device (PLAN 4g). Evidence from the hardware beats an unreadable flag.

Phase 4 also widens the state from two to three, because a denied notification permission or a muted channel is not best-effort — it is certain, and it is detectable. Reminders present as blocked, best-effort or armed, resolved by one pure function that Phase 5 inherits for doses.

Amendment (Phase 9, 9a): the autostart list gates the honest state after all — and this time it was measured

4a’s amendment above reasoned from an absence: autostart has no readable state, therefore conditioning armed on it makes armed unreachable forever, therefore leave it out. That is sound as far as it goes, and it went one step too far — it treated an unreadable fact as an unimportant one. 9a measured the fact.

Two runs on the test device, one variable between them, everything else identical (unplugged, stationary, SCHEDULE_EXACT_ALARM granted, alarm verified window=0 and whenElapsed == maxWhenElapsed before the run):

  • Autostart denied. device_idle=full unbroken from 01:07:08 to 03:07:09, straight across a 03:00 dose. No alarm fired at all. The dose was delivered 3h50m47s late, at 06:50:47 — the instant the phone was plugged in. The logs name the mechanism: GreezeManager: THAW uid = 10507, then Aurogon: sendPendingAlarm uid = 10507 half a second later. HyperOS had frozen the process and its power framework held the pending alarm until something thawed it.
  • Autostart granted. Same course, same phone. Fired at 10:00:00.779, fifty minutes inside an unbroken 59m51s stretch of device_idle=full, on battery. No GreezeManager or Aurogon line for the app anywhere in the capture — it was never frozen.

So the freezer is not Doze, and it is not something the app can out-argue: neither SCHEDULE_EXACT_ALARM, nor window=0, nor the alarm’s own temporaryAppAllowlistReasonCode=302 outranks it. It holds the process, so no mechanism this app schedules sits above it.

That falsifies the claim ReminderDelivery.Armed was making. As built since 4a, an owner on a Xiaomi who granted the battery exemption was told “this phone is set up to let them through” by an app that had no basis for saying so — and this ADR’s opening argument is that a dose reminder which silently fails is worse than none, because it inverts the feature into a hazard. So, as built now:

  • Where an OEM autostart list exists, Armed is out of reach, and hasAutostartSettings() is the input that puts it there. That resolves to “this phone keeps such a list”, never “the app is off it” — which is the weaker claim, and the only one available. Being hedged at while already on the list is the cost, and it is the right way round: over-hedging annoys, over-promising loses a dose.
  • The ceiling is best-effort with the reason named and the way in attached. The autostart sentence is the third variant of that state, after the exemption and the exact-alarm ones, and it is reached only once both of those are satisfied — so nobody is shown two background-limit lines at once, and the line that does show is the one thing left to do.
  • The copy says hours and says when the reminder turns up, because that is what was observed and because “may be delayed” is the phrasing an owner reads past. This is the one delivery line permitted to be that concrete; it earned it.
  • 4a’s other two rules stand unchanged. The owner is still never asked to confirm autostart, and the app still claims nothing about the list in either direction. What changed is that not knowing now costs the promise rather than being waved through.
  • The wallpaper objection is answered by scope, not by volume. The hedge appears only on phones that actually keep such a list, and it carries a fix; a stock phone still reaches armed on the exemption alone. oemAutostartUnreadable defaults to false for exactly that reason.

The evidence rule from 4a is unchanged and is what settled this: evidence from the hardware beats an unreadable flag — including when it beats the conclusion the last amendment drew from one.

Amendment (Phase 9, 9b): a fourth state, for the delivery that happens and is not heard

The three states have always split on whether something arrives. 9b found a fourth axis under that: something arrives, on time, exactly as scheduled — and makes no sound.

Switch the doses channel off and on again in system settings and HyperOS returns it at IMPORTANCE_LOW (2), not the IMPORTANCE_HIGH (4) the app created it with, and sets mUserLockedFields — the framework’s record that a person has touched the channel, after which createNotificationChannel may only ever lower it. A relaunch does not help. Only pm clear puts it back. At importance 2 a dose reminder posts with no sound and no heads-up, and resolveReminderDelivery returned Armed for it, because only IMPORTANCE_NONE was treated as blocked.

For a 03:00 dose, that is most of the way to not posting at all — and the app was calling it the one state it reserves for a promise.

So, as built now:

  • ReminderDelivery.Silent sits between Blocked and BestEffort, returned for any importance below IMPORTANCE_DEFAULT. The ordering rule is the one this ADR has used since 4a — certainty before likelihood — and a silenced channel is certain in the same way a muted one is. What keeps it out of Blocked is that something genuinely does arrive; it waits in the shade instead of being dropped, so the app owes a caveat rather than a refusal to promise.
  • A fourth enum entry, not a fourth branch in caveatFor. The caveat-only version of this fix is smaller and leaves the resolver saying Armed — in the one function whose entire purpose is to be the honest answer, and to every other reader of it. Making it a state turns the fix into a compile error at each call site, which is how the opt-in screen’s delivery line was corrected in the same pass.
  • The cliff is IMPORTANCE_DEFAULT, not the channel’s own creation level. Below DEFAULT Android plays no sound; at DEFAULT a doses channel lowered from HIGH keeps its sound and loses only the heads-up. The app says nothing about the latter, deliberately: “it will arrive silently” would be false, and the sound is the half an owner responds to. Hedging about a pop-up would spend the one card on the smaller fact.
  • The way in is the channel’s own settings page, via ACTION_CHANNEL_NOTIFICATION_SETTINGS — one screen deeper than every other notification caveat here, because the app-wide page does not hold this control and landing an owner above it turns a two-tap fix into a hunt through four channel names.
  • The app never says the owner chose this. It may well not have been chosen at all — the vendor restores the channel at LOW whatever it was created at — so the copy states the consequence and offers the screen, and makes no claim about how the level got there.

And the state 9b found that is deliberately not here. Under File-Based Encryption with a secure lock screen, ACTION_BOOT_COMPLETED arrives at the owner’s first unlock, not when the kernel finishes booting, so a restart at 02:00 picked up at 07:00 leaves a 03:00 dose never armed rather than late (ADR-0025’s amendment). It gets no state and no line, because it fails the rule the other three pass: the app speaks when it can read the fact and the owner can act on it. Nothing is running to read this one, so anything said would be printed unconditionally on every phone forever — which is the same wallpaper the armed state is silent to avoid.

Hi, I'm Maksymilian.

MaksForge is where I build the small tools I wish existed. Each one solves a single problem.