[fix](client): arm in the background, make dismiss final, and leave the takeover
The reported failures are one missing acknowledgement and one missing service start. - Alarms only fired with the app in focus: the armed WebSocket lives in the native foreground service, and nothing started it — the only entries were a button on one alert screen and a toggle in Settings. The provider now arms the active subscription itself (asks for POST_NOTIFICATIONS, uses the device token the service can hold, throttles a refused start to once per five minutes), and Home says whether the background listener is really running instead of presenting the on-screen socket as coverage. - Dismissed alarms came back: the takeover never sent the ack. acknowledgeAlarm existed and was never called, so the service still believed this device owed the alarm and replayed it on every connect/poll. The acknowledge path now persists the dismissal (durably, retried) and records it locally, so a status report of the trigger it already dismissed cannot raise the takeover again. The record tolerates a dismissal with no event id (the native launch carries only the alert) and is pinned to the concrete event as soon as one is known. - No way back from the takeover: acknowledge now shows a Back to alerts action, hardware back works once the device is silent, and the screen leaves by itself when the alert ends for this device. - A destroyed alert answers 401 to its device token (the subscription went with it). That is terminal: acknowledgements and the status poll stop retrying the dead credential and mark the subscription ended instead of looping. - Settings' start-listening button built the socket URL without the device token, so it could never arm a subscriber's service. tsc clean, 144 tests (15 new: dismissal memory, background arming, dead credential, unnamed ack queue).
This commit is contained in:
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) ?? [])],
|
||||
};
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export default function HomeScreen() {
|
||||
liveEvent,
|
||||
delivery,
|
||||
socketState,
|
||||
backgroundListening,
|
||||
lastEnding,
|
||||
clearLastEnding,
|
||||
} = useAlarm();
|
||||
@@ -246,12 +247,17 @@ export default function HomeScreen() {
|
||||
/>
|
||||
))}
|
||||
{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}>
|
||||
{socketState === 'open'
|
||||
? 'Live delivery connected — triggers arrive over the instance 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.'}
|
||||
{backgroundListening?.state === 'running'
|
||||
? 'Listening in the background — triggers arrive even when this app is closed.'
|
||||
: delivery !== 'socket'
|
||||
? '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>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||
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 { useAuth } from '../state/AuthContext';
|
||||
import { AppText } from '../components/ui';
|
||||
@@ -21,6 +21,7 @@ import type { RootStackParamList } from '../navigation';
|
||||
import {
|
||||
motionSiren,
|
||||
radiusFull,
|
||||
space1,
|
||||
space2,
|
||||
space3,
|
||||
space4,
|
||||
@@ -61,8 +62,16 @@ const STOP_LABEL = 'Stop for everyone';
|
||||
export default function LiveAlarmScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { params } = useRoute<Rt>();
|
||||
const { liveEvent, clearLiveEvent, adminAlerts, adminTokenFor, subscribedAlerts, updateAlertLocal } =
|
||||
useAlarm();
|
||||
const {
|
||||
liveEvent,
|
||||
clearLiveEvent,
|
||||
adminAlerts,
|
||||
adminTokenFor,
|
||||
subscribedAlerts,
|
||||
updateAlertLocal,
|
||||
acknowledgeAlarm,
|
||||
endingFor,
|
||||
} = useAlarm();
|
||||
const { username } = useAuth();
|
||||
|
||||
const alertId = params.alertId;
|
||||
@@ -181,11 +190,28 @@ export default function LiveAlarmScreen() {
|
||||
} catch {
|
||||
// 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
|
||||
// event no longer needs to drive a takeover — but the screen stays mounted
|
||||
// in its acknowledged state until the user leaves it.
|
||||
// event no longer needs to drive a takeover.
|
||||
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 () => {
|
||||
if (!adminToken) {
|
||||
@@ -206,12 +232,17 @@ export default function LiveAlarmScreen() {
|
||||
}
|
||||
}, [adminToken, alertId, leave, updateAlertLocal]);
|
||||
|
||||
// Hardware back and the back gesture are consumed: the only exits are
|
||||
// Acknowledge and Stop alarm (spec, Cross-screen rules).
|
||||
// While the alarm is live, back is consumed: the only exits are Acknowledge
|
||||
// and Stop alarm (spec, Cross-screen rules). Once this device has been
|
||||
// acknowledged it is silent, so back is the way home again.
|
||||
useEffect(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (!acknowledged) return true;
|
||||
leave();
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
}, [acknowledged, leave]);
|
||||
|
||||
// `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
|
||||
@@ -311,6 +342,18 @@ export default function LiveAlarmScreen() {
|
||||
</Pressable>
|
||||
) : 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}>
|
||||
{isOwner
|
||||
? 'Acknowledge silences this device only · Stop ends it for everyone'
|
||||
@@ -392,6 +435,16 @@ const styles = StyleSheet.create({
|
||||
gap: space2,
|
||||
},
|
||||
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 },
|
||||
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
|
||||
});
|
||||
|
||||
@@ -103,8 +103,15 @@ export default function SettingsScreen() {
|
||||
const listeningFailed = listening?.state === 'delivery-failed';
|
||||
|
||||
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
|
||||
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '')
|
||||
? alarmSocketUrl(
|
||||
resolved,
|
||||
activeAlert.alertId,
|
||||
activeAlert.subscribeToken ?? '',
|
||||
activeAlert.deviceToken,
|
||||
)
|
||||
: null;
|
||||
|
||||
const toggleService = async () => {
|
||||
|
||||
@@ -280,6 +280,18 @@ export function isAlertGone(error: unknown): boolean {
|
||||
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.
|
||||
*
|
||||
@@ -290,7 +302,12 @@ export function isAlertGone(error: unknown): boolean {
|
||||
*/
|
||||
export function ackAlarm(input: {
|
||||
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). */
|
||||
deviceId?: string;
|
||||
/** Subscriber path: the device token already names the subscription. */
|
||||
|
||||
@@ -13,23 +13,29 @@
|
||||
* forever.
|
||||
*/
|
||||
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';
|
||||
|
||||
export interface PendingAck {
|
||||
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;
|
||||
/** Subscriber credential for the alert (rev 10); absent for accounts. */
|
||||
deviceToken?: string;
|
||||
}
|
||||
|
||||
const ackKey = (ack: { alertId: string; eventId?: string }) =>
|
||||
`${ack.alertId}:${ack.eventId ?? '*'}`;
|
||||
|
||||
/** 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];
|
||||
return list.some(a => ackKey(a) === ackKey(ack)) ? list : [...list, ack];
|
||||
}
|
||||
|
||||
function parse(raw: string | null): PendingAck[] {
|
||||
@@ -42,7 +48,8 @@ function parse(raw: string | null): PendingAck[] {
|
||||
!!v &&
|
||||
typeof v === 'object' &&
|
||||
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' &&
|
||||
((v as PendingAck).deviceToken === undefined ||
|
||||
typeof (v as PendingAck).deviceToken === 'string'),
|
||||
@@ -71,8 +78,9 @@ 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));
|
||||
export async function dropAck(alertId: string, eventId?: string): Promise<void> {
|
||||
const key = ackKey({ alertId, eventId });
|
||||
const next = (await loadAcks()).filter(a => ackKey(a) !== key);
|
||||
await writeAcks(next);
|
||||
}
|
||||
|
||||
@@ -86,7 +94,11 @@ export async function flushAcks(): Promise<void> {
|
||||
try {
|
||||
await ackAlarm(ack);
|
||||
} 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);
|
||||
}
|
||||
|
||||
+134
-5
@@ -7,10 +7,20 @@ 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 {
|
||||
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 { isPastDeadline } from '../lib/time';
|
||||
import { createDismissals } from '../lib/dismissals';
|
||||
|
||||
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
|
||||
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
|
||||
@@ -48,6 +58,12 @@ interface AlarmContextValue {
|
||||
acknowledgeAlarm: (alertId: string, eventId: string | undefined) => Promise<void>;
|
||||
lastStatus: api.AlarmStatus | null;
|
||||
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>;
|
||||
createAlert: (input: {
|
||||
name: string;
|
||||
@@ -110,6 +126,31 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [endings, setEndings] = useState<Record<string, EndingReason>>({});
|
||||
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
|
||||
// addresses this device in the delivery ledger (the service hashes it), so it
|
||||
// must come from SecureRandom, not a timestamp-guessable generator.
|
||||
@@ -205,6 +246,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const handleTrigger = useCallback(
|
||||
(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 = {
|
||||
alertId: frame.alertId,
|
||||
eventId: frame.eventId,
|
||||
@@ -297,6 +342,83 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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(
|
||||
async (input: { name: string; message?: string; expiresInSeconds?: number }) => {
|
||||
const created = await api.createAlert(input);
|
||||
@@ -429,14 +551,19 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
const acknowledgeAlarm = useCallback(
|
||||
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
|
||||
// with its session token (the api client picks the right header).
|
||||
const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken;
|
||||
await enqueueAck({ alertId, eventId, deviceId, deviceToken });
|
||||
await enqueueAck({ alertId, eventId: known, deviceId, deviceToken });
|
||||
await flushAcks();
|
||||
},
|
||||
[deviceId, subscribedAlerts],
|
||||
[deviceId, lastStatus?.eventId, markDismissed, subscribedAlerts],
|
||||
);
|
||||
|
||||
// Retry the durable queue on every plausible reconnect point, plus a slow
|
||||
@@ -472,6 +599,8 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
acknowledgeAlarm,
|
||||
lastStatus,
|
||||
refresh,
|
||||
backgroundListening,
|
||||
refreshBackgroundListening,
|
||||
reload,
|
||||
createAlert,
|
||||
joinAlert,
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
type SocketState,
|
||||
} from '../services/alarmSocket';
|
||||
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';
|
||||
|
||||
@@ -105,11 +110,12 @@ export function useAlarmSocket({
|
||||
try {
|
||||
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken));
|
||||
} catch (e) {
|
||||
if (isAlertGone(e)) onGoneRef.current?.();
|
||||
// A failed status read is not fatal — the socket is the primary path and
|
||||
// the next poll retries.
|
||||
// 404 = destroyed. 401 under a device token = the same thing: the alert
|
||||
// was destroyed by the last ack, so the credential addresses nothing.
|
||||
// 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
|
||||
// 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);
|
||||
if (!cancelled) applyStatus(status);
|
||||
} catch (e) {
|
||||
if (isAlertGone(e)) onGoneRef.current?.();
|
||||
if (isAlertGone(e) || (!!deviceToken && isDeviceTokenRefused(e))) onGoneRef.current?.();
|
||||
// ignore otherwise — polling must never surface a spinner
|
||||
}
|
||||
};
|
||||
@@ -169,7 +175,16 @@ export function useAlarmSocket({
|
||||
cancelled = true;
|
||||
clearInterval(handle);
|
||||
};
|
||||
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
|
||||
}, [
|
||||
enabled,
|
||||
alertId,
|
||||
appState,
|
||||
foregroundPollMs,
|
||||
backgroundPollMs,
|
||||
applyStatus,
|
||||
deviceId,
|
||||
deviceToken,
|
||||
]);
|
||||
|
||||
return { socketState, delivery, attempts, lastStatus, refresh };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user