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

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

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

tsc clean, 144 tests (15 new: dismissal memory, background arming, dead
credential, unnamed ack queue).
This commit is contained in:
2026-10-19 19:34:36 +01:00
parent 31cff32824
commit 98625adb25
10 changed files with 556 additions and 38 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* A subscription must ring while the app is not focused.
*
* On Android the JS runtime (and therefore the JS socket) stops when the app is
* backgrounded; the only supported delivery path is the native foreground
* service, which holds its own WebSocket. Nothing in the app used to start it —
* the toggle lived on one screen — so a plain subscriber only ever rang while
* the app was open. These assertions pin the automatic arming and the facts it
* depends on (a credential the service can use, and its notification).
*/
export {};
declare const __dirname: string;
const fs = require('fs') as { readFileSync(p: string, enc: string): string };
const path = require('path') as { join(...parts: string[]): string };
const context = fs.readFileSync(
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
'utf8',
);
const moduleKt = fs.readFileSync(
path.join(
__dirname,
'..',
'android',
'app',
'src',
'main',
'java',
'com',
'dcntrlized',
'alarm',
'AlarmModule.kt',
),
'utf8',
);
const home = fs.readFileSync(
path.join(__dirname, '..', 'src', 'screens', 'HomeScreen.tsx'),
'utf8',
);
describe('background arming', () => {
it('starts the foreground service from the provider, not from one screen', () => {
expect(context).toContain('startForegroundService(');
expect(context).toContain('alarmSocketUrl(');
});
it('arms the alert the provider is actively listening to', () => {
const at = context.indexOf('const armedAttemptRef');
expect(at).toBeGreaterThan(-1);
const body = context.slice(at, at + 2200);
expect(body).toContain('activeAlert.alertId');
expect(body).toContain('activeAlert.subscribeToken');
// The service cannot refresh a session, so only a device token works here.
expect(body).toContain('if (!activeAlert.deviceToken) return;');
expect(body).toContain('activeAlert.deviceToken');
});
it('asks for the notification a foreground service cannot run without', () => {
expect(context).toContain('requestPermission(');
expect(context).toContain("'notifications'");
// The native side refuses to start a service whose notification cannot show.
expect(moduleKt).toContain('notifications-not-granted');
});
it('does not retry a refused start in a tight loop', () => {
expect(context).toMatch(/300_000/);
});
it('reports whether the background listener is running, and does not pretend', () => {
expect(home).toContain('backgroundListening');
expect(home).toContain('triggers arrive even when this app is closed');
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* A dismissed alarm must stay dismissed.
*
* The service keeps an alert "triggered" until the owner disarms it or every
* subscriber acknowledges, so every status poll, check-in and socket replay keeps
* offering the same trigger to a device that already dismissed it. These tests
* pin the client's memory of dismissals (what is suppressed, and what must still
* ring), plus the two wiring facts that make dismissal reach the service at all.
*/
// The ack queue module owns AsyncStorage at module load; the storage package is
// ESM-only and this is a headless run, so the tiny surface mergeAck needs is
// stubbed (nothing here reads or writes real storage).
jest.mock('@react-native-async-storage/async-storage', () => ({
__esModule: true,
default: {
getItem: async () => null,
setItem: async () => {},
removeItem: async () => {},
},
}));
import { createDismissals } from '../src/lib/dismissals';
import { isDeviceTokenRefused, isAlertGone } from '../src/services/alarmApi';
import { ApiError } from '../src/services/apiClient';
import { mergeAck, type PendingAck } from '../src/services/pendingAcks';
declare const __dirname: string;
const fs = require('fs') as { readFileSync(p: string, enc: string): string };
const path = require('path') as { join(...parts: string[]): string };
const EVENT = '11111111-1111-1111-1111-111111111111';
const NEXT = '22222222-2222-2222-2222-222222222222';
describe('dismissal memory', () => {
it('suppresses the event this device dismissed and nothing else', () => {
const d = createDismissals();
d.mark('a', EVENT);
expect(d.suppress('a', EVENT, false)).toBe(true);
expect(d.suppress('a', NEXT, false)).toBe(false);
expect(d.suppress('b', EVENT, false)).toBe(false);
});
it('pins an unnamed dismissal to the trigger it actually covered', () => {
const d = createDismissals();
d.mark('a'); // native launch: the event id was never known
expect(d.suppress('a', EVENT, false)).toBe(true);
expect(d.remembered('a')).toEqual([EVENT]);
// Once pinned, a later trigger is news again.
expect(d.suppress('a', NEXT, false)).toBe(false);
});
it('keeps suppressing while the event id is still unknown', () => {
const d = createDismissals();
d.mark('a');
expect(d.suppress('a', undefined, false)).toBe(true);
expect(d.suppress('a', undefined, false)).toBe(true);
});
it('lets a live socket frame clear the memory — a new trigger must ring', () => {
const d = createDismissals();
d.mark('a', EVENT);
expect(d.suppress('a', EVENT, true)).toBe(false);
expect(d.suppress('a', EVENT, false)).toBe(false);
});
it('seeds from the durable ack queue so a replay before the flush is silent', () => {
const d = createDismissals();
d.seed([
{ alertId: 'a', eventId: EVENT },
{ alertId: 'b' },
]);
expect(d.suppress('a', EVENT, false)).toBe(true);
expect(d.suppress('b', NEXT, false)).toBe(true);
});
});
describe('dismissal reaches the service', () => {
const screen = fs.readFileSync(
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
'utf8',
);
const context = fs.readFileSync(
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
'utf8',
);
it('the takeover acks on dismiss, before it forgets the event', () => {
const at = screen.indexOf('const ack = useCallback');
const body = screen.slice(at, at + 1400);
expect(body).toContain('acknowledgeAlarm(');
expect(body.indexOf('acknowledgeAlarm(')).toBeLessThan(body.indexOf('clearLiveEvent()'));
});
it('the ack is queued durably, not fired and forgotten', () => {
expect(context).toContain('enqueueAck(');
expect(context).toContain('flushAcks(');
});
it('an unnamed dismissal is queued without an eventId', () => {
// The service then clears the subscription's whole outstanding set.
expect(context).toContain('eventId: known');
expect(context).toContain("const known = eventId ?? lastStatus?.eventId ?? undefined");
});
});
describe('a dead device credential is terminal, not retryable', () => {
it('401 from a device-token call means the subscription is gone', () => {
expect(isDeviceTokenRefused(new ApiError(401, 'invalid device token', 'ERR_UNAUTHORIZED'))).toBe(
true,
);
expect(isAlertGone(new ApiError(404, 'gone', 'ERR_NOT_FOUND'))).toBe(true);
expect(isDeviceTokenRefused(new ApiError(500, 'boom'))).toBe(false);
});
});
describe('ack queue without an event id', () => {
it('merges an unnamed dismissal with itself only', () => {
const unnamed: PendingAck = { alertId: 'a', deviceId: 'dev' };
const named: PendingAck = { alertId: 'a', eventId: EVENT, deviceId: 'dev' };
const once = mergeAck([], unnamed);
expect(mergeAck(once, { ...unnamed })).toHaveLength(1);
expect(mergeAck(once, named)).toHaveLength(2);
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* What this device has already dismissed, per alert.
*
* The service keeps an alert "triggered" until the owner disarms it or every
* subscriber acknowledges, so `alarmStatus` keeps reporting the same trigger
* after this device dismissed it — and the check-in/replay paths keep offering
* it too. Raising the takeover from those reports again is the alarm that
* cannot be kept silenced, so the client has to remember what it dismissed:
*
* - a dismissal that named its event suppresses exactly that event;
* - a dismissal that could not name one (the native takeover launch carries
* only the alert) is recorded as `*`: it suppresses the trigger current at
* dismissal time, and is pinned to that concrete event as soon as a status
* report reveals it, so a later trigger is not swallowed;
* - a live socket frame is a genuinely new trigger from the owner (replays of
* acknowledged deliveries never reach the client), so it clears the memory
* for that alert instead of being suppressed.
*
* Pure and in-memory on purpose: the durable part of a dismissal is the ack
* queue, and correctness must not depend on storage having finished writing.
*/
export interface DismissalSource {
alertId: string;
eventId?: string;
}
export interface DismissalMemory {
mark(alertId: string, eventId?: string | null): void;
/** Seed from a persisted ack queue so a replay before the flush cannot ring. */
seed(list: readonly DismissalSource[]): void;
/**
* Should a trigger raise be suppressed?
*
* `live` frames are new triggers and clear the memory; status/check-in
* raises are suppressed when this device already dismissed that event.
*/
suppress(alertId: string, eventId: string | undefined, live: boolean): boolean;
forget(alertId: string): void;
/** Test/debug view of what is remembered for an alert. */
remembered(alertId: string): string[];
}
export function createDismissals(): DismissalMemory {
const seen = new Map<string, Set<string>>();
const mark = (alertId: string, eventId?: string | null) => {
const set = seen.get(alertId) ?? new Set<string>();
set.add(eventId || '*');
seen.set(alertId, set);
};
const forget = (alertId: string) => {
seen.delete(alertId);
};
const suppress = (alertId: string, eventId: string | undefined, live: boolean) => {
if (live) {
forget(alertId);
return false;
}
const set = seen.get(alertId);
if (!set) return false;
if (eventId && set.has(eventId)) return true;
if (!set.has('*')) return false;
// Pin the anonymous dismissal to the event it actually covered; a later
// trigger carries a different id and must ring.
if (eventId) {
set.delete('*');
set.add(eventId);
}
return true;
};
return {
mark,
forget,
suppress,
seed: list => list.forEach(a => mark(a.alertId, a.eventId)),
remembered: alertId => [...(seen.get(alertId) ?? [])],
};
}
+11 -5
View File
@@ -79,6 +79,7 @@ export default function HomeScreen() {
liveEvent, liveEvent,
delivery, delivery,
socketState, socketState,
backgroundListening,
lastEnding, lastEnding,
clearLastEnding, clearLastEnding,
} = useAlarm(); } = useAlarm();
@@ -246,12 +247,17 @@ export default function HomeScreen() {
/> />
))} ))}
{tab === 'subscribed' ? ( {tab === 'subscribed' ? (
// Delivery truth, most durable fact first: the foreground service
// is what keeps ringing while the app is closed. The JS socket only
// covers the on-screen case and must not be presented as coverage.
<AppText style={styles.deliveryNote}> <AppText style={styles.deliveryNote}>
{socketState === 'open' {backgroundListening?.state === 'running'
? 'Live delivery connected — triggers arrive over the instance socket.' ? 'Listening in the background — triggers arrive even when this app is closed.'
: delivery === 'socket' : delivery !== 'socket'
? `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.` ? 'Not listening — set your instance address in Settings.'
: 'Not listening — set your instance address in Settings.'} : socketState === 'open'
? 'Listening while this app is open only — background listening is off. Open the alert to see what is missing.'
: `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.`}
</AppText> </AppText>
) : null} ) : null}
</View> </View>
+63 -10
View File
@@ -10,7 +10,7 @@ import {
} from 'react-native'; } from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BellOff, OctagonX, Siren, VolumeX } from 'lucide-react-native'; import { BellOff, ChevronLeft, OctagonX, Siren, VolumeX } from 'lucide-react-native';
import { useAlarm } from '../state/AlarmContext'; import { useAlarm } from '../state/AlarmContext';
import { useAuth } from '../state/AuthContext'; import { useAuth } from '../state/AuthContext';
import { AppText } from '../components/ui'; import { AppText } from '../components/ui';
@@ -21,6 +21,7 @@ import type { RootStackParamList } from '../navigation';
import { import {
motionSiren, motionSiren,
radiusFull, radiusFull,
space1,
space2, space2,
space3, space3,
space4, space4,
@@ -61,8 +62,16 @@ const STOP_LABEL = 'Stop for everyone';
export default function LiveAlarmScreen() { export default function LiveAlarmScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>(); const { params } = useRoute<Rt>();
const { liveEvent, clearLiveEvent, adminAlerts, adminTokenFor, subscribedAlerts, updateAlertLocal } = const {
useAlarm(); liveEvent,
clearLiveEvent,
adminAlerts,
adminTokenFor,
subscribedAlerts,
updateAlertLocal,
acknowledgeAlarm,
endingFor,
} = useAlarm();
const { username } = useAuth(); const { username } = useAuth();
const alertId = params.alertId; const alertId = params.alertId;
@@ -181,11 +190,28 @@ export default function LiveAlarmScreen() {
} catch { } catch {
// Best effort: the volume warning (if any) is the only remaining signal. // Best effort: the volume warning (if any) is the only remaining signal.
} }
// Persist the dismissal. Without this the service still believes this device
// owes the alarm and replays it on the next connect/poll, which is exactly
// how a dismissed alarm comes back — so the ack is enqueued durably (it
// survives the app dying) and the local memory stops the same trigger being
// raised again meanwhile.
try {
await acknowledgeAlarm(alertId, event?.eventId ?? params.eventId);
} catch {
// The queue retries; nothing to show the user while an alarm is ringing.
}
// Once the native player is stopped this device is silent, so the live // Once the native player is stopped this device is silent, so the live
// event no longer needs to drive a takeover — but the screen stays mounted // event no longer needs to drive a takeover.
// in its acknowledged state until the user leaves it.
clearLiveEvent(); clearLiveEvent();
}, [clearLiveEvent]); }, [acknowledgeAlarm, alertId, clearLiveEvent, event?.eventId, params.eventId]);
// The alert can end while the takeover is on screen (the owner disarmed it, its
// lifetime ran out, or the last owed ack destroyed it). The screen has no
// purpose after that — leave instead of stranding the user on a dead alarm.
useEffect(() => {
if (!endingFor(alertId)) return;
leave();
}, [alertId, endingFor, leave]);
const stop = useCallback(async () => { const stop = useCallback(async () => {
if (!adminToken) { if (!adminToken) {
@@ -206,12 +232,17 @@ export default function LiveAlarmScreen() {
} }
}, [adminToken, alertId, leave, updateAlertLocal]); }, [adminToken, alertId, leave, updateAlertLocal]);
// Hardware back and the back gesture are consumed: the only exits are // While the alarm is live, back is consumed: the only exits are Acknowledge
// Acknowledge and Stop alarm (spec, Cross-screen rules). // and Stop alarm (spec, Cross-screen rules). Once this device has been
// acknowledged it is silent, so back is the way home again.
useEffect(() => { useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true); const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (!acknowledged) return true;
leave();
return true;
});
return () => sub.remove(); return () => sub.remove();
}, []); }, [acknowledged, leave]);
// `steps(1, end)` as a square wave: a linear driver sampled at the halfway // `steps(1, end)` as a square wave: a linear driver sampled at the halfway
// point jumps −8° → +8° with no interpolation, which is the mechanical klaxon // point jumps −8° → +8° with no interpolation, which is the mechanical klaxon
@@ -311,6 +342,18 @@ export default function LiveAlarmScreen() {
</Pressable> </Pressable>
) : null} ) : null}
{acknowledged ? (
<Pressable
onPress={leave}
accessibilityRole="button"
accessibilityLabel="Back to your alerts"
style={styles.back}
>
<ChevronLeft size={18} color={onAlarm} />
<AppText style={styles.backLabel}>Back to alerts</AppText>
</Pressable>
) : null}
<AppText style={styles.footer}> <AppText style={styles.footer}>
{isOwner {isOwner
? 'Acknowledge silences this device only · Stop ends it for everyone' ? 'Acknowledge silences this device only · Stop ends it for everyone'
@@ -392,6 +435,16 @@ const styles = StyleSheet.create({
gap: space2, gap: space2,
}, },
stopLabel: { ...typeBodyStrong, color: onAlarm }, stopLabel: { ...typeBodyStrong, color: onAlarm },
back: {
height: 48,
borderRadius: radiusFull,
backgroundColor: 'transparent',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space1,
},
backLabel: { ...typeLabel, color: onAlarm },
disabled: { opacity: 0.4 }, disabled: { opacity: 0.4 },
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' }, footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
}); });
+8 -1
View File
@@ -103,8 +103,15 @@ export default function SettingsScreen() {
const listeningFailed = listening?.state === 'delivery-failed'; const listeningFailed = listening?.state === 'delivery-failed';
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null; const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
// The device token is the subscriber's credential (rev 10): without it this
// URL would be refused for an account-less device.
const wsUrl = resolved && activeAlert const wsUrl = resolved && activeAlert
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '') ? alarmSocketUrl(
resolved,
activeAlert.alertId,
activeAlert.subscribeToken ?? '',
activeAlert.deviceToken,
)
: null; : null;
const toggleService = async () => { const toggleService = async () => {
+18 -1
View File
@@ -280,6 +280,18 @@ export function isAlertGone(error: unknown): boolean {
return error instanceof ApiError && error.status === 404; return error instanceof ApiError && error.status === 404;
} }
/**
* A device token that no longer resolves. The service answers 401 for it, which
* is how a *destroyed* alert looks to a device credential: acknowledging the
* last owed alarm destroys the alert, the subscription row cascades away, and
* the token has nothing left to address. For a device-token call this is as
* terminal as a 404, and treating it as retryable is what keeps a dead
* subscription polling (and replaying) forever.
*/
export function isDeviceTokenRefused(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
/** /**
* Explicit acknowledgement of a delivered alarm. * Explicit acknowledgement of a delivered alarm.
* *
@@ -290,7 +302,12 @@ export function isAlertGone(error: unknown): boolean {
*/ */
export function ackAlarm(input: { export function ackAlarm(input: {
alertId: string; alertId: string;
eventId: string; /**
* Optional: the native takeover launch carries only the alert, so a dismissal
* from that path names no event. The service then clears this subscription's
* whole outstanding set — "I have heard the alarm" needs no uuid.
*/
eventId?: string;
/** Account path: the wire still addresses the ledger by (user, device). */ /** Account path: the wire still addresses the ledger by (user, device). */
deviceId?: string; deviceId?: string;
/** Subscriber path: the device token already names the subscription. */ /** Subscriber path: the device token already names the subscription. */
+21 -9
View File
@@ -13,23 +13,29 @@
* forever. * forever.
*/ */
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { ackAlarm, isAlertGone } from './alarmApi'; import { ackAlarm, isAlertGone, isDeviceTokenRefused } from './alarmApi';
const KEY = 'alarm.pendingAcks.v1'; const KEY = 'alarm.pendingAcks.v1';
export interface PendingAck { export interface PendingAck {
alertId: string; alertId: string;
eventId: string; /**
* Absent means "whatever this device still owes on the alert" — the native
* takeover launch knows the alert but not the event, and a suppression that
* could not be named would otherwise replay forever.
*/
eventId?: string;
deviceId: string; deviceId: string;
/** Subscriber credential for the alert (rev 10); absent for accounts. */ /** Subscriber credential for the alert (rev 10); absent for accounts. */
deviceToken?: string; deviceToken?: string;
} }
const ackKey = (ack: { alertId: string; eventId?: string }) =>
`${ack.alertId}:${ack.eventId ?? '*'}`;
/** Merge by (alertId, eventId); a repeat dismiss never duplicates an entry. */ /** Merge by (alertId, eventId); a repeat dismiss never duplicates an entry. */
export function mergeAck(list: PendingAck[], ack: PendingAck): PendingAck[] { export function mergeAck(list: PendingAck[], ack: PendingAck): PendingAck[] {
return list.some(a => a.alertId === ack.alertId && a.eventId === ack.eventId) return list.some(a => ackKey(a) === ackKey(ack)) ? list : [...list, ack];
? list
: [...list, ack];
} }
function parse(raw: string | null): PendingAck[] { function parse(raw: string | null): PendingAck[] {
@@ -42,7 +48,8 @@ function parse(raw: string | null): PendingAck[] {
!!v && !!v &&
typeof v === 'object' && typeof v === 'object' &&
typeof (v as PendingAck).alertId === 'string' && typeof (v as PendingAck).alertId === 'string' &&
typeof (v as PendingAck).eventId === 'string' && ((v as PendingAck).eventId === undefined ||
typeof (v as PendingAck).eventId === 'string') &&
typeof (v as PendingAck).deviceId === 'string' && typeof (v as PendingAck).deviceId === 'string' &&
((v as PendingAck).deviceToken === undefined || ((v as PendingAck).deviceToken === undefined ||
typeof (v as PendingAck).deviceToken === 'string'), typeof (v as PendingAck).deviceToken === 'string'),
@@ -71,8 +78,9 @@ export async function enqueueAck(ack: PendingAck): Promise<void> {
await writeAcks(mergeAck(await loadAcks(), ack)); await writeAcks(mergeAck(await loadAcks(), ack));
} }
export async function dropAck(alertId: string, eventId: string): Promise<void> { export async function dropAck(alertId: string, eventId?: string): Promise<void> {
const next = (await loadAcks()).filter(a => !(a.alertId === alertId && a.eventId === eventId)); const key = ackKey({ alertId, eventId });
const next = (await loadAcks()).filter(a => ackKey(a) !== key);
await writeAcks(next); await writeAcks(next);
} }
@@ -86,7 +94,11 @@ export async function flushAcks(): Promise<void> {
try { try {
await ackAlarm(ack); await ackAlarm(ack);
} catch (e) { } catch (e) {
if (!isAlertGone(e)) return; // 404: the alert is gone. 401 for a device token: the alert was destroyed
// by the very ack that preceded this retry, so the credential no longer
// resolves. Both are settled; retrying either is an infinite loop.
const settled = isAlertGone(e) || (!!ack.deviceToken && isDeviceTokenRefused(e));
if (!settled) return;
} }
await dropAck(ack.alertId, ack.eventId); await dropAck(ack.alertId, ack.eventId);
} }
+134 -5
View File
@@ -7,10 +7,20 @@ import { useAuth } from './AuthContext';
import { useConnection } from './ConnectionContext'; import { useConnection } from './ConnectionContext';
import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket'; import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket';
import { closeLiveAlarm, openLiveAlarm } from '../navigationRef'; import { closeLiveAlarm, openLiveAlarm } from '../navigationRef';
import { stopForegroundService } from '../services/alarmNative'; import {
import { enqueueAck, flushAcks } from '../services/pendingAcks'; getPermissions,
isAlarmNativeAvailable,
requestPermission,
startForegroundService,
stopForegroundService,
type AlarmServiceState,
} from '../services/alarmNative';
import { enqueueAck, flushAcks, loadAcks } from '../services/pendingAcks';
import { alarmSocketUrl } from '../services/instanceUrl';
import { useListeningState } from './useListeningState';
import { DEVICE_ID_STORAGE_KEY, isValidDeviceId, newDeviceId } from '../lib/deviceId'; import { DEVICE_ID_STORAGE_KEY, isValidDeviceId, newDeviceId } from '../lib/deviceId';
import { isPastDeadline } from '../lib/time'; import { isPastDeadline } from '../lib/time';
import { createDismissals } from '../lib/dismissals';
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1'; const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1'; const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
@@ -48,6 +58,12 @@ interface AlarmContextValue {
acknowledgeAlarm: (alertId: string, eventId: string | undefined) => Promise<void>; acknowledgeAlarm: (alertId: string, eventId: string | undefined) => Promise<void>;
lastStatus: api.AlarmStatus | null; lastStatus: api.AlarmStatus | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
/**
* Whether the background listener (the foreground service that holds the
* socket when the app is not focused) is actually running. `null` until read.
*/
backgroundListening: AlarmServiceState | null;
refreshBackgroundListening: () => Promise<void>;
reload: () => Promise<void>; reload: () => Promise<void>;
createAlert: (input: { createAlert: (input: {
name: string; name: string;
@@ -110,6 +126,31 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
const [endings, setEndings] = useState<Record<string, EndingReason>>({}); const [endings, setEndings] = useState<Record<string, EndingReason>>({});
const [lastEnding, setLastEnding] = useState<{ alertId: string; reason: EndingReason } | null>(null); const [lastEnding, setLastEnding] = useState<{ alertId: string; reason: EndingReason } | null>(null);
/**
* Triggers this device has already dismissed, per alert.
*
* The service keeps an alert "triggered" until the owner disarms it or every
* subscriber acknowledges, so its status keeps reporting the same trigger
* after this device dismissed it. Without this memory the reconciliation
* effect raises the takeover again on the next poll — the alarm that nobody
* can keep silenced. `*` is the entry for a dismissal that could not name its
* event (native takeover launch): it suppresses the trigger current at
* dismissal time and is replaced by the concrete id as soon as one is known.
*/
const dismissals = useRef(createDismissals());
const markDismissed = useCallback((alertId: string, eventId?: string | null) => {
dismissals.current.mark(alertId, eventId);
}, []);
// A queued dismissal survives a restart; seed the in-memory memory from it so
// a replay that arrives before the queue flushes cannot ring again.
useEffect(() => {
loadAcks()
.then(list => dismissals.current.seed(list))
.catch(() => {});
}, []);
// A per-install device id: stable across launches, unique per device. It // 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 // addresses this device in the delivery ledger (the service hashes it), so it
// must come from SecureRandom, not a timestamp-guessable generator. // must come from SecureRandom, not a timestamp-guessable generator.
@@ -205,6 +246,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
const handleTrigger = useCallback( const handleTrigger = useCallback(
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => { (frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
// Dismissed on this device: the commanded status keeps saying "triggered"
// until the owner disarms or everybody acks, so a status-driven raise for
// the same event is not news. See lib/dismissals.ts.
if (dismissals.current.suppress(frame.alertId, frame.eventId, live)) return;
const event: LiveAlarmEvent = { const event: LiveAlarmEvent = {
alertId: frame.alertId, alertId: frame.alertId,
eventId: frame.eventId, eventId: frame.eventId,
@@ -297,6 +342,83 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastStatus, activeAlert?.alertId]); }, [lastStatus, activeAlert?.alertId]);
const { state: backgroundListening, refresh: refreshBackgroundListening } = useListeningState();
/**
* Arms the background listener automatically.
*
* The armed socket lives in a native foreground service, not in this JS
* runtime: Android freezes/tears down the app process when it is backgrounded,
* so a subscription whose service was never started rings only while the app
* is on screen — the exact "alarms don't trigger if the app isn't focused"
* failure. Joining (or launching with a subscription) is therefore enough:
* the first enabled subscription becomes the armed alert and the service is
* started without asking the user to find a toggle.
*
* It is idempotent and throttled: a running service for the same alert is left
* alone, and a failed start (a missing grant) is retried at most every five
* minutes, because the alert screen states what is missing rather than looping.
*/
const armedAttemptRef = useRef<{ alertId: string; at: number } | null>(null);
useEffect(() => {
if (!isAlarmNativeAvailable) return;
if (!resolved || !online || !activeAlert?.subscribeToken) return;
// The service holds one credential and cannot refresh an expiring access
// token, so this path is the device-token one by construction. An
// account-backed subscription keeps the JS socket + check-in path.
if (!activeAlert.deviceToken) return;
if (backgroundListening?.state === 'running' && backgroundListening.alertId === activeAlert.alertId) {
return;
}
const now = Date.now();
const last = armedAttemptRef.current;
if (last && last.alertId === activeAlert.alertId && now - last.at < 300_000) return;
armedAttemptRef.current = { alertId: activeAlert.alertId, at: now };
let cancelled = false;
(async () => {
try {
const perms = await getPermissions();
if (!perms.notifications) await requestPermission('notifications');
const wsUrl = alarmSocketUrl(
resolved,
activeAlert.alertId,
activeAlert.subscribeToken as string,
activeAlert.deviceToken,
);
if (!wsUrl || cancelled) return;
await startForegroundService(
activeAlert.alertId,
activeAlert.subscribeToken as string,
wsUrl,
);
await refreshBackgroundListening();
} catch {
// The alert screen reports which grant is missing; nothing to loop on.
}
})();
return () => {
cancelled = true;
};
}, [
activeAlert,
backgroundListening?.alertId,
backgroundListening?.state,
online,
refreshBackgroundListening,
resolved,
]);
// Nothing subscribed → nothing to keep alive. Stops a stale service (and its
// persistent notification) after the last alert ends or is left.
useEffect(() => {
if (subscribedAlerts.length > 0) return;
if (backgroundListening?.state !== 'running') return;
stopForegroundService()
.then(() => refreshBackgroundListening())
.catch(() => {});
}, [backgroundListening?.state, refreshBackgroundListening, subscribedAlerts.length]);
const createAlert = useCallback( const createAlert = useCallback(
async (input: { name: string; message?: string; expiresInSeconds?: number }) => { async (input: { name: string; message?: string; expiresInSeconds?: number }) => {
const created = await api.createAlert(input); const created = await api.createAlert(input);
@@ -429,14 +551,19 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
*/ */
const acknowledgeAlarm = useCallback( const acknowledgeAlarm = useCallback(
async (alertId: string, eventId: string | undefined) => { async (alertId: string, eventId: string | undefined) => {
if (!deviceId || !eventId) return; if (!deviceId) return;
// A dismissal whose event is unknown (the native takeover launch carries
// only the alert) still names the trigger the status loop last reported;
// otherwise the service clears this subscription's whole outstanding set.
const known = eventId ?? lastStatus?.eventId ?? undefined;
markDismissed(alertId, known);
// A subscriber acks with the device token it joined with; an account acks // A subscriber acks with the device token it joined with; an account acks
// with its session token (the api client picks the right header). // with its session token (the api client picks the right header).
const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken; const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken;
await enqueueAck({ alertId, eventId, deviceId, deviceToken }); await enqueueAck({ alertId, eventId: known, deviceId, deviceToken });
await flushAcks(); await flushAcks();
}, },
[deviceId, subscribedAlerts], [deviceId, lastStatus?.eventId, markDismissed, subscribedAlerts],
); );
// Retry the durable queue on every plausible reconnect point, plus a slow // Retry the durable queue on every plausible reconnect point, plus a slow
@@ -472,6 +599,8 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
acknowledgeAlarm, acknowledgeAlarm,
lastStatus, lastStatus,
refresh, refresh,
backgroundListening,
refreshBackgroundListening,
reload, reload,
createAlert, createAlert,
joinAlert, joinAlert,
+22 -7
View File
@@ -7,7 +7,12 @@ import {
type SocketState, type SocketState,
} from '../services/alarmSocket'; } from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection'; import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi'; import {
alarmStatus as fetchAlarmStatus,
isAlertGone,
isDeviceTokenRefused,
type AlarmStatus,
} from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable'; export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
@@ -105,11 +110,12 @@ export function useAlarmSocket({
try { try {
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken)); applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken));
} catch (e) { } catch (e) {
if (isAlertGone(e)) onGoneRef.current?.(); // 404 = destroyed. 401 under a device token = the same thing: the alert
// A failed status read is not fatal — the socket is the primary path and // was destroyed by the last ack, so the credential addresses nothing.
// the next poll retries. // Either way the subscription is over; anything else is a transient read.
if (isAlertGone(e) || (!!deviceToken && isDeviceTokenRefused(e))) onGoneRef.current?.();
} }
}, [alertId, applyStatus, deviceId]); }, [alertId, applyStatus, deviceId, deviceToken]);
// AppState awareness: while backgrounded the JS socket is unreliable, so the // AppState awareness: while backgrounded the JS socket is unreliable, so the
// poll cadence relaxes to the background value and the socket is left to the // poll cadence relaxes to the background value and the socket is left to the
@@ -159,7 +165,7 @@ export function useAlarmSocket({
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken); const status = await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken);
if (!cancelled) applyStatus(status); if (!cancelled) applyStatus(status);
} catch (e) { } catch (e) {
if (isAlertGone(e)) onGoneRef.current?.(); if (isAlertGone(e) || (!!deviceToken && isDeviceTokenRefused(e))) onGoneRef.current?.();
// ignore otherwise — polling must never surface a spinner // ignore otherwise — polling must never surface a spinner
} }
}; };
@@ -169,7 +175,16 @@ export function useAlarmSocket({
cancelled = true; cancelled = true;
clearInterval(handle); clearInterval(handle);
}; };
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]); }, [
enabled,
alertId,
appState,
foregroundPollMs,
backgroundPollMs,
applyStatus,
deviceId,
deviceToken,
]);
return { socketState, delivery, attempts, lastStatus, refresh }; return { socketState, delivery, attempts, lastStatus, refresh };
} }