Compare commits
10
Commits
531e9ccf15
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0771a69d7d | ||
|
|
06afd92647 | ||
|
|
98625adb25 | ||
|
|
31cff32824 | ||
|
|
020a90f0d3 | ||
|
|
b32df04ab9 | ||
|
|
b5c1710b99 | ||
|
|
6cd6bf3ed3 | ||
|
|
1f62137754 | ||
|
|
ff07df7ae6 |
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { StatusBar, View } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { HeroUINativeProviderRaw } from 'heroui-native/provider-raw';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { DarkTheme, NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ThemeProvider } from './src/state/ThemeContext';
|
||||
@@ -17,12 +18,32 @@ import ScannerScreen from './src/screens/ScannerScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import LiveAlarmScreen from './src/screens/LiveAlarmScreen';
|
||||
import { LoadingView } from './src/components/ui';
|
||||
import { bg, border, focusRing, text } from './src/theme/tokens';
|
||||
import { navigationRef, openLiveAlarm } from './src/navigationRef';
|
||||
import { getLaunchAlert } from './src/services/alarmNative';
|
||||
import type { RootStackParamList } from './src/navigation';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
/**
|
||||
* React Navigation paints each scene with its own theme background, and the
|
||||
* default theme is light — that white surface is what showed through every
|
||||
* screen that did not paint `bg` itself (the login page, the boot loader),
|
||||
* producing light text on white. The app palette is dark by construction, so
|
||||
* the navigator theme has to say so too.
|
||||
*/
|
||||
const NAV_THEME = {
|
||||
...DarkTheme,
|
||||
colors: {
|
||||
...DarkTheme.colors,
|
||||
background: bg,
|
||||
card: bg,
|
||||
text,
|
||||
border,
|
||||
primary: focusRing,
|
||||
},
|
||||
};
|
||||
|
||||
function Navigator({ initialRoute }: { initialRoute: 'Home' | 'Login' }) {
|
||||
return (
|
||||
<Stack.Navigator initialRouteName={initialRoute} screenOptions={{ headerShown: false }}>
|
||||
@@ -111,11 +132,9 @@ function Root() {
|
||||
useEffect(() => {
|
||||
if (token === prevToken.current) return;
|
||||
prevToken.current = token;
|
||||
if (token) {
|
||||
// Home is always reachable: without an account it is the subscriber view
|
||||
// (rev 10). Login is an explicit destination, never a wall.
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
|
||||
} else {
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
||||
}
|
||||
}, [token, navigation]);
|
||||
|
||||
useNativeLaunchTakeover();
|
||||
@@ -123,25 +142,55 @@ function Root() {
|
||||
if (booting) return <LoadingView label="Starting…" />;
|
||||
return (
|
||||
<AlarmProvider>
|
||||
<Navigator initialRoute={token ? 'Home' : 'Login'} />
|
||||
<Navigator initialRoute="Home" />
|
||||
</AlarmProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* React Native's equivalent of Flutter's `SafeArea`: pad the whole tree by the
|
||||
* platform's window insets once, so no screen can draw its header under the
|
||||
* status bar or its buttons under the navigation bar.
|
||||
*
|
||||
* This is not optional on Android 15+: with `targetSdk 36` the system enforces
|
||||
* edge-to-edge, so the window extends behind both system bars and the app is
|
||||
* responsible for the padding. Without it the Home header's settings gear sat
|
||||
* underneath the notification bar and could not be tapped.
|
||||
*/
|
||||
function SafeAreaFrame({ children }: { children: React.ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: bg }}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={bg} />
|
||||
<HeroUINativeProviderRaw>
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaFrame>
|
||||
<ThemeProvider>
|
||||
<ConnectionProvider>
|
||||
<AuthProvider>
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<NavigationContainer ref={navigationRef} theme={NAV_THEME}>
|
||||
<Root />
|
||||
</NavigationContainer>
|
||||
</AuthProvider>
|
||||
</ConnectionProvider>
|
||||
</ThemeProvider>
|
||||
</SafeAreaFrame>
|
||||
</SafeAreaProvider>
|
||||
</HeroUINativeProviderRaw>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -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,137 @@
|
||||
/**
|
||||
* Rev 10: a subscriber is authenticated by a device token, not by an account
|
||||
* and not by a fake user. The client must send it as `X-Device-Token` on every
|
||||
* subscriber call — and it must win over an account token when both exist,
|
||||
* because it is the narrower credential.
|
||||
*/
|
||||
import { configureApi, get, post } from '../src/services/apiClient';
|
||||
import { ackAlarm, alarmStatus, getAlert, joinAlert, qrImage } from '../src/services/alarmApi';
|
||||
|
||||
const g = globalThis as unknown as { fetch: typeof fetch };
|
||||
const realFetch = g.fetch;
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
let calls: Call[] = [];
|
||||
|
||||
function stub(status = 200, body: unknown = {}): void {
|
||||
calls = [];
|
||||
g.fetch = (async (url: string, init: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
});
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configureApi({
|
||||
baseUrl: 'http://instance.test',
|
||||
token: 'account-token',
|
||||
refreshToken: null,
|
||||
onUnauthorized: () => {},
|
||||
onSessionUpdate: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
g.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe('device token transport', () => {
|
||||
it('is sent as X-Device-Token and replaces the account token', async () => {
|
||||
stub(200, {});
|
||||
await get('/action/getAlert?alertId=a', 'dev-token');
|
||||
expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
|
||||
expect(calls[0].headers['X-Access-Token']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is omitted (and the account token used) when not supplied', async () => {
|
||||
stub(200, {});
|
||||
await get('/action/getAlert?alertId=a');
|
||||
expect(calls[0].headers['X-Device-Token']).toBeUndefined();
|
||||
expect(calls[0].headers['X-Access-Token']).toBe('account-token');
|
||||
});
|
||||
|
||||
it('survives a request with no session at all', async () => {
|
||||
configureApi({ token: null, refreshToken: null });
|
||||
stub(200, {});
|
||||
await post('/action/ackAlarm', { alertId: 'a', eventId: 'e' }, false, 'dev-token');
|
||||
expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
|
||||
expect(calls[0].headers['X-Access-Token']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscriber calls use the device token', () => {
|
||||
it('getAlert passes it through', async () => {
|
||||
stub(200, { alertId: 'a' });
|
||||
await getAlert('a', 'dev-token');
|
||||
expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
|
||||
});
|
||||
|
||||
it('alarmStatus passes it through', async () => {
|
||||
stub(200, { active: false });
|
||||
await alarmStatus('a', undefined, 'dev-token');
|
||||
expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
|
||||
});
|
||||
|
||||
it('ackAlarm passes it through and omits deviceId when the token names the row', async () => {
|
||||
stub(200, { acked: 1 });
|
||||
await ackAlarm({ alertId: 'a', eventId: 'e', deviceToken: 'dev-token' });
|
||||
expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
|
||||
expect(calls[0].url).not.toContain('deviceId=');
|
||||
});
|
||||
|
||||
it('qrImage passes it through', async () => {
|
||||
g.fetch = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
|
||||
const blob = { data: 'png' };
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => blob,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
const RealReader = (globalThis as { FileReader?: unknown }).FileReader;
|
||||
class FakeReader {
|
||||
result: string | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
readAsDataURL() {
|
||||
this.result = 'data:image/png;base64,cG5n';
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
(globalThis as { FileReader?: unknown }).FileReader = FakeReader;
|
||||
try {
|
||||
await qrImage('a', 'dev-token');
|
||||
} finally {
|
||||
(globalThis as { FileReader?: unknown }).FileReader = RealReader;
|
||||
}
|
||||
expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinAlert', () => {
|
||||
it('is the unauthenticated path: no access token, and it returns the device token once', async () => {
|
||||
configureApi({ token: null, refreshToken: null });
|
||||
stub(200, { id: 'sub-1', deviceToken: 'issued-once' });
|
||||
const joined = await joinAlert({ alertId: 'a', subscribeToken: 's', deviceId: 'd' });
|
||||
expect(joined.deviceToken).toBe('issued-once');
|
||||
expect(calls[0].headers['X-Access-Token']).toBeUndefined();
|
||||
expect(calls[0].headers['X-Device-Token']).toBeUndefined();
|
||||
// The payload wrapper the service expects, with no credential fields.
|
||||
expect(JSON.parse(calls[0].body ?? '{}').payload).toMatchObject({ alertId: 'a', deviceId: 'd' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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('does not let a live socket frame undo a dismissal', () => {
|
||||
// Measured: after dismissing, a queued native takeover replayed the trigger
|
||||
// down the socket and the siren came back. The alert is triggered once, so
|
||||
// a second "live" frame for a dismissed alert is the same alarm.
|
||||
const d = createDismissals();
|
||||
d.mark('a', EVENT);
|
||||
expect(d.suppress('a', EVENT, true)).toBe(true);
|
||||
expect(d.suppress('a', EVENT, false)).toBe(true);
|
||||
});
|
||||
|
||||
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('a dismissal must survive the app being killed', () => {
|
||||
it('queues the ack even before the device identity is loaded', () => {
|
||||
// Measured against the service: a device-token ack with no deviceId is
|
||||
// accepted (the credential identifies the subscription). `acknowledgeAlarm`
|
||||
// used to `return` when `deviceId` was still null, silently dropping the
|
||||
// dismissal — the alarm then owed the ledger again and rang on reopen.
|
||||
const context = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const at = context.indexOf('const acknowledgeAlarm = useCallback');
|
||||
const body = context.slice(at, at + 1600);
|
||||
expect(body).not.toContain('if (!deviceId) return;');
|
||||
expect(body).toContain('enqueueAck(');
|
||||
expect(body).toContain('deviceId: deviceId ?? undefined');
|
||||
});
|
||||
|
||||
it('does not raise an alarm before the queued dismissals are known', () => {
|
||||
const context = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const at = context.indexOf('const handleTrigger = useCallback');
|
||||
const body = context.slice(at, at + 900);
|
||||
expect(body).toContain('dismissalsReadyRef.current');
|
||||
// …and the armed service must not start listening before that either.
|
||||
const armAt = context.indexOf('const armedAttemptRef');
|
||||
const arm = context.slice(armAt, armAt + 900);
|
||||
expect(arm).toContain('if (!dismissalsReady) return;');
|
||||
});
|
||||
|
||||
it('unsubscribes the device as part of acknowledging', () => {
|
||||
// Owner ruling: a subscriber that acknowledged is done with the alarm, so
|
||||
// the acknowledgement drops the subscription instead of leaving a live
|
||||
// address that rings again on the next launch.
|
||||
const context = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const at = context.indexOf('const acknowledgeAlarm = useCallback');
|
||||
const body = context.slice(at, at + 2600);
|
||||
expect(body).toContain('api.removeSubscription(');
|
||||
expect(body).toContain("markEnded(alertId, 'left')");
|
||||
// The ack settles the ledger *before* the row goes, so a failed removal can
|
||||
// never leave the debt behind.
|
||||
expect(body.indexOf('flushAcks(')).toBeLessThan(body.indexOf('api.removeSubscription('));
|
||||
});
|
||||
|
||||
it('flushes a queued ack that has no device id of its own', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'services', 'pendingAcks.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).toContain('deviceId?: string;');
|
||||
expect(source).toContain('flushAcks(fallbackDeviceId?: string)');
|
||||
expect(source).toContain('ack.deviceId ?? fallbackDeviceId');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -39,10 +39,41 @@ describe('native alarm delivery contract', () => {
|
||||
it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
|
||||
expect(scheduler).toContain('setAlarmClock(');
|
||||
expect(scheduler).toContain('AlarmClockInfo(');
|
||||
expect(scheduler).toContain('AlarmActivity.intent(');
|
||||
expect(scheduler).toContain('canScheduleExactAlarms');
|
||||
});
|
||||
|
||||
it('never makes the alarm clock entry launch an activity', () => {
|
||||
// Measured on Android 15: a background activity launch from the alarm
|
||||
// clock's PendingIntent is refused (BAL_BLOCK) and then *deferred*, so the
|
||||
// window surfaced later — after the alarm had been acknowledged — and the
|
||||
// siren came back for a delivered alert. The screen is raised by the
|
||||
// full-screen-intent notification; the entry itself must only wake the
|
||||
// process.
|
||||
const fireAt = scheduler.indexOf('fun fireNow(');
|
||||
const fire = scheduler
|
||||
.slice(fireAt, fireAt + 2000)
|
||||
.split('\n')
|
||||
.filter(line => !line.trimStart().startsWith('//'))
|
||||
.join('\n');
|
||||
expect(fire).toContain('PendingIntent.getBroadcast(');
|
||||
expect(fire).toContain('AlarmWakeReceiver::class.java');
|
||||
expect(fire).not.toContain('PendingIntent.getActivity(');
|
||||
expect(fire).not.toContain('AlarmActivity.intent(');
|
||||
// The one activity PendingIntent left is the alarm-clock *show* intent,
|
||||
// which only the user can invoke by tapping the status-bar alarm icon.
|
||||
const showAt = scheduler.indexOf('private fun showIntent(');
|
||||
const show = scheduler.slice(showAt);
|
||||
expect(show).toContain('PendingIntent.getActivity(');
|
||||
const code = scheduler
|
||||
.split('\n')
|
||||
.filter(line => !line.trimStart().startsWith('//'))
|
||||
.join('\n');
|
||||
expect(code.split('PendingIntent.getActivity(').length - 1).toBe(1);
|
||||
// The receiver must ignore an entry that outlived the arming.
|
||||
expect(receiver).toContain('AlarmScheduler.ACTION_FIRE');
|
||||
expect(receiver).toContain('armed?.alertId != alertId');
|
||||
});
|
||||
|
||||
it('fires the alarm from the trigger frame through the scheduler, not startActivity', () => {
|
||||
expect(service).toContain('AlarmScheduler.fireNow(');
|
||||
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
|
||||
@@ -78,21 +109,33 @@ describe('native alarm delivery contract', () => {
|
||||
expect(cancelFireBody).toContain('REQ_FIRE');
|
||||
});
|
||||
|
||||
it('has exactly two notification channels and no full-screen-intent notification', () => {
|
||||
// The old contract said "no notification ever announces an alarm" — that was
|
||||
// falsified on an Android 15 emulator: the alarm's own PendingIntent cannot
|
||||
// start the takeover from the background (`BAL_BLOCK`), so the ringing alarm
|
||||
// needs the one notification the platform does launch for you, a full-screen
|
||||
// intent. Delivery is still the alarm clock + the service's own audio stream;
|
||||
// the notification is only the launch path for the screen.
|
||||
it('has three channels, and exactly one full-screen-intent notification', () => {
|
||||
const channels = notifier.split('= NotificationChannel(').length - 1;
|
||||
expect(channels).toBe(2);
|
||||
expect(channels).toBe(3);
|
||||
expect(notifier).toContain('CHANNEL_VOLUME');
|
||||
expect(notifier).toContain('CHANNEL_SERVICE');
|
||||
// The volume warning is informational; only an alarm may take over the screen,
|
||||
// and that is scheduled, not notified.
|
||||
expect(notifier).not.toContain('setFullScreenIntent');
|
||||
expect(notifier).toContain('CHANNEL_FIRING');
|
||||
const fsi = notifier.split('setFullScreenIntent').length - 1;
|
||||
expect(fsi).toBe(1);
|
||||
// The firing channel is silent: the siren is our own audio stream, and a
|
||||
// channel sound on top of it would double the alarm.
|
||||
const firingAt = notifier.indexOf('CHANNEL_FIRING,');
|
||||
expect(notifier.slice(firingAt, firingAt + 400)).toContain('setSound(null, null)');
|
||||
});
|
||||
|
||||
it('only warns about volume, never about a ringing alarm', () => {
|
||||
it('keeps the volume warning informational and never arms from a notification', () => {
|
||||
expect(notifier).toContain('Alarm volume is at ');
|
||||
expect(notifier).not.toMatch(/Alarm ringing/);
|
||||
expect(notifier).not.toMatch(/Alarm armed"\)/);
|
||||
expect(notifier).toContain('CATEGORY_RECOMMENDATION');
|
||||
// The volume warning must not carry the takeover: only the firing
|
||||
// notification may take over the screen.
|
||||
const warningAt = notifier.indexOf('fun showVolumeWarning');
|
||||
expect(notifier.slice(warningAt, warningAt + 1200)).not.toContain('setFullScreenIntent');
|
||||
});
|
||||
|
||||
it('refuses to arm a device that cannot deliver a real alarm', () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
export {};
|
||||
|
||||
/**
|
||||
* The takeover must reach the screen from the background.
|
||||
*
|
||||
* Measured on an Android 15 emulator: the alarm rang with the app backgrounded,
|
||||
* but `AlarmActivity` was refused by the platform —
|
||||
* `Background activity launch blocked! … BAL_BLOCK` — because an activity launched
|
||||
* from the alarm's own PendingIntent is subject to the Android 14+ background
|
||||
* launch rules. The siren was audible and nothing was on screen.
|
||||
*
|
||||
* The platform's sanctioned path is a notification **full-screen intent**, which
|
||||
* SystemUI launches. These assertions pin that structure, because it cannot be
|
||||
* exercised in a Jest run (no Android runtime): the firing notification must
|
||||
* carry the FSI, the alarm category and the takeover's own intent, and silencing
|
||||
* the alarm must retract it.
|
||||
*/
|
||||
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 NATIVE = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'android',
|
||||
'app',
|
||||
'src',
|
||||
'main',
|
||||
'java',
|
||||
'com',
|
||||
'dcntrlized',
|
||||
'alarm',
|
||||
);
|
||||
const player = fs.readFileSync(path.join(NATIVE, 'AlarmPlayer.kt'), 'utf8');
|
||||
const service = fs.readFileSync(path.join(NATIVE, 'AlarmForegroundService.kt'), 'utf8');
|
||||
|
||||
describe('background takeover launch', () => {
|
||||
it('posts a full-screen-intent notification for a trigger', () => {
|
||||
expect(player).toContain('fun showFiring(');
|
||||
expect(player).toContain('setFullScreenIntent(open, true)');
|
||||
expect(player).toContain('Notification.CATEGORY_ALARM');
|
||||
expect(player).toContain('AlarmActivity.intent(context, alertId, message)');
|
||||
});
|
||||
|
||||
it('raises it from the service that received the frame', () => {
|
||||
const at = service.indexOf('"trigger" ->');
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const branch = service.slice(at, at + 700);
|
||||
expect(branch).toContain('AlarmNotifier.showFiring(');
|
||||
expect(branch.indexOf('showFiring')).toBeLessThan(branch.indexOf('AlarmPlayer.play'));
|
||||
});
|
||||
|
||||
it('retracts it when the alarm is silenced — but not from AlarmPlayer.stop', () => {
|
||||
// play() calls stop() first, so cancelling there retracted the full-screen
|
||||
// intent on every single trigger (measured: NotificationService "Cannot find
|
||||
// enqueued record for key 4713" 120 ms after the trigger).
|
||||
const stop = player.slice(player.indexOf('fun stop(context: Context)'));
|
||||
expect(stop.slice(0, 1200)).not.toContain('cancelFiring');
|
||||
expect(service).toContain('AlarmNotifier.cancelFiring(this)');
|
||||
// A disarm and an explicit stop both retract it.
|
||||
expect(service.split('AlarmNotifier.cancelFiring(this)').length - 1).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('closes its own window when the user leaves — a route pop is not enough', () => {
|
||||
// Measured on the emulator: after acknowledging, the app returned to its
|
||||
// previous screen but the AlarmActivity window stayed on top for good
|
||||
// (show-when-locked, keep-screen-on, excludeFromRecents) — the reported
|
||||
// "no way back to the main menu".
|
||||
const activity = fs.readFileSync(path.join(NATIVE, 'AlarmActivity.kt'), 'utf8');
|
||||
const bridge = fs.readFileSync(path.join(NATIVE, 'AlarmModule.kt'), 'utf8');
|
||||
const screen = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
expect(activity).toContain('fun currentActivity()');
|
||||
expect(activity).toContain('current = null');
|
||||
const finishAt = bridge.indexOf('fun finishTakeover(');
|
||||
expect(finishAt).toBeGreaterThan(-1);
|
||||
expect(bridge.slice(finishAt, finishAt + 400)).toContain('.finish()');
|
||||
const leaveAt = screen.indexOf('const leave = useCallback(');
|
||||
expect(screen.slice(leaveAt, leaveAt + 600)).toContain('finishTakeover()');
|
||||
});
|
||||
|
||||
it('tells the truth when the FSI grant is missing', () => {
|
||||
expect(player).toContain('canUseFullScreenIntent()');
|
||||
expect(player).toContain('heads-up only');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import java.lang.ref.WeakReference
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
@@ -38,6 +39,7 @@ class AlarmActivity : ReactActivity() {
|
||||
// Order matters: the flags must be set before the window is shown.
|
||||
showOverLockScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
current = WeakReference(this)
|
||||
}
|
||||
|
||||
private fun showOverLockScreen() {
|
||||
@@ -67,11 +69,30 @@ class AlarmActivity : ReactActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (current?.get() === this) current = null
|
||||
AlarmPlayer.stop(this)
|
||||
// The ringing notification dies with the ring it announced. NOT in
|
||||
// AlarmPlayer.stop: play() calls that first, which would retract the
|
||||
// full-screen intent that raised this activity.
|
||||
AlarmNotifier.cancelFiring(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The takeover that is on screen, if any.
|
||||
*
|
||||
* The JS "Back to alerts" action pops the route but cannot dismiss an
|
||||
* Android activity, so without this the alarm window would stay on top
|
||||
* for good — visible over the lock screen, holding the screen awake, in a
|
||||
* task that is excluded from Recents. That is the reported "no way back to
|
||||
* the main menu". [AlarmModule.finishTakeover] ends it.
|
||||
*/
|
||||
@Volatile
|
||||
private var current: WeakReference<AlarmActivity>? = null
|
||||
|
||||
fun currentActivity(): AlarmActivity? = current?.get()
|
||||
|
||||
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||
const val EXTRA_MESSAGE = "alarm.message"
|
||||
|
||||
|
||||
@@ -220,6 +220,11 @@ class AlarmForegroundService : Service() {
|
||||
|
||||
when (action) {
|
||||
"trigger" -> {
|
||||
// Raise the screen first: SystemUI launches the takeover from
|
||||
// this notification's full-screen intent, which is the only
|
||||
// launch path Android 14+ does not block from the background
|
||||
// (the alarm's own PendingIntent is refused — BAL_BLOCK).
|
||||
AlarmNotifier.showFiring(this, id, message)
|
||||
val volumeRaised = AlarmPlayer.play(this, id)
|
||||
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
||||
// A real alarm, not a notification: setAlarmClock is what an
|
||||
@@ -233,6 +238,7 @@ class AlarmForegroundService : Service() {
|
||||
}
|
||||
"disarm" -> {
|
||||
AlarmPlayer.stop(this)
|
||||
AlarmNotifier.cancelFiring(this)
|
||||
AlarmNotifier.cancelVolumeWarning(this)
|
||||
// Only the firing entry dies here: a disarm ends this ring,
|
||||
// not the subscription. The standing entry (alarm-app status
|
||||
@@ -259,6 +265,7 @@ class AlarmForegroundService : Service() {
|
||||
stopping = true
|
||||
disconnect()
|
||||
AlarmPlayer.stop(this)
|
||||
AlarmNotifier.cancelFiring(this)
|
||||
AlarmNotifier.cancelVolumeWarning(this)
|
||||
// Explicit stop (user asked): idle, not "timed out". Leaving the arm
|
||||
// record or the standing entry behind would keep an alarm icon for an
|
||||
|
||||
@@ -46,6 +46,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
||||
fun stopAlarm(promise: Promise) {
|
||||
try {
|
||||
AlarmPlayer.stop(reactContext)
|
||||
AlarmNotifier.cancelFiring(reactContext)
|
||||
AlarmNotifier.cancelVolumeWarning(reactContext)
|
||||
promise.resolve(result(ok = true))
|
||||
} catch (e: Exception) {
|
||||
@@ -240,6 +241,21 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the full-screen takeover, returning the user to the app they were in.
|
||||
*
|
||||
* A no-op unless the alarm activity is alive: when the alarm fires while the
|
||||
* app is in the foreground the takeover is a JS route, not an activity.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun finishTakeover(promise: Promise) {
|
||||
val activity = AlarmActivity.currentActivity()
|
||||
if (activity != null) {
|
||||
activity.finish()
|
||||
}
|
||||
promise.resolve(result(ok = activity != null))
|
||||
}
|
||||
|
||||
/** Opens this app's Android settings page (used by the camera-denied state). */
|
||||
@ReactMethod
|
||||
fun openAppSettings(promise: Promise) {
|
||||
|
||||
@@ -171,6 +171,9 @@ object AlarmNotifier {
|
||||
/** Foreground-service presence. Deliberately the quietest channel. */
|
||||
const val CHANNEL_SERVICE = "alarm_armed"
|
||||
|
||||
/** The ringing alarm itself, the only channel with a full-screen intent. */
|
||||
const val CHANNEL_FIRING = "alarm_firing"
|
||||
|
||||
fun ensureChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
@@ -199,6 +202,22 @@ object AlarmNotifier {
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
|
||||
}
|
||||
nm.createNotificationChannel(service)
|
||||
|
||||
// The ringing alarm. High importance so SystemUI launches the
|
||||
// full-screen intent; silent because the siren is our own audio stream
|
||||
// (a channel sound would double it).
|
||||
val firing = NotificationChannel(
|
||||
CHANNEL_FIRING,
|
||||
"Alarm ringing",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Shows the alarm screen when an alert you follow is triggered"
|
||||
setShowBadge(true)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
nm.createNotificationChannel(firing)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +253,61 @@ object AlarmNotifier {
|
||||
nm.cancel(NOTIFICATION_ID_VOLUME)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ringing alarm's notification, carrying a **full-screen intent** for the
|
||||
* takeover screen.
|
||||
*
|
||||
* This is the platform-sanctioned way to raise an activity from the
|
||||
* background, and it is not optional: starting the activity from the alarm's
|
||||
* own PendingIntent is refused on Android 14+ (`Background activity launch
|
||||
* blocked! … BAL_BLOCK`, measured on an Android 15 emulator — the alarm rang
|
||||
* with no screen). A notification full-screen intent is launched by
|
||||
* SystemUI, which is exempt from that rule.
|
||||
*
|
||||
* It is also why the FSI grant matters: without `canUseFullScreenIntent()`
|
||||
* the system shows a heads-up instead of taking over the screen, so the
|
||||
* caller records that the device may only get the siren.
|
||||
*/
|
||||
fun showFiring(context: Context, alertId: String, message: String) {
|
||||
ensureChannels(context)
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val open = android.app.PendingIntent.getActivity(
|
||||
context,
|
||||
NOTIFICATION_ID_FIRING,
|
||||
AlarmActivity.intent(context, alertId, message),
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val builder = android.app.Notification.Builder(context, CHANNEL_FIRING)
|
||||
.setContentTitle("Alarm")
|
||||
.setContentText(message.ifBlank { "An alert you follow was triggered" })
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setCategory(android.app.Notification.CATEGORY_ALARM)
|
||||
.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(open)
|
||||
.setFullScreenIntent(open, true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
if (!nm.canUseFullScreenIntent()) {
|
||||
Log.w(
|
||||
"AlarmNotifier",
|
||||
"full-screen intent not granted: the takeover will show as a heads-up only"
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
nm.notify(NOTIFICATION_ID_FIRING, builder.build())
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFiring(context: Context) {
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
nm.cancel(NOTIFICATION_ID_FIRING)
|
||||
}
|
||||
|
||||
/** Removes the persistent "Alarm armed" notification. */
|
||||
fun cancelArmed(context: Context) {
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
@@ -242,4 +316,5 @@ object AlarmNotifier {
|
||||
|
||||
const val NOTIFICATION_ID_VOLUME = 4711
|
||||
const val NOTIFICATION_ID_SERVICE = 4712
|
||||
const val NOTIFICATION_ID_FIRING = 4713
|
||||
}
|
||||
|
||||
@@ -70,7 +70,12 @@ object AlarmScheduler {
|
||||
private const val REQ_SHOW = 4203
|
||||
|
||||
const val ACTION_STANDING = "com.dcntrlized.alarm.STANDING"
|
||||
|
||||
/** The entry that ends the ring: the alarm clock's own operation. */
|
||||
const val ACTION_FIRE = "com.dcntrlized.alarm.FIRE"
|
||||
|
||||
const val EXTRA_ALERT_ID = "alarm.scheduler.alertId"
|
||||
const val EXTRA_MESSAGE = "alarm.scheduler.message"
|
||||
|
||||
/**
|
||||
* `USE_EXACT_ALARM` (declared in the manifest) makes this true without user
|
||||
@@ -95,10 +100,28 @@ object AlarmScheduler {
|
||||
}
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||
return try {
|
||||
val operation = PendingIntent.getActivity(
|
||||
// The operation is a broadcast, NEVER an activity launch.
|
||||
//
|
||||
// This used to be `PendingIntent.getActivity(AlarmActivity)`, and on
|
||||
// Android 14+ that launch is refused from the background
|
||||
// (`BAL_BLOCK`). Worse than being dropped, it is *deferred*: the
|
||||
// activity sits in the task and surfaces later — measured after an
|
||||
// acknowledgement, where pressing back let the queued takeover come
|
||||
// to the front again, re-playing the siren for an alert that had
|
||||
// already been delivered (and therefore rendering a title-less alarm
|
||||
// that fell back to the raw alert id).
|
||||
//
|
||||
// The screen is raised by the full-screen-intent notification, which
|
||||
// SystemUI launches; the alarm clock only has to make the platform
|
||||
// treat this as an alarm and wake the process at the right time.
|
||||
val operation = PendingIntent.getBroadcast(
|
||||
context,
|
||||
REQ_FIRE,
|
||||
AlarmActivity.intent(context, alertId, message),
|
||||
Intent(context, AlarmWakeReceiver::class.java).apply {
|
||||
action = ACTION_FIRE
|
||||
putExtra(EXTRA_ALERT_ID, alertId)
|
||||
putExtra(EXTRA_MESSAGE, message)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
am.setAlarmClock(
|
||||
@@ -176,10 +199,10 @@ object AlarmScheduler {
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||
try {
|
||||
am.cancel(
|
||||
PendingIntent.getActivity(
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
REQ_FIRE,
|
||||
AlarmActivity.intent(context, "", ""),
|
||||
Intent(context, AlarmWakeReceiver::class.java).apply { action = ACTION_FIRE },
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,6 +33,29 @@ class AlarmWakeReceiver : BroadcastReceiver() {
|
||||
Log.i(TAG, "boot: standing entry re-armed=$ok for ${armed.alertId}")
|
||||
}
|
||||
|
||||
AlarmScheduler.ACTION_FIRE -> {
|
||||
// The ring's own alarm-clock entry. It must NOT start an
|
||||
// activity (that is what the full-screen-intent notification is
|
||||
// for, and a background activity launch is refused and then
|
||||
// deferred by the platform, which is how a dismissed alarm came
|
||||
// back). All this has to do is make sure the process is awake
|
||||
// and the takeover is on screen.
|
||||
val alertId = intent.getStringExtra(AlarmScheduler.EXTRA_ALERT_ID)
|
||||
val message = intent.getStringExtra(AlarmScheduler.EXTRA_MESSAGE) ?: ""
|
||||
val armed = AlarmArmStore.current(context)
|
||||
if (alertId.isNullOrEmpty() || armed?.alertId != alertId) {
|
||||
// Dismissed, disarmed or replaced between scheduling and
|
||||
// firing: there is nothing to ring.
|
||||
Log.i(TAG, "fire ignored: not armed for $alertId")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "fire for $alertId")
|
||||
AlarmNotifier.showFiring(context, alertId, message)
|
||||
if (!AlarmForegroundService.isRunning) {
|
||||
AlarmForegroundService.start(context, armed.alertId, armed.wsUrl)
|
||||
}
|
||||
}
|
||||
|
||||
AlarmScheduler.ACTION_STANDING -> {
|
||||
val armed = AlarmArmStore.current(context) ?: return
|
||||
// Chain the next wake before doing anything that could fail.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-vision-camera": "5.2.3",
|
||||
"react-native-vision-camera-barcode-scanner": "^5.2.3",
|
||||
"react-native-worklets": "0.10.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
@@ -1442,6 +1443,8 @@
|
||||
|
||||
"react-native-vision-camera": ["[email protected]", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-image": "*", "react-native-nitro-modules": "*" }, "optionalPeers": ["react-native-nitro-modules"] }, "sha512-OHjgM8BPzxppTqbF1FEltj+fxvisj4xdghM7ypoT1p6cOe28HByhOJP1c7LkBiUiZuotbsdbQwGdJ8nU7ZvPzA=="],
|
||||
|
||||
"react-native-vision-camera-barcode-scanner": ["[email protected]", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-image": "*", "react-native-nitro-modules": "*", "react-native-vision-camera": "*" }, "optionalPeers": ["react-native-nitro-modules"] }, "sha512-p8SyswRlr9Ty5gFcQ1qM8gmU8hKbHyRLyu9TKFMD7oUXsZDF93NKGvcr4Qdr16Z+AIj3HYA/fW1NgoeY+L353g=="],
|
||||
|
||||
"react-native-worklets": ["[email protected]", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@babel/types": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.83 - 0.86" } }, "sha512-JhE6IxDf6iabC0qu3+TAKA4v9RlluXmoIngPQX7/QUByf75lfrsHZ6/dQhyjEWnp1EEQiwzz8Cpew140ZcewDw=="],
|
||||
|
||||
"react-refresh": ["[email protected]", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-vision-camera": "5.2.3",
|
||||
"react-native-vision-camera-barcode-scanner": "5.2.3",
|
||||
"react-native-worklets": "0.10.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
bg,
|
||||
border,
|
||||
danger,
|
||||
dangerDeep,
|
||||
@@ -536,7 +537,7 @@ export const styles = StyleSheet.create({
|
||||
paddingVertical: 48,
|
||||
},
|
||||
emptyAction: { marginTop: space6, alignSelf: 'stretch' },
|
||||
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48 },
|
||||
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, backgroundColor: bg },
|
||||
errorBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(248,113,113,0.3)',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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;
|
||||
* - a dismissal is **not** undone by the transport. It used to be: a live
|
||||
* socket frame cleared the memory on the theory that a live frame must be a
|
||||
* new trigger. That is wrong on this service — an alert is triggered once
|
||||
* and then disarmed or delivered, so a live frame for a dismissed alert is
|
||||
* the same alarm arriving down another pipe (measured: after dismissing, the
|
||||
* deferred native takeover replayed a frame and the siren came back). The
|
||||
* only thing that clears the memory is the alert ending (`forget`).
|
||||
*
|
||||
* 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?
|
||||
*
|
||||
* Suppressed when this device dismissed the alert — whether the raise came
|
||||
* from a status report or the socket (`live` only changes where the frame
|
||||
* came from, never whether the user already dismissed it).
|
||||
*/
|
||||
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);
|
||||
};
|
||||
|
||||
// `live` is accepted for call-site symmetry and deliberately ignored: the
|
||||
// transport a frame arrived on says nothing about whether a human dismissed
|
||||
// the alarm (see the header comment).
|
||||
const suppress = (alertId: string, eventId: string | undefined, _live: boolean) => {
|
||||
const set = seen.get(alertId);
|
||||
if (!set) return false;
|
||||
if (!eventId) return true;
|
||||
if (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) ?? [])],
|
||||
};
|
||||
}
|
||||
@@ -103,6 +103,10 @@ const ENDING_COPY: Record<EndingReason, { title: string; body: string }> = {
|
||||
title: 'This alert was deleted',
|
||||
body: 'The alert and its tokens are gone.',
|
||||
},
|
||||
left: {
|
||||
title: 'You left this alert',
|
||||
body: 'This device acknowledged the alarm and unsubscribed, so it will not ring here again. The alert may still be live for everyone else.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AlertDetailScreen() {
|
||||
@@ -255,7 +259,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
||||
setNotice('The service URL is not known yet — re-open the alert in a moment.');
|
||||
return;
|
||||
}
|
||||
const wsUrl = alarmSocketUrl(resolved, alertId, token);
|
||||
const wsUrl = alarmSocketUrl(resolved, alertId, token, alert?.deviceToken);
|
||||
if (!wsUrl) {
|
||||
setNotice(
|
||||
'No instance address is configured, so background listening is unavailable.',
|
||||
@@ -269,7 +273,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
||||
? 'Listening again — this device is armed.'
|
||||
: `Could not start listening (${res.reason ?? 'unknown'}).`,
|
||||
);
|
||||
}, [alertId, getAlert, refreshListening, resolved]);
|
||||
}, [alert?.deviceToken, alertId, getAlert, refreshListening, resolved]);
|
||||
|
||||
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
||||
isAdmin ? alertId : null,
|
||||
@@ -279,7 +283,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
||||
// Read the authoritative state on mount; the local copy is only a cache.
|
||||
const loadServerAlert = useCallback(async () => {
|
||||
try {
|
||||
const fresh = await api.getAlert(alertId);
|
||||
const fresh = await api.getAlert(alertId, alert?.deviceToken);
|
||||
setServerAlert(fresh);
|
||||
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
||||
} catch (e) {
|
||||
@@ -296,7 +300,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
||||
}
|
||||
// Offline is fine — the cached copy renders.
|
||||
}
|
||||
}, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]);
|
||||
}, [
|
||||
alert?.deviceToken,
|
||||
alert?.expiresAt,
|
||||
alertId,
|
||||
markEnded,
|
||||
serverAlert?.expiresAt,
|
||||
updateAlertLocal,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveAlertId(isAdmin ? null : alertId);
|
||||
|
||||
@@ -64,11 +64,12 @@ const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>
|
||||
expired: 'Expired — its lifetime ran out.',
|
||||
disarmed: 'Ended — you disarmed it.',
|
||||
deleted: 'Deleted.',
|
||||
left: 'Acknowledged — this device unsubscribed.',
|
||||
};
|
||||
|
||||
export default function HomeScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { username, logout } = useAuth();
|
||||
const { username, anonymous, logout } = useAuth();
|
||||
const { online } = useConnection();
|
||||
const {
|
||||
adminAlerts,
|
||||
@@ -79,11 +80,14 @@ export default function HomeScreen() {
|
||||
liveEvent,
|
||||
delivery,
|
||||
socketState,
|
||||
backgroundListening,
|
||||
lastEnding,
|
||||
clearLastEnding,
|
||||
} = useAlarm();
|
||||
|
||||
const [tab, setTab] = useState<Tab>('admin');
|
||||
// rev 10: a device identity has no alerts of its own; it starts (and stays)
|
||||
// on the subscriptions tab.
|
||||
const [tab, setTab] = useState<Tab>(anonymous ? 'subscribed' : 'admin');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
@@ -102,7 +106,11 @@ export default function HomeScreen() {
|
||||
<View style={{ flex: 1 }}>
|
||||
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||
{username ? `Signed in as ${username}` : 'Max-volume alerts for your devices'}
|
||||
{anonymous
|
||||
? 'Subscribed alerts on this device — no account needed'
|
||||
: username
|
||||
? `Signed in as ${username}`
|
||||
: 'Max-volume alerts for your devices'}
|
||||
</AppText>
|
||||
</View>
|
||||
<Pressable
|
||||
@@ -131,6 +139,30 @@ export default function HomeScreen() {
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{anonymous ? (
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in to create your own alerts"
|
||||
>
|
||||
<Card>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: space2 }}>
|
||||
<ShieldCheck size={20} color={textMuted} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<AppText style={{ ...typeBodyStrong, color: text }}>
|
||||
Sign in to create your own alerts
|
||||
</AppText>
|
||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||
Receiving alarms needs no account. Creating one does.
|
||||
</AppText>
|
||||
</View>
|
||||
<ChevronRight size={18} color={textMuted} />
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{anonymous ? null : (
|
||||
<View style={styles.tabs}>
|
||||
<TabButton
|
||||
label="My alerts"
|
||||
@@ -147,6 +179,7 @@ export default function HomeScreen() {
|
||||
onPress={() => setTab('subscribed')}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
@@ -215,12 +248,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>
|
||||
@@ -241,6 +279,9 @@ export default function HomeScreen() {
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('Scanner')}
|
||||
/>
|
||||
{/* A subscriber has no session to end: the device token lives with the
|
||||
subscription, so there is nothing to sign out of (rev 10). */}
|
||||
{anonymous ? null : (
|
||||
<Button
|
||||
label="Sign out"
|
||||
variant="ghost"
|
||||
@@ -252,6 +293,7 @@ export default function HomeScreen() {
|
||||
])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -10,17 +10,24 @@ 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';
|
||||
import * as api from '../services/alarmApi';
|
||||
import { openSoundSettings, playAlarm, stopAlarm, isAlarmNativeAvailable } from '../services/alarmNative';
|
||||
import {
|
||||
finishTakeover,
|
||||
isAlarmNativeAvailable,
|
||||
openSoundSettings,
|
||||
playAlarm,
|
||||
stopAlarm,
|
||||
} from '../services/alarmNative';
|
||||
import { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import {
|
||||
motionSiren,
|
||||
radiusFull,
|
||||
space1,
|
||||
space2,
|
||||
space3,
|
||||
space4,
|
||||
@@ -61,8 +68,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;
|
||||
@@ -146,10 +161,23 @@ export default function LiveAlarmScreen() {
|
||||
};
|
||||
}, [fade, shake, reduceMotion]);
|
||||
|
||||
/**
|
||||
* Is there an alarm to sound at all?
|
||||
*
|
||||
* A route can be opened for an alert that no longer exists: the native
|
||||
* launch carries only an id, and on the way out of an alarm the platform can
|
||||
* still hand the window a stale intent. Ringing for it is the "siren comes
|
||||
* back after dismissing, showing the alert id" failure, so playback waits for
|
||||
* evidence that the alarm is live — either the alert this device follows, or
|
||||
* a trigger event that actually arrived.
|
||||
*/
|
||||
const live = !!alert || event?.action === 'trigger';
|
||||
|
||||
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
|
||||
// refuses the alarm stream, the only thing that can still reach the user is
|
||||
// the volume warning — the screen says so instead of pretending.
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const res = await playAlarm(alertId, message ?? '');
|
||||
@@ -161,12 +189,16 @@ export default function LiveAlarmScreen() {
|
||||
cancelled = true;
|
||||
stopAlarm();
|
||||
};
|
||||
}, [alertId, message]);
|
||||
}, [alertId, message, live]);
|
||||
|
||||
const leave = useCallback(() => {
|
||||
clearLiveEvent();
|
||||
if (navigation.canGoBack()) navigation.goBack();
|
||||
else navigation.navigate('Home');
|
||||
// The route pop is not enough: when the alarm was raised as its own window
|
||||
// (the background case), that window has to close or the user lands back in
|
||||
// a full-screen alarm activity that never lets go of the screen.
|
||||
void finishTakeover();
|
||||
}, [clearLiveEvent, navigation]);
|
||||
|
||||
/**
|
||||
@@ -181,11 +213,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 +255,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
|
||||
@@ -236,7 +290,7 @@ export default function LiveAlarmScreen() {
|
||||
{/* The alert name is the headline: at the moment of maximum stress this
|
||||
is the one piece of information the user needs (D-5). */}
|
||||
<AppText style={styles.name} numberOfLines={2}>
|
||||
{alert?.name ?? alertId}
|
||||
{alert?.name ?? 'Alarm'}
|
||||
</AppText>
|
||||
<AppText style={styles.owner}>{attribution}</AppText>
|
||||
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
|
||||
@@ -311,6 +365,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 +458,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' },
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Screen,
|
||||
TextInputBox,
|
||||
} from '../components/ui';
|
||||
import { space4, text, textMuted, textSecondary, typeCaption, typeLabel, typeTitle } from '../theme/tokens';
|
||||
import { space4, text, textMuted, textSecondary, typeCaption, typeLabel, typeTitle, bg } from '../theme/tokens';
|
||||
|
||||
type Mode = 'signin' | 'register';
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function LoginScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flex: 1, backgroundColor: bg }}>
|
||||
{!online ? <OfflineStrip /> : null}
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
@@ -72,14 +72,24 @@ export default function LoginScreen() {
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<Field label="Username" accessibilityLabel="Username">
|
||||
{/* Sign-in identifies the account by EMAIL: the auth hub's identity
|
||||
is the email column, and `login()` sends this value as `email`.
|
||||
Labelling it "Username" made the seeded accounts unusable —
|
||||
typing the username from the seed file fails with "invalid
|
||||
credentials" even though the password is right. Registration is
|
||||
the only flow that has both a username and an email. */}
|
||||
<Field
|
||||
label={isRegister ? 'Username' : 'Email'}
|
||||
accessibilityLabel={isRegister ? 'Username' : 'Email'}
|
||||
>
|
||||
<TextInputBox
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel="Username"
|
||||
placeholder="your username"
|
||||
keyboardType={isRegister ? 'default' : 'email-address'}
|
||||
accessibilityLabel={isRegister ? 'Username' : 'Email'}
|
||||
placeholder={isRegister ? 'your username' : '[email protected]'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
Camera,
|
||||
useCameraDevice,
|
||||
useCameraPermission,
|
||||
useObjectOutput,
|
||||
} from 'react-native-vision-camera';
|
||||
import { useBarcodeScannerOutput } from 'react-native-vision-camera-barcode-scanner';
|
||||
import { ScanLine, X } from 'lucide-react-native';
|
||||
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
|
||||
import { useAlarm } from '../state/AlarmContext';
|
||||
@@ -158,32 +158,42 @@ export default function ScannerScreen() {
|
||||
[busy, joinAlert, navigation, perms],
|
||||
);
|
||||
|
||||
const onScanned = useCallback(
|
||||
(objects: readonly { type: string; value?: string }[]) => {
|
||||
for (const obj of objects) {
|
||||
const value = typeof obj.value === 'string' ? obj.value : undefined;
|
||||
if (obj.type !== 'qr' || !value) continue;
|
||||
// Both platforms scan through MLKit here. The core library's
|
||||
// `useObjectOutput` is Apple-Vision only: on Android its native factory
|
||||
// throws `CameraObjectOutput is not available on Android!` the moment the
|
||||
// screen mounts, which crashed the app on "Scan a QR code". The Barcode
|
||||
// Scanner output is cross-platform (MLKit on Android, Vision on iOS).
|
||||
const handleScanValue = useCallback(
|
||||
(value: string) => {
|
||||
const parsed = parseQrPayload(value);
|
||||
if (parsed.ok) {
|
||||
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
|
||||
setError('That code is for a different alert.');
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
|
||||
} else {
|
||||
setError(qrErrorMessage(parsed.error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[complete, targetAlertId],
|
||||
);
|
||||
|
||||
// v5 ships the scanner in core: `useObjectOutput` is the code-scanner — there
|
||||
// is no separate vision-camera-code-scanner plugin for 5.x.
|
||||
const objectOutput = useObjectOutput({
|
||||
types: ['qr'],
|
||||
onObjectsScanned: onScanned,
|
||||
const barcodeOutput = useBarcodeScannerOutput({
|
||||
barcodeFormats: ['qr-code'],
|
||||
onBarcodeScanned(barcodes) {
|
||||
for (const barcode of barcodes) {
|
||||
const value = barcode.rawValue;
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
handleScanValue(value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setError('The camera could not start scanning. You can paste the code by hand.');
|
||||
console.log('barcode scanner error', error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const submitManual = async () => {
|
||||
@@ -236,7 +246,7 @@ export default function ScannerScreen() {
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={!joinedName}
|
||||
outputs={[objectOutput]}
|
||||
outputs={[barcodeOutput]}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.permission}>
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -132,6 +139,45 @@ export default function SettingsScreen() {
|
||||
{!online ? <OfflineStrip /> : null}
|
||||
<Header title="Settings & health" onBack={() => navigation.goBack()} />
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{/* ── Connection ───────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>Connection</AppText>
|
||||
<Row
|
||||
icon={Server}
|
||||
tint={online ? stateDisarmed : warn}
|
||||
title={online ? 'Instance reachable' : 'No instance answered'}
|
||||
detail={resolved?.baseUrl ? resolved.baseUrl : 'No instance address set yet.'}
|
||||
/>
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
Your instance exposes one address; every service is a path under it. The client
|
||||
only ever talks to this origin — Maestro routes the alarm service.
|
||||
</AppText>
|
||||
<TextInput
|
||||
value={instanceDraft}
|
||||
onChangeText={setInstanceDraft}
|
||||
onEndEditing={saveInstance}
|
||||
onSubmitEditing={saveInstance}
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
placeholderTextColor={textFaint}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
accessibilityLabel="Instance address"
|
||||
style={styles.instanceInput}
|
||||
/>
|
||||
<Button label="Save instance address" variant="secondary" onPress={saveInstance} />
|
||||
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
|
||||
<Button label={probing ? 'Checking…' : 'Check connection'} variant="secondary" onPress={reprobe} />
|
||||
{!isRelease ? (
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
Dev build · default instance {DEFAULT_SETTINGS.instanceUrl} ·
|
||||
`adb reverse tcp:8080 tcp:8080` on a USB phone
|
||||
</AppText>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* ── Delivery ─────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
@@ -273,45 +319,6 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* ── Connection ───────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>Connection</AppText>
|
||||
<Row
|
||||
icon={Server}
|
||||
tint={online ? stateDisarmed : warn}
|
||||
title={online ? 'Instance reachable' : 'No instance answered'}
|
||||
detail={resolved?.baseUrl ? resolved.baseUrl : 'No instance address set yet.'}
|
||||
/>
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
Your instance exposes one address; every service is a path under it. The client
|
||||
only ever talks to this origin — Maestro routes the alarm service.
|
||||
</AppText>
|
||||
<TextInput
|
||||
value={instanceDraft}
|
||||
onChangeText={setInstanceDraft}
|
||||
onEndEditing={saveInstance}
|
||||
onSubmitEditing={saveInstance}
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
placeholderTextColor={textFaint}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
accessibilityLabel="Instance address"
|
||||
style={styles.instanceInput}
|
||||
/>
|
||||
<Button label="Save instance address" variant="secondary" onPress={saveInstance} />
|
||||
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
|
||||
<Button label={probing ? 'Checking…' : 'Check connection'} variant="secondary" onPress={reprobe} />
|
||||
{!isRelease ? (
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
Dev build · default instance {DEFAULT_SETTINGS.instanceUrl} ·
|
||||
`adb reverse tcp:8080 tcp:8080` on a USB phone
|
||||
</AppText>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* ── Account ──────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
@@ -319,10 +326,14 @@ export default function SettingsScreen() {
|
||||
<Row
|
||||
icon={Info}
|
||||
tint={textMuted}
|
||||
title={username ?? 'Not signed in'}
|
||||
detail={email || 'Signed in on this instance'}
|
||||
title={username ?? 'No account on this device'}
|
||||
detail={
|
||||
username
|
||||
? email || 'Signed in on this instance'
|
||||
: 'Subscribed alerts work without one. Sign in only to create alerts.'
|
||||
}
|
||||
/>
|
||||
<Button label="Sign out" variant="secondary" onPress={logout} />
|
||||
{username ? <Button label="Sign out" variant="secondary" onPress={logout} /> : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
|
||||
+62
-19
@@ -104,6 +104,12 @@ export interface Alert {
|
||||
role?: 'admin' | 'subscriber';
|
||||
/** Client-side only: subscription id for the subscriber role. */
|
||||
subscriptionId?: string;
|
||||
/**
|
||||
* Client-side only: the device credential minted for this alert at join
|
||||
* (rev 10). It never appears in an alert response — it is the subscriber's
|
||||
* identity, so it only ever lives in this client's storage.
|
||||
*/
|
||||
deviceToken?: string;
|
||||
deviceLabel?: string;
|
||||
lastTriggered?: string;
|
||||
subscriberCount?: number;
|
||||
@@ -165,8 +171,12 @@ export function createAlert(payload: {
|
||||
return post<Alert>('/action/createAlert', payload);
|
||||
}
|
||||
|
||||
export function getAlert(alertId: string): Promise<Alert> {
|
||||
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`);
|
||||
/**
|
||||
* Read an alert. A subscriber passes the device token minted at join (rev 10);
|
||||
* an account passes nothing and the client attaches its access token.
|
||||
*/
|
||||
export function getAlert(alertId: string, deviceToken?: string | null): Promise<Alert> {
|
||||
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`, deviceToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,14 +184,20 @@ export function getAlert(alertId: string): Promise<Alert> {
|
||||
* revocation handle; there is no `subscriptionId` on the wire and no `alert`
|
||||
* object (the join screen already holds the QR's alert data).
|
||||
*/
|
||||
/**
|
||||
* Join an alert. This is the UNAUTHENTICATED path (rev 10): no access token is
|
||||
* involved — the shared subscribe token proves the caller was shown the QR, and
|
||||
* the service answers with a device token that becomes this device's credential
|
||||
* for that alert. There is no account and no synthetic user anywhere.
|
||||
*/
|
||||
export function joinAlert(payload: {
|
||||
alertId: string;
|
||||
subscribeToken: string;
|
||||
deviceId: string;
|
||||
deviceLabel?: string;
|
||||
platform?: string;
|
||||
}): Promise<Subscription> {
|
||||
return post<Subscription>('/action/joinAlert', payload);
|
||||
}): Promise<Subscription & { deviceToken?: string }> {
|
||||
return post<Subscription & { deviceToken?: string }>('/action/joinAlert', payload);
|
||||
}
|
||||
|
||||
export async function listSubscriptions(alertId: string): Promise<Subscription[]> {
|
||||
@@ -194,11 +210,11 @@ export async function listSubscriptions(alertId: string): Promise<Subscription[]
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
export function removeSubscription(payload: {
|
||||
alertId: string;
|
||||
subscriptionId: string;
|
||||
}): Promise<unknown> {
|
||||
return post<unknown>('/action/removeSubscription', payload);
|
||||
export function removeSubscription(
|
||||
payload: { alertId: string; subscriptionId: string },
|
||||
deviceToken?: string | null,
|
||||
): Promise<unknown> {
|
||||
return post<unknown>('/action/removeSubscription', payload, false, deviceToken);
|
||||
}
|
||||
|
||||
export function triggerAlert(payload: {
|
||||
@@ -231,12 +247,16 @@ export function disarmAlert(payload: {
|
||||
* upgrade covers it; this check-in is what covers a socket that was down or an
|
||||
* app reopened cold, where no replay could reach the native path.
|
||||
*/
|
||||
export async function alarmStatus(alertId: string, deviceId?: string): Promise<AlarmStatus> {
|
||||
export async function alarmStatus(
|
||||
alertId: string,
|
||||
deviceId?: string,
|
||||
deviceToken?: string | null,
|
||||
): Promise<AlarmStatus> {
|
||||
const query = `alertId=${encodeURIComponent(alertId)}${
|
||||
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
|
||||
}`;
|
||||
try {
|
||||
return await get<AlarmStatus>(`/action/alarmStatus?${query}`);
|
||||
return await get<AlarmStatus>(`/action/alarmStatus?${query}`, deviceToken);
|
||||
} catch (e) {
|
||||
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
|
||||
const frame = offlineFrame(e.body);
|
||||
@@ -260,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.
|
||||
*
|
||||
@@ -270,13 +302,19 @@ export function isAlertGone(error: unknown): boolean {
|
||||
*/
|
||||
export function ackAlarm(input: {
|
||||
alertId: string;
|
||||
eventId: string;
|
||||
deviceId: 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. */
|
||||
deviceToken?: string | null;
|
||||
}): Promise<unknown> {
|
||||
return post<unknown>(
|
||||
`/action/ackAlarm?deviceId=${encodeURIComponent(input.deviceId)}`,
|
||||
input,
|
||||
);
|
||||
const query = input.deviceId ? `?deviceId=${encodeURIComponent(input.deviceId)}` : '';
|
||||
return post<unknown>(`/action/ackAlarm${query}`, input, false, input.deviceToken);
|
||||
}
|
||||
|
||||
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
|
||||
@@ -284,8 +322,13 @@ export function deleteAlert(payload: { alertId: string; adminToken: string }): P
|
||||
}
|
||||
|
||||
/** Optional convenience: the service renders the PNG; resolved as base64. */
|
||||
export function qrImage(alertId: string): Promise<string> {
|
||||
return requestBase64('GET', `/action/qrImage?alertId=${encodeURIComponent(alertId)}`);
|
||||
export function qrImage(alertId: string, deviceToken?: string | null): Promise<string> {
|
||||
return requestBase64(
|
||||
'GET',
|
||||
`/action/qrImage?alertId=${encodeURIComponent(alertId)}`,
|
||||
undefined,
|
||||
deviceToken,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface AlarmServiceState {
|
||||
interface AlarmNativeModule {
|
||||
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
||||
stopAlarm(): Promise<AlarmNativeResult>;
|
||||
finishTakeover(): Promise<AlarmNativeResult>;
|
||||
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
||||
stopService(): Promise<AlarmNativeResult>;
|
||||
getPermissions(): Promise<AlarmPermissions>;
|
||||
@@ -101,6 +102,25 @@ export async function stopAlarm(): Promise<AlarmNativeResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the full-screen alarm window, if this JS tree happens to be running in
|
||||
* it.
|
||||
*
|
||||
* Popping a route leaves the Android activity behind: it keeps its
|
||||
* show-when-locked / keep-screen-on flags, sits on top of the app the user
|
||||
* returns to, and lives in a task that is excluded from Recents. So "Back to
|
||||
* alerts" has to close the window as well as the route. A no-op when the alarm
|
||||
* was shown as a route inside the main activity (`ok: false`).
|
||||
*/
|
||||
export async function finishTakeover(): Promise<AlarmNativeResult> {
|
||||
if (!native) return UNAVAILABLE;
|
||||
try {
|
||||
return await native.finishTakeover();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: e instanceof Error ? e.message : 'finish-failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the socket alive while armed: a foreground service with a persistent
|
||||
* notification, so the trigger still lands when the app is backgrounded.
|
||||
|
||||
@@ -124,6 +124,7 @@ async function perform<T>(
|
||||
payload: unknown,
|
||||
raw: boolean,
|
||||
bodyMode: BodyMode,
|
||||
deviceToken?: string | null,
|
||||
): Promise<T> {
|
||||
if (!config.baseUrl) {
|
||||
throw new ApiError(null, 'Server not configured yet');
|
||||
@@ -141,7 +142,11 @@ async function perform<T>(
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.token) {
|
||||
// A device token is a narrower credential than an account token (contract
|
||||
// rev 10): when a call carries one, that is the identity the service sees.
|
||||
if (deviceToken) {
|
||||
headers['X-Device-Token'] = deviceToken;
|
||||
} else if (config.token) {
|
||||
headers['X-Access-Token'] = config.token;
|
||||
}
|
||||
|
||||
@@ -249,8 +254,9 @@ export async function request<T>(
|
||||
path: string,
|
||||
payload?: unknown,
|
||||
raw = false,
|
||||
deviceToken?: string | null,
|
||||
): Promise<T> {
|
||||
return perform<T>(method, path, payload, raw, 'json');
|
||||
return perform<T>(method, path, payload, raw, 'json', deviceToken);
|
||||
}
|
||||
|
||||
/** Text response variant for endpoints that are not JSON. */
|
||||
@@ -266,22 +272,32 @@ export async function requestBase64(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
payload?: unknown,
|
||||
deviceToken?: string | null,
|
||||
): Promise<string> {
|
||||
return perform<string>(method, path, payload, false, 'base64');
|
||||
return perform<string>(method, path, payload, false, 'base64', deviceToken);
|
||||
}
|
||||
|
||||
export async function get<T>(path: string): Promise<T> {
|
||||
return request<T>('GET', path);
|
||||
export async function get<T>(path: string, deviceToken?: string | null): Promise<T> {
|
||||
return request<T>('GET', path, undefined, false, deviceToken);
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, payload?: unknown, raw?: boolean): Promise<T> {
|
||||
return request<T>('POST', path, payload, raw);
|
||||
export async function post<T>(
|
||||
path: string,
|
||||
payload?: unknown,
|
||||
raw?: boolean,
|
||||
deviceToken?: string | null,
|
||||
): Promise<T> {
|
||||
return request<T>('POST', path, payload, raw, deviceToken);
|
||||
}
|
||||
|
||||
export async function put<T>(path: string, payload?: unknown): Promise<T> {
|
||||
return request<T>('PUT', path, payload);
|
||||
}
|
||||
|
||||
export async function del<T = void>(path: string, payload?: unknown): Promise<T> {
|
||||
return request<T>('DELETE', path, payload);
|
||||
export async function del<T = void>(
|
||||
path: string,
|
||||
payload?: unknown,
|
||||
deviceToken?: string | null,
|
||||
): Promise<T> {
|
||||
return request<T>('DELETE', path, payload, false, deviceToken);
|
||||
}
|
||||
|
||||
@@ -47,9 +47,12 @@ export function alarmSocketUrl(
|
||||
resolved: { baseUrl: string },
|
||||
alertId: string,
|
||||
subscribeToken: string,
|
||||
deviceToken?: string | null,
|
||||
): string | null {
|
||||
if (!resolved.baseUrl) return null;
|
||||
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
||||
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
|
||||
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}${
|
||||
deviceToken ? `&deviceToken=${encodeURIComponent(deviceToken)}` : ''
|
||||
}`;
|
||||
return `${ws}/api/${SERVICE_NAME}/ws/alarm?${q}`;
|
||||
}
|
||||
|
||||
+36
-13
@@ -13,21 +13,35 @@
|
||||
* 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;
|
||||
deviceId: 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;
|
||||
/**
|
||||
* The caller's device id. Absent when the dismissal happened before the
|
||||
* identity was loaded — the service accepts a device-token ack without it
|
||||
* (measured), and dropping the entry instead is how a dismissed alarm came
|
||||
* back after the app was closed.
|
||||
*/
|
||||
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[] {
|
||||
@@ -40,8 +54,12 @@ function parse(raw: string | null): PendingAck[] {
|
||||
!!v &&
|
||||
typeof v === 'object' &&
|
||||
typeof (v as PendingAck).alertId === 'string' &&
|
||||
typeof (v as PendingAck).eventId === 'string' &&
|
||||
typeof (v as PendingAck).deviceId === 'string',
|
||||
((v as PendingAck).eventId === undefined ||
|
||||
typeof (v as PendingAck).eventId === 'string') &&
|
||||
((v as PendingAck).deviceId === undefined ||
|
||||
typeof (v as PendingAck).deviceId === 'string') &&
|
||||
((v as PendingAck).deviceToken === undefined ||
|
||||
typeof (v as PendingAck).deviceToken === 'string'),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -67,8 +85,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);
|
||||
}
|
||||
|
||||
@@ -76,13 +95,17 @@ export async function dropAck(alertId: string, eventId: string): Promise<void> {
|
||||
* Send every queued ack. Stops on the first transport/server failure (the queue
|
||||
* is retried later); drops entries on success and on 404 ("settled").
|
||||
*/
|
||||
export async function flushAcks(): Promise<void> {
|
||||
export async function flushAcks(fallbackDeviceId?: string): Promise<void> {
|
||||
const list = await loadAcks();
|
||||
for (const ack of list) {
|
||||
try {
|
||||
await ackAlarm(ack);
|
||||
await ackAlarm({ ...ack, deviceId: ack.deviceId ?? fallbackDeviceId });
|
||||
} 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);
|
||||
}
|
||||
|
||||
+212
-15
@@ -7,16 +7,32 @@ 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';
|
||||
|
||||
/** Why an alert no longer exists. Each ending has its own explicit copy. */
|
||||
export type EndingReason = 'delivered' | 'expired' | 'disarmed' | 'deleted';
|
||||
export type EndingReason =
|
||||
| 'delivered'
|
||||
| 'expired'
|
||||
| 'disarmed'
|
||||
| 'deleted'
|
||||
/** This device acknowledged and left the alert (a dismissal *is* leaving). */
|
||||
| 'left';
|
||||
|
||||
export interface LiveAlarmEvent {
|
||||
alertId: string;
|
||||
@@ -48,6 +64,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 +132,41 @@ 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());
|
||||
/**
|
||||
* Whether the durable queue has been read into [dismissals].
|
||||
*
|
||||
* A raise must wait for it: a dismissal that is still queued (the app was
|
||||
* killed before the ack reached the service) is the only record that this
|
||||
* device already dismissed, and raising before the seed lands rings the alarm
|
||||
* the user just dismissed — measured as "reopening the app retriggers it".
|
||||
*/
|
||||
const [dismissalsReady, setDismissalsReady] = useState(false);
|
||||
|
||||
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(() => {})
|
||||
.finally(() => setDismissalsReady(true));
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
@@ -201,10 +258,27 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
const endingFor = useCallback((alertId: string) => endings[alertId] ?? null, [endings]);
|
||||
// Read by `handleTrigger`, which must not depend on the render that scheduled
|
||||
// it: an alert this device saw end can never be raised again.
|
||||
const endingsRef = useRef(endings);
|
||||
endingsRef.current = endings;
|
||||
const dismissalsReadyRef = useRef(dismissalsReady);
|
||||
dismissalsReadyRef.current = dismissalsReady;
|
||||
const clearLastEnding = useCallback(() => setLastEnding(null), []);
|
||||
|
||||
const handleTrigger = useCallback(
|
||||
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
|
||||
// Ended on this device (delivered, disarmed, expired). The alert is gone,
|
||||
// so any frame that still names it is the tail of that alarm — the
|
||||
// service's own status keeps answering for up to a poll, and a queued
|
||||
// native launch can replay it. Raising it again is how a dismissed alarm
|
||||
// came back with no title to show.
|
||||
if (!dismissalsReadyRef.current) return;
|
||||
if (endingsRef.current[frame.alertId]) return;
|
||||
// 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,
|
||||
@@ -253,8 +327,11 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
resolved,
|
||||
alertId: activeAlert?.alertId ?? null,
|
||||
subscribeToken: activeAlert?.subscribeToken ?? null,
|
||||
enabled: !!token && online,
|
||||
// A subscriber needs no session: the device token minted at join is the
|
||||
// credential (rev 10).
|
||||
enabled: (!!token || !!activeAlert?.deviceToken) && online,
|
||||
deviceId,
|
||||
deviceToken: activeAlert?.deviceToken ?? null,
|
||||
onTrigger: frame => handleTrigger(frame, true),
|
||||
onDisarm: frame => handleDisarm(frame, true),
|
||||
// A 404 from the check-in is terminal: the alert ended. There is nothing to
|
||||
@@ -294,6 +371,88 @@ 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;
|
||||
// Never start listening before the dismissals this device already made are
|
||||
// known: the service's own check-in rings on whatever the ledger still owes,
|
||||
// and an unsent dismissal would make it ring for an alarm just dismissed.
|
||||
if (!dismissalsReady) 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,
|
||||
dismissalsReady,
|
||||
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);
|
||||
@@ -323,6 +482,9 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
name: input.alertId,
|
||||
status: 'armed' as AlertStatus,
|
||||
subscribeToken: input.subscribeToken,
|
||||
// The service minted this credential for THIS device and alert; it is
|
||||
// returned exactly once, on the join that created (or rotated) it.
|
||||
deviceToken: joined.deviceToken,
|
||||
role: 'subscriber',
|
||||
// The wire's subscription view is `id`; the request field name is the
|
||||
// only place `subscriptionId` appears.
|
||||
@@ -344,7 +506,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
const target = subscribedAlerts.find(a => a.alertId === alertId);
|
||||
if (target?.subscriptionId) {
|
||||
try {
|
||||
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
|
||||
await api.removeSubscription(
|
||||
{ alertId, subscriptionId: target.subscriptionId },
|
||||
target.deviceToken,
|
||||
);
|
||||
} catch (e) {
|
||||
// Already gone is settled: the alert ended, so there is nothing to
|
||||
// leave on the server and nothing to retry.
|
||||
@@ -420,30 +585,60 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
const acknowledgeAlarm = useCallback(
|
||||
async (alertId: string, eventId: string | undefined) => {
|
||||
if (!deviceId || !eventId) return;
|
||||
await enqueueAck({ alertId, eventId, deviceId });
|
||||
await flushAcks();
|
||||
// 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;
|
||||
// The device id is optional: a device-token ack identifies the
|
||||
// subscription by its credential alone (measured against the service), and
|
||||
// this used to `return` when the identity had not loaded yet — dropping the
|
||||
// ack that keeps the alarm from ringing again after a restart.
|
||||
await enqueueAck({ alertId, eventId: known, deviceId: deviceId ?? undefined, deviceToken });
|
||||
await flushAcks(deviceId ?? undefined);
|
||||
// Dismissing is leaving: a subscriber that acknowledged has no reason to
|
||||
// stay on the alert, and every leftover subscription is a live address
|
||||
// that can ring again — the reported "it fires again when I reopen the
|
||||
// app unless I unsubscribe". The ack settles the debt first (so the
|
||||
// ledger is clean even if the removal fails), then the row goes.
|
||||
const subscription = subscribedAlerts.find(a => a.alertId === alertId);
|
||||
if (subscription?.deviceToken) {
|
||||
try {
|
||||
await api.removeSubscription(
|
||||
{ alertId, subscriptionId: subscription.subscriptionId ?? '' },
|
||||
subscription.deviceToken,
|
||||
);
|
||||
} catch {
|
||||
// Best effort: the debt is settled either way, so a failure only
|
||||
// leaves a stale row (visible in "Subscribed devices") that the user
|
||||
// can still remove from the alert. Never resurrect the alarm over it.
|
||||
}
|
||||
markEnded(alertId, 'left').catch(() => {});
|
||||
}
|
||||
},
|
||||
[deviceId],
|
||||
[deviceId, lastStatus?.eventId, markDismissed, markEnded, subscribedAlerts],
|
||||
);
|
||||
|
||||
// Retry the durable queue on every plausible reconnect point, plus a slow
|
||||
// interval so a queue that could not flush at dismiss cannot linger.
|
||||
useEffect(() => {
|
||||
flushAcks().catch(() => {});
|
||||
flushAcks(deviceId ?? undefined).catch(() => {});
|
||||
const sub = AppState.addEventListener('change', next => {
|
||||
if (next === 'active') flushAcks().catch(() => {});
|
||||
if (next === 'active') flushAcks(deviceId ?? undefined).catch(() => {});
|
||||
});
|
||||
const timer = setInterval(() => flushAcks().catch(() => {}), 30_000);
|
||||
const timer = setInterval(() => flushAcks(deviceId ?? undefined).catch(() => {}), 30_000);
|
||||
return () => {
|
||||
sub.remove();
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
}, [deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (socketState === 'open') flushAcks().catch(() => {});
|
||||
}, [socketState]);
|
||||
if (socketState === 'open') flushAcks(deviceId ?? undefined).catch(() => {});
|
||||
}, [socketState, deviceId]);
|
||||
|
||||
const value: AlarmContextValue = {
|
||||
adminAlerts,
|
||||
@@ -460,6 +655,8 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
acknowledgeAlarm,
|
||||
lastStatus,
|
||||
refresh,
|
||||
backgroundListening,
|
||||
refreshBackgroundListening,
|
||||
reload,
|
||||
createAlert,
|
||||
joinAlert,
|
||||
|
||||
@@ -22,6 +22,12 @@ interface AuthContextValue {
|
||||
token: string | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
/**
|
||||
* No account on this device: subscriber-only surface. Receiving alarms never
|
||||
* needs one (rev 10) — a subscriber is identified by the device token that
|
||||
* `joinAlert` hands out, not by a user.
|
||||
*/
|
||||
anonymous: boolean;
|
||||
booting: boolean;
|
||||
busy: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
@@ -95,11 +101,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
if (cancelled) return;
|
||||
|
||||
// No account: come up as a subscriber. Rev 10 needs no identity at boot —
|
||||
// the device token arrives with the first join, and the app is usable from
|
||||
// the store without a login.
|
||||
if (!cancelled) setBooting(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// startAnonymousSession is stable (useCallback with no deps).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [persistTokens]);
|
||||
|
||||
const login = useCallback(
|
||||
@@ -153,6 +166,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
token: session?.token ?? null,
|
||||
username: session?.username ?? null,
|
||||
email: session?.email ?? null,
|
||||
anonymous: session === null,
|
||||
booting,
|
||||
busy,
|
||||
login,
|
||||
|
||||
@@ -18,14 +18,18 @@ interface ThemeContextValue {
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
// The app's palette is dark by construction (`theme/tokens.ts` are fixed
|
||||
// dark values); this flag only drives heroUI's own theme so its controls
|
||||
// match. Defaulting to light left every heroUI surface light while the
|
||||
// app's text tokens stayed light-on-light — the white-on-white login page.
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(THEME_KEY);
|
||||
const dark = raw === 'dark';
|
||||
const dark = raw !== 'light';
|
||||
if (!cancelled) setIsDark(dark);
|
||||
Uniwind.setTheme(dark ? 'dark' : 'light');
|
||||
} catch {
|
||||
|
||||
+32
-11
@@ -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';
|
||||
|
||||
@@ -24,6 +29,8 @@ export interface UseAlarmSocketOptions {
|
||||
enabled?: boolean;
|
||||
/** This device's ledger id (the service hashes it to address the debt). */
|
||||
deviceId?: string | null;
|
||||
/** Subscriber credential for this alert (rev 10). Replaces the access token. */
|
||||
deviceToken?: string | null;
|
||||
/** The service answered 404: the alert is destroyed, there is nothing to ring. */
|
||||
onGone?: () => void;
|
||||
}
|
||||
@@ -56,6 +63,7 @@ export function useAlarmSocket({
|
||||
onDisarm,
|
||||
enabled = true,
|
||||
deviceId,
|
||||
deviceToken,
|
||||
onGone,
|
||||
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
||||
const [socketState, setSocketState] = useState<SocketState>('idle');
|
||||
@@ -70,8 +78,11 @@ export function useAlarmSocket({
|
||||
disarmRef.current = onDisarm;
|
||||
|
||||
const url = useMemo(
|
||||
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
|
||||
[resolved, alertId, subscribeToken],
|
||||
() =>
|
||||
resolved && alertId && subscribeToken
|
||||
? alarmSocketUrl(resolved, alertId, subscribeToken, deviceToken)
|
||||
: null,
|
||||
[resolved, alertId, subscribeToken, deviceToken],
|
||||
);
|
||||
|
||||
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
|
||||
@@ -97,13 +108,14 @@ export function useAlarmSocket({
|
||||
const refresh = useCallback(async () => {
|
||||
if (!alertId) return;
|
||||
try {
|
||||
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
|
||||
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
|
||||
@@ -150,10 +162,10 @@ export function useAlarmSocket({
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
|
||||
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
|
||||
}
|
||||
};
|
||||
@@ -163,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