[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:
@@ -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) ?? [])],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user