[feat](client): make delivery durable with an offline check-in and ack queue

The server ledger is cleared by an explicit ack, never by the check-in response, so a dismissal that cannot reach the service must be persisted and retried or the device rings again later. Add a durable pending-ack queue (404 means the alert ended: settled, drop it), map the 409 ERR_ALARM_TRIGGERED_OFFLINE check-in into a normal trigger for the same takeover, and make a 404 an ending rather than a missed alarm. This also carries the expiry/endings state the screens render.
This commit is contained in:
2026-10-19 02:30:51 +01:00
parent a29d9b775e
commit 5fa1e8e83e
6 changed files with 674 additions and 81 deletions
+45
View File
@@ -0,0 +1,45 @@
export {};
/**
* Dismiss must silence the phone locally, first, without the owner.
*
* Source contract: JSX/native playback can't be executed headlessly here, so we
* pin the order the code depends on — `stopAlarm()` before any server call or
* event clearing in the acknowledge path — and that playback has no timer that
* could silence it "by itself".
*/
declare const __dirname: string;
const fs: { readFileSync(p: string, enc: string): string } = jest.requireActual('fs');
const path: { join(...parts: string[]): string } = jest.requireActual('path');
const screen = fs.readFileSync(
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
'utf8',
);
const nativeTs = fs.readFileSync(
path.join(__dirname, '..', 'src', 'services', 'alarmNative.ts'),
'utf8',
);
describe('local dismiss contract', () => {
it('silences first and only then drops the live event', () => {
const ackAt = screen.indexOf('const ack = useCallback');
expect(ackAt).toBeGreaterThan(-1);
const ack = screen.slice(ackAt, ackAt + 900);
expect(ack.indexOf('await stopAlarm()')).toBeGreaterThan(-1);
expect(ack.indexOf('await stopAlarm()')).toBeLessThan(ack.indexOf('clearLiveEvent()'));
});
it('stops both audio and vibration through the native bridge', () => {
expect(nativeTs).toContain('stopAlarm');
expect(nativeTs).toContain('AlarmModule');
});
it('does not schedule its own auto-stop in JS', () => {
expect(screen).not.toMatch(/autoStop/i);
// The 1 s interval is the displayed count-up only; it must not stop audio.
const countUp = screen.slice(screen.indexOf('The count-up ticks'), screen.indexOf('The count-up ticks') + 300);
expect(countUp).toContain('setInterval');
expect(countUp).not.toContain('stopAlarm');
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* An ended (destroyed) alert is never an alarm.
*
* The server destroys an alert on delivery, expiry, owner disarm or delete, and
* every action then 404s. A client must render that as a terminal ending — no
* ring, no pending event, no invented replay — and must not retry an ack for it
* forever.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { configureApi, ApiError } from '../src/services/apiClient';
import { ackAlarm, alarmStatus, isAlertGone } from '../src/services/alarmApi';
import { dropAck, enqueueAck, flushAcks, loadAcks } from '../src/services/pendingAcks';
jest.mock('@react-native-async-storage/async-storage', () => {
const store = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn(async (k: string) => store.get(k) ?? null),
setItem: jest.fn(async (k: string, v: string) => {
store.set(k, v);
}),
removeItem: jest.fn(async (k: string) => {
store.delete(k);
}),
},
};
});
const g = globalThis as unknown as { fetch: typeof fetch };
const realFetch = g.fetch;
function stub(status: number, body: unknown): void {
g.fetch = (async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
json: async () => body,
})) as unknown as typeof fetch;
}
beforeEach(async () => {
await AsyncStorage.removeItem('alarm.pendingAcks.v1');
configureApi({
baseUrl: 'http://instance.test',
token: null,
refreshToken: null,
onUnauthorized: () => {},
onSessionUpdate: () => {},
});
});
afterEach(() => {
g.fetch = realFetch;
});
describe('ended alerts never ring', () => {
it('a 404 check-in is a terminal error, not an offline trigger', async () => {
stub(404, { error: { code: 'ERR_ALERT_NOT_FOUND', message: 'alert not found' } });
const err = await alarmStatus('alr_gone').catch(e => e);
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(404);
expect(isAlertGone(err)).toBe(true);
expect((err as ApiError).code).toBe('ERR_ALERT_NOT_FOUND');
// Nothing success-shaped was produced, so no caller can ring from it.
expect((err as { offlineTrigger?: boolean }).offlineTrigger).toBeUndefined();
});
it('a 404 ack is settled: the queued entry is dropped, not retried', async () => {
await enqueueAck({ alertId: 'alr_gone', eventId: 'evt_1', deviceId: 'dev_x' });
stub(404, { error: { code: 'ERR_ALERT_NOT_FOUND', message: 'alert not found' } });
await flushAcks();
expect(await loadAcks()).toEqual([]);
});
it('a transport failure keeps the entry queued for the next retry', async () => {
await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' });
g.fetch = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
await flushAcks();
expect(await loadAcks()).toEqual([
{ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' },
]);
});
it('a successful ack clears the entry', async () => {
await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' });
stub(200, { cleared: 1 });
await flushAcks();
expect(await loadAcks()).toEqual([]);
});
});
describe('ackAlarm wire shape', () => {
it('sends alertId, eventId and deviceId with deviceId also in the query', async () => {
const seen: { url: string; body?: string }[] = [];
g.fetch = (async (url: string, init?: RequestInit) => {
seen.push({ url, body: init?.body as string | undefined });
return { ok: true, status: 200, text: async () => '{}', json: async () => ({}) };
}) as unknown as typeof fetch;
await ackAlarm({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_abc' });
expect(seen[0].url).toBe(
'http://instance.test/api/alarm/action/ackAlarm?deviceId=dev_abc',
);
expect(JSON.parse(seen[0].body ?? '{}')).toEqual({
payload: { alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_abc' },
});
});
it('drops a duplicate queued ack', async () => {
await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' });
await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' });
expect(await loadAcks()).toHaveLength(1);
await dropAck('alr_1', 'evt_1');
expect(await loadAcks()).toEqual([]);
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Durable delivery: `alarmStatus` can answer 409 `ERR_ALARM_TRIGGERED_OFFLINE`
* with the alarm frame in the body. That is an alarm signal, not a failure —
* the client must ring the same takeover and must not render an error.
*/
import { configureApi, ApiError, OfflineAlarmError, post } from '../src/services/apiClient';
import { alarmStatus } from '../src/services/alarmApi';
const g = globalThis as unknown as { fetch: typeof fetch };
const realFetch = g.fetch;
function stub(status: number, body: unknown): void {
g.fetch = (async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
json: async () => body,
})) as unknown as typeof fetch;
}
beforeEach(() => {
configureApi({
baseUrl: 'http://instance.test',
token: null,
refreshToken: null,
onUnauthorized: () => {},
onSessionUpdate: () => {},
});
});
afterEach(() => {
g.fetch = realFetch;
});
describe('ERR_ALARM_TRIGGERED_OFFLINE', () => {
it('is returned as a success-shaped status carrying the frame', async () => {
stub(409, {
error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'alarm fired while offline' },
alarm: {
type: 'alarm',
alertId: 'alr_1',
eventId: 'evt_7',
action: 'trigger',
message: 'Fire drill',
serverTime: '2026-10-19T00:00:00Z',
},
});
await expect(alarmStatus('alr_1')).resolves.toEqual({
active: true,
action: 'trigger',
eventId: 'evt_7',
message: 'Fire drill',
serverTime: '2026-10-19T00:00:00Z',
offlineTrigger: true,
});
});
it('accepts the frame at the top level too', async () => {
stub(409, {
error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'late' },
alertId: 'alr_1',
eventId: 'evt_8',
action: 'trigger',
});
await expect(alarmStatus('alr_1')).resolves.toMatchObject({
active: true,
eventId: 'evt_8',
offlineTrigger: true,
});
});
it('does not let the generic HTTP path swallow it', async () => {
stub(409, {
error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'late' },
alarm: { alertId: 'alr_1', eventId: 'evt_9' },
});
await expect(post('alarmStatus', {})).rejects.toBeInstanceOf(OfflineAlarmError);
});
it('refuses to ring a 409 that claims the code but carries no frame', async () => {
stub(409, { error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'late' } });
await expect(alarmStatus('alr_1')).rejects.toBeInstanceOf(OfflineAlarmError);
});
it('keeps any other 409 a plain error', async () => {
stub(409, { error: { code: 'ERR_ALERT_ALREADY_JOINED', message: 'already joined' } });
const err = await alarmStatus('alr_1').catch(e => e);
expect(err).toBeInstanceOf(ApiError);
expect(err).not.toBeInstanceOf(OfflineAlarmError);
expect(err.code).toBe('ERR_ALERT_ALREADY_JOINED');
});
});
+165 -58
View File
@@ -1,15 +1,19 @@
/**
* Alarm service API — the frozen contract, client side.
*
* **Single origin.** Every request goes through the configured instance origin
* that Maestro fronts; `servicePath()` prefixes `/api/alarm`, so no call in this
* module can reach a service port directly.
*
* `/action/<name>` calls go through `apiClient`'s `{payload: …}` envelope and
* carry `X-Access-Token`; `/auth/*` calls pass `raw = true` so the body is the
* plain JSON object (root AGENTS.md body contract).
*
* Actions: createAlert, getAlert, joinAlert, listSubscriptions,
* removeSubscription, triggerAlert, disarmAlert, alarmStatus, deleteAlert,
* qrImage.
* removeSubscription, triggerAlert, disarmAlert, alarmStatus, ackAlarm,
* deleteAlert, qrImage.
*/
import { ApiError, del, get, post, requestBase64 } from './apiClient';
import { ApiError, OfflineAlarmError, del, get, post, requestBase64 } from './apiClient';
// ── Auth ─────────────────────────────────────────────────────────────────────
export interface AuthUser {
@@ -75,10 +79,8 @@ export interface Alert {
name: string;
message?: string;
status: AlertStatus;
/** True while the alarm is ringing right now (server-computed, auto-stop aware). */
/** True while the alarm is ringing right now (server-computed). */
active?: boolean;
/** Bumped by every ``rotateSubscribeToken``. */
tokenVersion?: number;
modified?: string;
triggeredAt?: string;
disarmedAt?: string;
@@ -89,30 +91,41 @@ export interface Alert {
/** Owner-only. Never rendered in full (DESIGN.md §7.4 truncates to 12 + …). */
adminToken?: string;
subscribeToken?: string;
/** `https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}` */
/**
* Instance-relative join path the service mints:
* `/api/alarm/a/{alertId}#{subscribeToken}`. No host — the client composes it
* against its configured origin.
*/
qrPayload?: string;
autoStopSeconds?: number;
created?: string;
/** ISO deadline the owner set at creation; the alert self-destructs at it. */
expiresAt?: string;
/** Client-side only: which local role this alert is held in. */
role?: 'admin' | 'subscriber';
/** Client-side only: subscription id for the subscriber role. */
subscriptionId?: string;
deviceId?: string;
deviceLabel?: string;
lastTriggered?: string;
subscriberCount?: number;
}
/**
* A subscription as the service serialises it (`Alarm/src/actions/subscriptions.go`
* `subscriptionView`). The stable per-device `deviceId` is deliberately NOT on
* the wire: `id` is the revocation handle and `created`/`deviceLabel`/`platform`
* are the non-identifying discriminators a human can use.
*/
export interface Subscription {
subscriptionId: string;
alertId?: string;
deviceId: string;
/** Subscription id — what revocation takes. */
id: string;
userId?: string;
/** Role label only (e.g. `Android phone`) — no stable per-device identity. */
deviceLabel?: string;
platform?: string;
joined?: string;
lastSeen?: string;
/** True when this device reported it could not use full volume. */
volumeBlocked?: boolean;
enabled?: boolean;
/** ISO timestamp of when the subscription was created (the wire's `created`). */
created?: string;
lastSeenAt?: string;
}
export interface AlarmStatus {
@@ -121,13 +134,33 @@ export interface AlarmStatus {
action?: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
autoStopSeconds?: number;
/**
* True when this status is the durable check-in record of an alarm that fired
* while this device was offline (a 409 `ERR_ALARM_TRIGGERED_OFFLINE`), not a
* live socket frame. Same takeover, same ringing; an explicit `ackAlarm` (not
* the check-in itself) clears the ledger row.
*
* Contract body:
* `{"error":{"code":"ERR_ALARM_TRIGGERED_OFFLINE","message":…},
* "alarm":{"type":"alarm","alertId","eventId","action","message","serverTime"}}`
* — the frame is a sibling of the error envelope and is shaped exactly like a
* WS frame. `offlineFrame()` also tolerates the frame under `frame`, nested in
* `error`, or at the top level as defence, but the sibling `alarm` form is the
* contract, not a guess.
*/
offlineTrigger?: boolean;
}
/**
* Create an alert. `expiresInSeconds` is the owner's maximum lifetime: the
* service defaults to 24 h when omitted, requires at least 60 s and refuses
* anything over 14 days (`ERR_VALIDATION`, never a silent clamp) — so the client
* offers the cap and never more. The response carries the resulting `expiresAt`.
*/
export function createAlert(payload: {
name: string;
message?: string;
autoStopSeconds?: number;
expiresInSeconds?: number;
}): Promise<Alert> {
return post<Alert>('/action/createAlert', payload);
}
@@ -136,18 +169,29 @@ export function getAlert(alertId: string): Promise<Alert> {
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`);
}
/**
* Join an alert. Returns the service's `subscriptionView` — `id` is the
* revocation handle; there is no `subscriptionId` on the wire and no `alert`
* object (the join screen already holds the QR's alert data).
*/
export function joinAlert(payload: {
alertId: string;
subscribeToken: string;
deviceId: string;
deviceLabel?: string;
platform?: string;
}): Promise<Subscription & { alert?: Alert }> {
return post<Subscription & { alert?: Alert }>('/action/joinAlert', payload);
}): Promise<Subscription> {
return post<Subscription>('/action/joinAlert', payload);
}
export function listSubscriptions(alertId: string): Promise<Subscription[]> {
return get<Subscription[]>(`/action/listSubscriptions?alertId=${encodeURIComponent(alertId)}`);
export async function listSubscriptions(alertId: string): Promise<Subscription[]> {
const res = await get<{ items?: Subscription[] } | Subscription[]>(
`/action/listSubscriptions?alertId=${encodeURIComponent(alertId)}`,
);
// The service emits `{items:[…]}`; older builds emitted a bare array. Accept
// both rather than rendering an empty list and reporting it as "no devices".
const items = Array.isArray(res) ? res : res?.items;
return Array.isArray(items) ? items : [];
}
export function removeSubscription(payload: {
@@ -172,9 +216,67 @@ export function disarmAlert(payload: {
return post<AlarmStatus>('/action/disarmAlert', payload);
}
/** Polling fallback + catch-up, valid in both connection modes. */
export function alarmStatus(alertId: string): Promise<AlarmStatus> {
return get<AlarmStatus>(`/action/alarmStatus?alertId=${encodeURIComponent(alertId)}`);
/**
* Status check-in.
*
* The socket is the live path; this call also carries the durable record: an
* alarm that fired while the client was offline is answered with a 409
* `ERR_ALARM_TRIGGERED_OFFLINE` **and the alarm frame** as a sibling of the
* error envelope. That is an alarm signal, not a failure, so it is turned into
* a success-shaped [AlarmStatus] here — callers ring the same takeover and never
* see an error. A 409 with any other code stays a real [ApiError].
*
* Division of labour with the socket: while the process is alive the FGS holds
* the socket, so the service's replay of unacked deliveries on a successful
* upgrade covers it; this check-in is what covers a socket that was down or an
* app reopened cold, where no replay could reach the native path.
*/
export async function alarmStatus(alertId: string, deviceId?: string): Promise<AlarmStatus> {
const query = `alertId=${encodeURIComponent(alertId)}${
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
}`;
try {
return await get<AlarmStatus>(`/action/alarmStatus?${query}`);
} catch (e) {
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
const frame = offlineFrame(e.body);
// A 409 claiming this code but carrying no frame is a malformed response:
// do not swallow it as "delivered" and do not ring an empty alarm.
if (frame) return frame;
}
throw e;
}
}
/**
* True when the service says the alert no longer exists.
*
* After an alert is delivered to every subscriber, reaches its persistence cap,
* or is disarmed by its owner, it is destroyed: every action on that id answers
* 404. That is a terminal ending, never an alarm — callers render a specific
* ending, and the ack queue treats it as settled rather than retrying forever.
*/
export function isAlertGone(error: unknown): boolean {
return error instanceof ApiError && error.status === 404;
}
/**
* Explicit acknowledgement of a delivered alarm.
*
* This — not the check-in response — is what clears the device's ledger debt.
* `deviceId` is the raw local id: the service hashes it to address the
* subscription. Payload and query both carry it so the action works whichever
* the service reads.
*/
export function ackAlarm(input: {
alertId: string;
eventId: string;
deviceId: string;
}): Promise<unknown> {
return post<unknown>(
`/action/ackAlarm?deviceId=${encodeURIComponent(input.deviceId)}`,
input,
);
}
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
@@ -188,44 +290,57 @@ export function qrImage(alertId: string): Promise<string> {
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Map an `alarmStatus` response onto the four-state ramp. `alarmStatus` reports
* `active` + the last `action`, which is exactly enough to derive the state:
* active → triggered, last action disarm → disarmed, otherwise idle.
*/
export interface RotateResponse {
alertId: string;
subscribeToken: string;
qrPayload: string;
tokenVersion: number;
/** A record object, or null for arrays/null/primitives. */
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Rotates the subscribe token. Existing subscriptions survive — rotation
* invalidates the QR/join path, not the people who already joined — so a leaked
* code can be retired without kicking anyone off.
* Normalise the frame carried by an offline 409 into a success-shaped status.
*
* The contract puts the frame in a sibling `alarm` object; the other shapes are
* accepted defensively. A candidate only counts as a frame when it carries
* evidence (`type:"alarm"`, `eventId`, or `alertId`) — otherwise the error
* envelope itself would be misread as a delivery.
*/
export async function rotateSubscribeToken(input: {
alertId: string;
adminToken: string;
}): Promise<RotateResponse> {
return post<RotateResponse>('rotateSubscribeToken', input);
function offlineFrame(body: unknown): AlarmStatus | null {
const root = asRecord(body);
if (!root) return null;
const error = asRecord(root.error);
const candidates: unknown[] = [root.alarm, root.frame, error?.alarm, error?.frame, root];
for (const candidate of candidates) {
const frame = asRecord(candidate);
if (!frame) continue;
const isFrame =
frame.type === 'alarm' ||
typeof frame.eventId === 'string' ||
(typeof frame.alertId === 'string' && frame.alertId.length > 0);
if (!isFrame) continue;
return {
active: true,
eventId: typeof frame.eventId === 'string' ? frame.eventId : undefined,
action: 'trigger',
message: typeof frame.message === 'string' ? frame.message : undefined,
serverTime: typeof frame.serverTime === 'string' ? frame.serverTime : undefined,
offlineTrigger: true,
};
}
return null;
}
/**
* Copy for a failed join, keyed by the service's error code.
*
* `JoinAlert` answers `ERR_ALERT_TOKEN_INVALID` for a token that was rotated
* **and** for one that was never valid — the service deliberately does not
* distinguish them, so the client cannot observe which happened and must not
* claim a mechanism. "This code is no longer valid — ask the owner for a new
* one." is true of both cases and tells the user what to do next; "expired"
* would be a guess.
* Token rotation does not exist: a code is either valid for this alert or it is
* not. The copy therefore never suggests asking for a replacement token.
*/
export function joinErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'ERR_ALERT_TOKEN_INVALID') {
return 'This code is no longer valid — ask the owner for a new one.';
return 'This code is not valid for this alert.';
}
return error.message;
}
@@ -239,11 +354,3 @@ export function statusToState(status: AlarmStatus | null | undefined): AlertStat
if (status.action === 'disarm') return 'disarmed';
return 'idle';
}
export function formatAutoStop(seconds: number | undefined): string {
if (!seconds || seconds <= 0) return 'until stopped';
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s ? `${m}m ${s}s` : `${m}m`;
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Durable pending-ack queue.
*
* The ledger is server-side and is cleared by an explicit `ackAlarm`, not by the
* check-in response. If that ack cannot be sent (offline, app killed right after
* dismiss), the server still believes this device owes the alarm and will replay
* it on the next connect — ringing someone who already dismissed. So an ack is
* persisted before the UI forgets the alarm and retried until the service
* accepts it.
*
* An ack answered 404 means the alert is already gone (delivered, expired or
* disarmed): that is *settled*, not a failure — drop the entry, never retry
* forever.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ackAlarm, isAlertGone } from './alarmApi';
const KEY = 'alarm.pendingAcks.v1';
export interface PendingAck {
alertId: string;
eventId: string;
deviceId: string;
}
/** Merge by (alertId, eventId); a repeat dismiss never duplicates an entry. */
export function mergeAck(list: PendingAck[], ack: PendingAck): PendingAck[] {
return list.some(a => a.alertId === ack.alertId && a.eventId === ack.eventId)
? list
: [...list, ack];
}
function parse(raw: string | null): PendingAck[] {
if (!raw) return [];
try {
const value = JSON.parse(raw) as unknown;
if (!Array.isArray(value)) return [];
return value.filter(
(v): v is PendingAck =>
!!v &&
typeof v === 'object' &&
typeof (v as PendingAck).alertId === 'string' &&
typeof (v as PendingAck).eventId === 'string' &&
typeof (v as PendingAck).deviceId === 'string',
);
} catch {
return [];
}
}
export async function loadAcks(): Promise<PendingAck[]> {
try {
return parse(await AsyncStorage.getItem(KEY));
} catch {
return [];
}
}
async function writeAcks(list: PendingAck[]): Promise<void> {
try {
if (list.length) await AsyncStorage.setItem(KEY, JSON.stringify(list));
else await AsyncStorage.removeItem(KEY);
} catch {}
}
export async function enqueueAck(ack: PendingAck): Promise<void> {
await writeAcks(mergeAck(await loadAcks(), ack));
}
export async function dropAck(alertId: string, eventId: string): Promise<void> {
const next = (await loadAcks()).filter(a => !(a.alertId === alertId && a.eventId === eventId));
await writeAcks(next);
}
/**
* Send every queued ack. Stops on the first transport/server failure (the queue
* is retried later); drops entries on success and on 404 ("settled").
*/
export async function flushAcks(): Promise<void> {
const list = await loadAcks();
for (const ack of list) {
try {
await ackAlarm(ack);
} catch (e) {
if (!isAlertGone(e)) return;
}
await dropAck(ack.alertId, ack.eventId);
}
}
+157 -23
View File
@@ -1,4 +1,5 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as api from '../services/alarmApi';
import type { Alert, AlertStatus, Subscription } from '../services/alarmApi';
@@ -6,17 +7,23 @@ import { useAuth } from './AuthContext';
import { useConnection } from './ConnectionContext';
import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket';
import { closeLiveAlarm, openLiveAlarm } from '../navigationRef';
import { stopForegroundService } from '../services/alarmNative';
import { enqueueAck, flushAcks } from '../services/pendingAcks';
import { DEVICE_ID_STORAGE_KEY, isValidDeviceId, newDeviceId } from '../lib/deviceId';
import { isPastDeadline } from '../lib/time';
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
/** Why an alert no longer exists. Each ending has its own explicit copy. */
export type EndingReason = 'delivered' | 'expired' | 'disarmed' | 'deleted';
export interface LiveAlarmEvent {
alertId: string;
eventId?: string;
action: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
autoStopSeconds?: number;
/** True when the event came in over the socket rather than a poll. */
live: boolean;
}
@@ -37,10 +44,16 @@ interface AlarmContextValue {
liveEvent: LiveAlarmEvent | null;
/** Cleared when the takeover is acknowledged/dismissed. */
clearLiveEvent: () => void;
/** Persist and send the explicit ack for a dismissed alarm (durable retry). */
acknowledgeAlarm: (alertId: string, eventId: string | undefined) => Promise<void>;
lastStatus: api.AlarmStatus | null;
refresh: () => Promise<void>;
reload: () => Promise<void>;
createAlert: (input: { name: string; message?: string; autoStopSeconds?: number }) => Promise<Alert>;
createAlert: (input: {
name: string;
message?: string;
expiresInSeconds?: number;
}) => Promise<Alert>;
joinAlert: (input: {
alertId: string;
subscribeToken: string;
@@ -53,6 +66,12 @@ interface AlarmContextValue {
adminTokenFor: (alertId: string) => string | null;
subscribeTokenFor: (alertId: string) => string | null;
deviceId: string | null;
/** Set once per alert when it is destroyed; drives the ending screen/list notice. */
endingFor: (alertId: string) => EndingReason | null;
/** Mark an alert destroyed after a 404 or a successful ending action. */
markEnded: (alertId: string, reason: EndingReason) => Promise<void>;
lastEnding: { alertId: string; reason: EndingReason } | null;
clearLastEnding: () => void;
}
const AlarmContext = createContext<AlarmContextValue | undefined>(undefined);
@@ -88,17 +107,28 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
const [activeAlertId, setActiveAlertId] = useState<string | null>(null);
const [liveEvent, setLiveEvent] = useState<LiveAlarmEvent | null>(null);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [endings, setEndings] = useState<Record<string, EndingReason>>({});
const [lastEnding, setLastEnding] = useState<{ alertId: string; reason: EndingReason } | null>(null);
// A per-install device id: stable across launches, unique per device.
// A per-install device id: stable across launches, unique per device. It
// addresses this device in the delivery ledger (the service hashes it), so it
// must come from SecureRandom, not a timestamp-guessable generator.
useEffect(() => {
let cancelled = false;
(async () => {
let id = await AsyncStorage.getItem('alarm.deviceId.v1');
if (!id) {
id = `dev_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
await AsyncStorage.setItem('alarm.deviceId.v1', id);
let stored = await AsyncStorage.getItem(DEVICE_ID_STORAGE_KEY);
if (!stored || !isValidDeviceId(stored)) {
try {
stored = await newDeviceId();
await AsyncStorage.setItem(DEVICE_ID_STORAGE_KEY, stored);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Could not create this device identity.');
}
return;
}
}
if (!cancelled) setDeviceId(id);
if (!cancelled) setDeviceId(stored);
})();
return () => {
cancelled = true;
@@ -138,15 +168,49 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
[subscribedAlerts, activeAlertId],
);
/**
* A destroyed alert is terminal, never an alarm. Drop every local reference
* and record why, so the UI can say "ended" instead of showing a stale page or
* inventing a missed trigger from a 404/404-shaped check-in.
*/
const markEnded = useCallback(
async (alertId: string, reason: EndingReason) => {
setAdminAlerts(prev => {
const next = prev.filter(a => a.alertId !== alertId);
writeJson(ADMIN_ALERTS_KEY, next);
return next;
});
setSubscribedAlerts(prev => {
const next = prev.filter(a => a.alertId !== alertId);
writeJson(SUBSCRIBED_ALERTS_KEY, next);
return next;
});
setEndings(prev => ({ ...prev, [alertId]: reason }));
setLastEnding({ alertId, reason });
setLiveEvent(prev => (prev?.alertId === alertId ? null : prev));
if (activeAlertId === alertId) {
try {
await stopForegroundService();
} catch {
// Best effort: the alert is gone, so a stale service must not linger.
}
}
setActiveAlertId(prev => (prev === alertId ? null : prev));
},
[activeAlertId],
);
const endingFor = useCallback((alertId: string) => endings[alertId] ?? null, [endings]);
const clearLastEnding = useCallback(() => setLastEnding(null), []);
const handleTrigger = useCallback(
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string; autoStopSeconds?: number }, live: boolean) => {
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
const event: LiveAlarmEvent = {
alertId: frame.alertId,
eventId: frame.eventId,
action: 'trigger',
message: frame.message,
serverTime: frame.serverTime,
autoStopSeconds: frame.autoStopSeconds,
live,
};
setLiveEvent(event);
@@ -179,8 +243,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
return { ...prev, action: 'disarm', eventId: frame.eventId, serverTime: frame.serverTime, live };
});
closeLiveAlarm();
// A disarm destroys the alert; there is nothing left to keep locally.
markEnded(frame.alertId, 'disarmed').catch(() => {});
},
[],
[markEnded],
);
const { socketState, delivery, attempts, lastStatus, refresh } = useAlarmSocket({
@@ -188,13 +254,27 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
alertId: activeAlert?.alertId ?? null,
subscribeToken: activeAlert?.subscribeToken ?? null,
enabled: !!token && online,
deviceId,
onTrigger: frame => handleTrigger(frame, true),
onDisarm: frame => handleDisarm(frame, true),
// A 404 from the check-in is terminal: the alert ended. There is nothing to
// ring and no local event is invented from the absence of a delivery.
onGone: () => {
if (activeAlert) {
markEnded(
activeAlert.alertId,
isPastDeadline(activeAlert.expiresAt, Date.now()) ? 'expired' : 'delivered',
).catch(() => {});
}
},
});
// Poll results are a safety net: if the server says "active" and we have no
// live event for it, raise the takeover from the polled data (never gated on
// a fetch — the takeover renders from what is already in hand).
// Status results are a safety net in two cases: the ordinary "server says
// active and we have no live event", and the durable check-in record of a
// trigger missed while offline (409 `ERR_ALARM_TRIGGERED_OFFLINE`, already
// mapped to a success-shaped status by `alarmStatus`). Both raise the same
// takeover from data in hand — never gated on a fetch. A 404 is *not* one of
// these cases: an ended alert produces no local alarm event at all.
useEffect(() => {
if (!lastStatus || !activeAlert) return;
if (lastStatus.active && lastStatus.action === 'trigger') {
@@ -205,7 +285,6 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
eventId: lastStatus.eventId,
message: lastStatus.message,
serverTime: lastStatus.serverTime,
autoStopSeconds: lastStatus.autoStopSeconds,
},
false,
);
@@ -216,7 +295,7 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
}, [lastStatus, activeAlert?.alertId]);
const createAlert = useCallback(
async (input: { name: string; message?: string; autoStopSeconds?: number }) => {
async (input: { name: string; message?: string; expiresInSeconds?: number }) => {
const created = await api.createAlert(input);
const record: Alert = { ...created, role: 'admin', status: created.status ?? 'armed' };
setAdminAlerts(prev => {
@@ -240,14 +319,14 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
platform: 'android',
});
const record: Alert = {
...(joined.alert ?? { alertId: input.alertId, name: input.alertId, status: 'armed' as AlertStatus }),
alertId: input.alertId,
name: input.alertId,
status: 'armed' as AlertStatus,
subscribeToken: input.subscribeToken,
role: 'subscriber',
subscriptionId: joined.subscriptionId,
deviceId,
deviceLabel: input.deviceLabel,
status: (joined.alert?.status as AlertStatus) ?? 'armed',
// The wire's subscription view is `id`; the request field name is the
// only place `subscriptionId` appears.
subscriptionId: joined.id,
};
setSubscribedAlerts(prev => {
const next = [record, ...prev.filter(a => a.alertId !== record.alertId)];
@@ -264,16 +343,34 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
async (alertId: string) => {
const target = subscribedAlerts.find(a => a.alertId === alertId);
if (target?.subscriptionId) {
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
try {
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
} catch (e) {
// Already gone is settled: the alert ended, so there is nothing to
// leave on the server and nothing to retry.
if (!api.isAlertGone(e)) throw e;
await markEnded(alertId, 'deleted');
return;
}
}
setSubscribedAlerts(prev => {
const next = prev.filter(a => a.alertId !== alertId);
writeJson(SUBSCRIBED_ALERTS_KEY, next);
return next;
});
// Leaving the armed alert must also drop the standing alarm-clock entry
// and the persisted arm record — otherwise the user keeps a permanent
// alarm icon for an alert they no longer follow.
if (activeAlertId === alertId) {
try {
await stopForegroundService();
} catch {
// Best effort: stopping is idempotent and the next start re-syncs.
}
}
setActiveAlertId(prev => (prev === alertId ? null : prev));
},
[subscribedAlerts],
[activeAlertId, markEnded, subscribedAlerts],
);
const forgetAdminAlert = useCallback(async (alertId: string) => {
@@ -316,6 +413,38 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
[adminAlerts, subscribedAlerts],
);
/**
* Dismissal acks the ledger explicitly — silence locally first, then this.
* The entry is persisted *before* anything else so a failed send survives the
* app dying; `flushAcks` retries until the service accepts it (404 = settled).
*/
const acknowledgeAlarm = useCallback(
async (alertId: string, eventId: string | undefined) => {
if (!deviceId || !eventId) return;
await enqueueAck({ alertId, eventId, deviceId });
await flushAcks();
},
[deviceId],
);
// Retry the durable queue on every plausible reconnect point, plus a slow
// interval so a queue that could not flush at dismiss cannot linger.
useEffect(() => {
flushAcks().catch(() => {});
const sub = AppState.addEventListener('change', next => {
if (next === 'active') flushAcks().catch(() => {});
});
const timer = setInterval(() => flushAcks().catch(() => {}), 30_000);
return () => {
sub.remove();
clearInterval(timer);
};
}, []);
useEffect(() => {
if (socketState === 'open') flushAcks().catch(() => {});
}, [socketState]);
const value: AlarmContextValue = {
adminAlerts,
subscribedAlerts,
@@ -328,6 +457,7 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
reconnectAttempts: attempts,
liveEvent,
clearLiveEvent: () => setLiveEvent(null),
acknowledgeAlarm,
lastStatus,
refresh,
reload,
@@ -340,6 +470,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
adminTokenFor,
subscribeTokenFor,
deviceId,
endingFor,
markEnded,
lastEnding,
clearLastEnding,
};
return <AlarmContext.Provider value={value}>{children}</AlarmContext.Provider>;