diff --git a/__tests__/dismissContract.test.ts b/__tests__/dismissContract.test.ts new file mode 100644 index 0000000..9e530ad --- /dev/null +++ b/__tests__/dismissContract.test.ts @@ -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'); + }); +}); diff --git a/__tests__/endedAlert.test.ts b/__tests__/endedAlert.test.ts new file mode 100644 index 0000000..afd9c3a --- /dev/null +++ b/__tests__/endedAlert.test.ts @@ -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(); + 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([]); + }); +}); diff --git a/__tests__/offlineTrigger.test.ts b/__tests__/offlineTrigger.test.ts new file mode 100644 index 0000000..a6aa2c5 --- /dev/null +++ b/__tests__/offlineTrigger.test.ts @@ -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'); + }); +}); diff --git a/src/services/alarmApi.ts b/src/services/alarmApi.ts index c4bc276..ebf7e4d 100644 --- a/src/services/alarmApi.ts +++ b/src/services/alarmApi.ts @@ -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/` 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 { return post('/action/createAlert', payload); } @@ -136,18 +169,29 @@ export function getAlert(alertId: string): Promise { return get(`/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 { - return post('/action/joinAlert', payload); +}): Promise { + return post('/action/joinAlert', payload); } -export function listSubscriptions(alertId: string): Promise { - return get(`/action/listSubscriptions?alertId=${encodeURIComponent(alertId)}`); +export async function listSubscriptions(alertId: string): Promise { + 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('/action/disarmAlert', payload); } -/** Polling fallback + catch-up, valid in both connection modes. */ -export function alarmStatus(alertId: string): Promise { - return get(`/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 { + const query = `alertId=${encodeURIComponent(alertId)}${ + deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : '' + }`; + try { + return await get(`/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 { + return post( + `/action/ackAlarm?deviceId=${encodeURIComponent(input.deviceId)}`, + input, + ); } export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise { @@ -188,44 +290,57 @@ export function qrImage(alertId: string): Promise { // ── 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 | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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 { - return post('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`; -} diff --git a/src/services/pendingAcks.ts b/src/services/pendingAcks.ts new file mode 100644 index 0000000..d3abe89 --- /dev/null +++ b/src/services/pendingAcks.ts @@ -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 { + try { + return parse(await AsyncStorage.getItem(KEY)); + } catch { + return []; + } +} + +async function writeAcks(list: PendingAck[]): Promise { + try { + if (list.length) await AsyncStorage.setItem(KEY, JSON.stringify(list)); + else await AsyncStorage.removeItem(KEY); + } catch {} +} + +export async function enqueueAck(ack: PendingAck): Promise { + await writeAcks(mergeAck(await loadAcks(), ack)); +} + +export async function dropAck(alertId: string, eventId: string): Promise { + 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 { + 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); + } +} diff --git a/src/state/AlarmContext.tsx b/src/state/AlarmContext.tsx index 73d7925..decece2 100644 --- a/src/state/AlarmContext.tsx +++ b/src/state/AlarmContext.tsx @@ -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; lastStatus: api.AlarmStatus | null; refresh: () => Promise; reload: () => Promise; - createAlert: (input: { name: string; message?: string; autoStopSeconds?: number }) => Promise; + createAlert: (input: { + name: string; + message?: string; + expiresInSeconds?: number; + }) => Promise; 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; + lastEnding: { alertId: string; reason: EndingReason } | null; + clearLastEnding: () => void; } const AlarmContext = createContext(undefined); @@ -88,17 +107,28 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) { const [activeAlertId, setActiveAlertId] = useState(null); const [liveEvent, setLiveEvent] = useState(null); const [deviceId, setDeviceId] = useState(null); + const [endings, setEndings] = useState>({}); + 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 {children};