feat(timer): the engine, its expiry slot, the service and the receivers

One AlarmManager slot for every timer, registered on ELAPSED_REALTIME_WAKEUP
so the clock the domain anchors on is the clock the platform wakes on. Its
fire carries no id and is an idempotent sweep, backed up by a second trigger
inside the service, because neither has to be reliable on its own.

The foreground service posts per state change rather than per second — the
platform chronometer draws the countdown — and its actions are broadcasts, so
pause and reset still work with the process dead. Every verb is guarded
against a stale id.

Expiry reuses the alarm's audio path: the same player, vibrator, source policy
and fallback-to-vibration chain, with a zero ramp and its own channel. No
full-screen intent, no challenge, no snooze — a timer is not an alarm. Two
timers expiring together share one ring; a timer expiring after a previous
one's auto-silence window lapsed gets a sound of its own, which is the whole
point of having a timer.
This commit is contained in:
2026-09-12 16:48:36 +02:00
parent 19efb67740
commit 7f9219e929
18 changed files with 1971 additions and 4 deletions
+22
View File
@@ -104,6 +104,28 @@
android:name=".alarm.receiver.AlarmActionReceiver"
android:exported="false" />
<!-- The timers: one foreground service covering both phases, the
countdown and the ring. systemExempted for the same reason the
alarm's ringing service is, and M6 adds **no** permission for it —
the foreground-service, wake-lock, vibrate and notification set
above already covers it (slice plan D6, D18). -->
<service
android:name=".timer.service.TimerService"
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- The single expiry slot arriving. It carries no timer id: the fire is
an idempotent sweep (D5). Unexported, like the alarm's. -->
<receiver
android:name=".timer.receiver.TimerExpiryReceiver"
android:exported="false" />
<!-- The timer notification's buttons. Broadcasts rather than service
starts, so they work with the process dead (D16). -->
<receiver
android:name=".timer.receiver.TimerActionReceiver"
android:exported="false" />
<!-- Protected system broadcasts are delivered to unexported receivers —
this is how androidx.work declares its own RescheduleReceiver.
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the