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