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).
87 lines
3.4 KiB
TypeScript
87 lines
3.4 KiB
TypeScript
/**
|
|
* What this device has already dismissed, per alert.
|
|
*
|
|
* The service keeps an alert "triggered" until the owner disarms it or every
|
|
* subscriber acknowledges, so `alarmStatus` keeps reporting the same trigger
|
|
* after this device dismissed it — and the check-in/replay paths keep offering
|
|
* it too. Raising the takeover from those reports again is the alarm that
|
|
* cannot be kept silenced, so the client has to remember what it dismissed:
|
|
*
|
|
* - a dismissal that named its event suppresses exactly that event;
|
|
* - 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;
|
|
* - 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.
|
|
*/
|
|
export interface DismissalSource {
|
|
alertId: string;
|
|
eventId?: string;
|
|
}
|
|
|
|
export interface DismissalMemory {
|
|
mark(alertId: string, eventId?: string | null): void;
|
|
/** Seed from a persisted ack queue so a replay before the flush cannot ring. */
|
|
seed(list: readonly DismissalSource[]): void;
|
|
/**
|
|
* Should a trigger raise be suppressed?
|
|
*
|
|
* 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;
|
|
/** Test/debug view of what is remembered for an alert. */
|
|
remembered(alertId: string): string[];
|
|
}
|
|
|
|
export function createDismissals(): DismissalMemory {
|
|
const seen = new Map<string, Set<string>>();
|
|
|
|
const mark = (alertId: string, eventId?: string | null) => {
|
|
const set = seen.get(alertId) ?? new Set<string>();
|
|
set.add(eventId || '*');
|
|
seen.set(alertId, set);
|
|
};
|
|
|
|
const forget = (alertId: string) => {
|
|
seen.delete(alertId);
|
|
};
|
|
|
|
// `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) 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.
|
|
if (eventId) {
|
|
set.delete('*');
|
|
set.add(eventId);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
mark,
|
|
forget,
|
|
suppress,
|
|
seed: list => list.forEach(a => mark(a.alertId, a.eventId)),
|
|
remembered: alertId => [...(seen.get(alertId) ?? [])],
|
|
};
|
|
}
|