Two field reports, one chain: a dismissed alarm came back. Measured on the
emulator, three separate causes, all fixed here.
1. The alarm-clock entry launched an activity. On Android 14+ that launch is
refused from the background and then *deferred*, so the window surfaced
later — after the alarm had been acknowledged — and the siren played again
for a delivered alert. The entry is now a broadcast to AlarmWakeReceiver,
which only wakes the process and re-posts the takeover notification, and
ignores an entry that outlived its arming. The one activity PendingIntent
left is the alarm-clock show intent, which only the user can invoke.
2. A dismissal could be lost. `acknowledgeAlarm` returned early when the
device identity had not loaded, and the queue's entry required a deviceId,
so the ack was silently dropped and the ledger still owed the alarm. The
queue accepts an entry without one (measured: the service takes a
device-token ack with no deviceId) and the flush resolves the identity.
Nothing raises — and the armed service does not start listening — until the
queued dismissals have been read, so a restart cannot ring before the seed.
3. Acknowledging now unsubscribes the device (owner ruling: a dismissal is
leaving). The ack settles the ledger first, then the subscription row goes;
the takeover closes itself and Home reports "Acknowledged — this device
unsubscribed". A leftover subscription was a live address that kept ringing
on every launch.
Also: an alarm is only sounded for an alert this device still follows (or a
trigger event that actually arrived), and the takeover never renders the raw
alert id as its headline — that fallback is what made a stale launch look like
an alarm for "dbf1a4c2-…".
Verified on the emulator: join → trigger → acknowledge → subscription row gone,
alert delivered, app returns Home by itself; force-stop and reopen is silent
for the whole session (no audio, no notification, no takeover).
Measured on an Android 15 emulator with the app backgrounded and the display
asleep: the siren played, the platform refused the takeover
("Background activity launch blocked! … BAL_BLOCK"), and the alarm was
destroyed mid-ring by its own receipt ack.
Takeover:
- the foreground service posts an alarm_firing notification whose
full-screen intent raises AlarmActivity — the only background launch
Android 14+ permits. Verified with the display asleep: wakefulness
Asleep → Awake, topResumedActivity=.AlarmActivity, the FSI on the record.
- the cancel lives on the four real silences (disarm, stop, JS ack, activity
destroy), not in AlarmPlayer.stop: play() calls stop() first, which
retracted the notification ~120 ms after posting it.
- "Back to alerts" now finishes the alarm window (AlarmModule
finishTakeover). A route pop is not an activity: the window stayed on top
for good with show-when-locked, keep-screen-on and no Recents entry — the
reported "no way back to the main menu".
The native-delivery contract test said "no notification ever announces an
alarm"; that was falsified on device, so it now pins one FSI notification and
a silent firing channel instead.
Verified end to end on the emulator (two runs, one with the screen off):
join without an account, armed service, background trigger → wake + takeover
+ siren, acknowledge → silence, notification retracted, alert delivered,
takeover closed by itself, Home reachable, reopening silent.
The manifest sets android:usesCleartextTraffic explicitly (a LAN instance may
be plain HTTP), so the build-type placeholder no longer feeds anything and its
comment claimed a mechanism that is not the one in use.
A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.