[fix](client): arm in the background, make dismiss final, and leave the takeover

The reported failures are one missing acknowledgement and one missing service
start.

- Alarms only fired with the app in focus: the armed WebSocket lives in the
  native foreground service, and nothing started it — the only entries were a
  button on one alert screen and a toggle in Settings. The provider now arms the
  active subscription itself (asks for POST_NOTIFICATIONS, uses the device token
  the service can hold, throttles a refused start to once per five minutes), and
  Home says whether the background listener is really running instead of
  presenting the on-screen socket as coverage.
- Dismissed alarms came back: the takeover never sent the ack. acknowledgeAlarm
  existed and was never called, so the service still believed this device owed
  the alarm and replayed it on every connect/poll. The acknowledge path now
  persists the dismissal (durably, retried) and records it locally, so a status
  report of the trigger it already dismissed cannot raise the takeover again.
  The record tolerates a dismissal with no event id (the native launch carries
  only the alert) and is pinned to the concrete event as soon as one is known.
- No way back from the takeover: acknowledge now shows a Back to alerts action,
  hardware back works once the device is silent, and the screen leaves by itself
  when the alert ends for this device.
- A destroyed alert answers 401 to its device token (the subscription went with
  it). That is terminal: acknowledgements and the status poll stop retrying the
  dead credential and mark the subscription ended instead of looping.
- Settings' start-listening button built the socket URL without the device
  token, so it could never arm a subscriber's service.

tsc clean, 144 tests (15 new: dismissal memory, background arming, dead
credential, unnamed ack queue).
This commit is contained in:
2026-10-19 19:34:36 +01:00
parent 31cff32824
commit 98625adb25
10 changed files with 556 additions and 38 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* 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, 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.
*
* 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?
*
* `live` frames are new triggers and clear the memory; status/check-in
* raises are suppressed when this device already dismissed that event.
*/
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);
};
const suppress = (alertId: string, eventId: string | undefined, live: boolean) => {
if (live) {
forget(alertId);
return false;
}
const set = seen.get(alertId);
if (!set) return false;
if (eventId && 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) ?? [])],
};
}