[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,
|
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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user