Acknowledge unsubscribes, and nothing re-rings after a restart

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).
This commit is contained in:
2026-10-20 00:48:33 +01:00
parent 06afd92647
commit 0771a69d7d
10 changed files with 263 additions and 38 deletions
+17 -12
View File
@@ -11,10 +11,14 @@
* - a dismissal that could not name one (the native takeover launch carries
* only the alert) is recorded as `*`: it suppresses the trigger current at
* dismissal time, and is pinned to that concrete event as soon as a status
* report reveals it, so a later trigger is not swallowed;
* - a live socket frame is a genuinely new trigger from the owner (replays of
* acknowledged deliveries never reach the client), so it clears the memory
* for that alert instead of being suppressed.
* report reveals it;
* - a dismissal is **not** undone by the transport. It used to be: a live
* socket frame cleared the memory on the theory that a live frame must be a
* new trigger. That is wrong on this service — an alert is triggered once
* and then disarmed or delivered, so a live frame for a dismissed alert is
* the same alarm arriving down another pipe (measured: after dismissing, the
* deferred native takeover replayed a frame and the siren came back). The
* only thing that clears the memory is the alert ending (`forget`).
*
* Pure and in-memory on purpose: the durable part of a dismissal is the ack
* queue, and correctness must not depend on storage having finished writing.
@@ -31,8 +35,9 @@ export interface DismissalMemory {
/**
* Should a trigger raise be suppressed?
*
* `live` frames are new triggers and clear the memory; status/check-in
* raises are suppressed when this device already dismissed that event.
* Suppressed when this device dismissed the alert — whether the raise came
* from a status report or the socket (`live` only changes where the frame
* came from, never whether the user already dismissed it).
*/
suppress(alertId: string, eventId: string | undefined, live: boolean): boolean;
forget(alertId: string): void;
@@ -53,14 +58,14 @@ export function createDismissals(): DismissalMemory {
seen.delete(alertId);
};
const suppress = (alertId: string, eventId: string | undefined, live: boolean) => {
if (live) {
forget(alertId);
return false;
}
// `live` is accepted for call-site symmetry and deliberately ignored: the
// transport a frame arrived on says nothing about whether a human dismissed
// the alarm (see the header comment).
const suppress = (alertId: string, eventId: string | undefined, _live: boolean) => {
const set = seen.get(alertId);
if (!set) return false;
if (eventId && set.has(eventId)) return true;
if (!eventId) return true;
if (set.has(eventId)) return true;
if (!set.has('*')) return false;
// Pin the anonymous dismissal to the event it actually covered; a later
// trigger carries a different id and must ring.