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 React, { useEffect, useRef } from 'react';
|
||||||
|
import { StatusBar, View } from 'react-native';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { HeroUINativeProviderRaw } from 'heroui-native/provider-raw';
|
import { HeroUINativeProviderRaw } from 'heroui-native/provider-raw';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
import { DarkTheme, NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ThemeProvider } from './src/state/ThemeContext';
|
import { ThemeProvider } from './src/state/ThemeContext';
|
||||||
@@ -17,12 +18,32 @@ import ScannerScreen from './src/screens/ScannerScreen';
|
|||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import LiveAlarmScreen from './src/screens/LiveAlarmScreen';
|
import LiveAlarmScreen from './src/screens/LiveAlarmScreen';
|
||||||
import { LoadingView } from './src/components/ui';
|
import { LoadingView } from './src/components/ui';
|
||||||
|
import { bg, border, focusRing, text } from './src/theme/tokens';
|
||||||
import { navigationRef, openLiveAlarm } from './src/navigationRef';
|
import { navigationRef, openLiveAlarm } from './src/navigationRef';
|
||||||
import { getLaunchAlert } from './src/services/alarmNative';
|
import { getLaunchAlert } from './src/services/alarmNative';
|
||||||
import type { RootStackParamList } from './src/navigation';
|
import type { RootStackParamList } from './src/navigation';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
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' }) {
|
function Navigator({ initialRoute }: { initialRoute: 'Home' | 'Login' }) {
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator initialRouteName={initialRoute} screenOptions={{ headerShown: false }}>
|
<Stack.Navigator initialRouteName={initialRoute} screenOptions={{ headerShown: false }}>
|
||||||
@@ -111,11 +132,9 @@ function Root() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token === prevToken.current) return;
|
if (token === prevToken.current) return;
|
||||||
prevToken.current = token;
|
prevToken.current = token;
|
||||||
if (token) {
|
// Home is always reachable: without an account it is the subscriber view
|
||||||
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
|
// (rev 10). Login is an explicit destination, never a wall.
|
||||||
} else {
|
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
|
||||||
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
|
||||||
}
|
|
||||||
}, [token, navigation]);
|
}, [token, navigation]);
|
||||||
|
|
||||||
useNativeLaunchTakeover();
|
useNativeLaunchTakeover();
|
||||||
@@ -123,25 +142,55 @@ function Root() {
|
|||||||
if (booting) return <LoadingView label="Starting…" />;
|
if (booting) return <LoadingView label="Starting…" />;
|
||||||
return (
|
return (
|
||||||
<AlarmProvider>
|
<AlarmProvider>
|
||||||
<Navigator initialRoute={token ? 'Home' : 'Login'} />
|
<Navigator initialRoute="Home" />
|
||||||
</AlarmProvider>
|
</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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: bg }}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={bg} />
|
||||||
<HeroUINativeProviderRaw>
|
<HeroUINativeProviderRaw>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<ThemeProvider>
|
<SafeAreaFrame>
|
||||||
<ConnectionProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<ConnectionProvider>
|
||||||
<NavigationContainer ref={navigationRef}>
|
<AuthProvider>
|
||||||
<Root />
|
<NavigationContainer ref={navigationRef} theme={NAV_THEME}>
|
||||||
</NavigationContainer>
|
<Root />
|
||||||
</AuthProvider>
|
</NavigationContainer>
|
||||||
</ConnectionProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ConnectionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SafeAreaFrame>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</HeroUINativeProviderRaw>
|
</HeroUINativeProviderRaw>
|
||||||
</GestureHandlerRootView>
|
</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', () => {
|
it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
|
||||||
expect(scheduler).toContain('setAlarmClock(');
|
expect(scheduler).toContain('setAlarmClock(');
|
||||||
expect(scheduler).toContain('AlarmClockInfo(');
|
expect(scheduler).toContain('AlarmClockInfo(');
|
||||||
expect(scheduler).toContain('AlarmActivity.intent(');
|
|
||||||
expect(scheduler).toContain('canScheduleExactAlarms');
|
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', () => {
|
it('fires the alarm from the trigger frame through the scheduler, not startActivity', () => {
|
||||||
expect(service).toContain('AlarmScheduler.fireNow(');
|
expect(service).toContain('AlarmScheduler.fireNow(');
|
||||||
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
|
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
|
||||||
@@ -78,21 +109,33 @@ describe('native alarm delivery contract', () => {
|
|||||||
expect(cancelFireBody).toContain('REQ_FIRE');
|
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;
|
const channels = notifier.split('= NotificationChannel(').length - 1;
|
||||||
expect(channels).toBe(2);
|
expect(channels).toBe(3);
|
||||||
expect(notifier).toContain('CHANNEL_VOLUME');
|
expect(notifier).toContain('CHANNEL_VOLUME');
|
||||||
expect(notifier).toContain('CHANNEL_SERVICE');
|
expect(notifier).toContain('CHANNEL_SERVICE');
|
||||||
// The volume warning is informational; only an alarm may take over the screen,
|
expect(notifier).toContain('CHANNEL_FIRING');
|
||||||
// and that is scheduled, not notified.
|
const fsi = notifier.split('setFullScreenIntent').length - 1;
|
||||||
expect(notifier).not.toContain('setFullScreenIntent');
|
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).toContain('Alarm volume is at ');
|
||||||
expect(notifier).not.toMatch(/Alarm ringing/);
|
|
||||||
expect(notifier).not.toMatch(/Alarm armed"\)/);
|
|
||||||
expect(notifier).toContain('CATEGORY_RECOMMENDATION');
|
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', () => {
|
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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
import com.facebook.react.ReactActivity
|
import com.facebook.react.ReactActivity
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
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.
|
// Order matters: the flags must be set before the window is shown.
|
||||||
showOverLockScreen()
|
showOverLockScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
current = WeakReference(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOverLockScreen() {
|
private fun showOverLockScreen() {
|
||||||
@@ -67,11 +69,30 @@ class AlarmActivity : ReactActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
if (current?.get() === this) current = null
|
||||||
AlarmPlayer.stop(this)
|
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()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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_ALERT_ID = "alarm.alertId"
|
||||||
const val EXTRA_MESSAGE = "alarm.message"
|
const val EXTRA_MESSAGE = "alarm.message"
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ class AlarmForegroundService : Service() {
|
|||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
"trigger" -> {
|
"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)
|
val volumeRaised = AlarmPlayer.play(this, id)
|
||||||
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
||||||
// A real alarm, not a notification: setAlarmClock is what an
|
// A real alarm, not a notification: setAlarmClock is what an
|
||||||
@@ -233,6 +238,7 @@ class AlarmForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
"disarm" -> {
|
"disarm" -> {
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmNotifier.cancelFiring(this)
|
||||||
AlarmNotifier.cancelVolumeWarning(this)
|
AlarmNotifier.cancelVolumeWarning(this)
|
||||||
// Only the firing entry dies here: a disarm ends this ring,
|
// Only the firing entry dies here: a disarm ends this ring,
|
||||||
// not the subscription. The standing entry (alarm-app status
|
// not the subscription. The standing entry (alarm-app status
|
||||||
@@ -259,6 +265,7 @@ class AlarmForegroundService : Service() {
|
|||||||
stopping = true
|
stopping = true
|
||||||
disconnect()
|
disconnect()
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmNotifier.cancelFiring(this)
|
||||||
AlarmNotifier.cancelVolumeWarning(this)
|
AlarmNotifier.cancelVolumeWarning(this)
|
||||||
// Explicit stop (user asked): idle, not "timed out". Leaving the arm
|
// Explicit stop (user asked): idle, not "timed out". Leaving the arm
|
||||||
// record or the standing entry behind would keep an alarm icon for an
|
// 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) {
|
fun stopAlarm(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
AlarmPlayer.stop(reactContext)
|
AlarmPlayer.stop(reactContext)
|
||||||
|
AlarmNotifier.cancelFiring(reactContext)
|
||||||
AlarmNotifier.cancelVolumeWarning(reactContext)
|
AlarmNotifier.cancelVolumeWarning(reactContext)
|
||||||
promise.resolve(result(ok = true))
|
promise.resolve(result(ok = true))
|
||||||
} catch (e: Exception) {
|
} 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). */
|
/** Opens this app's Android settings page (used by the camera-denied state). */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun openAppSettings(promise: Promise) {
|
fun openAppSettings(promise: Promise) {
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ object AlarmNotifier {
|
|||||||
/** Foreground-service presence. Deliberately the quietest channel. */
|
/** Foreground-service presence. Deliberately the quietest channel. */
|
||||||
const val CHANNEL_SERVICE = "alarm_armed"
|
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) {
|
fun ensureChannels(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
@@ -199,6 +202,22 @@ object AlarmNotifier {
|
|||||||
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
|
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
|
||||||
}
|
}
|
||||||
nm.createNotificationChannel(service)
|
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)
|
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. */
|
/** Removes the persistent "Alarm armed" notification. */
|
||||||
fun cancelArmed(context: Context) {
|
fun cancelArmed(context: Context) {
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
@@ -242,4 +316,5 @@ object AlarmNotifier {
|
|||||||
|
|
||||||
const val NOTIFICATION_ID_VOLUME = 4711
|
const val NOTIFICATION_ID_VOLUME = 4711
|
||||||
const val NOTIFICATION_ID_SERVICE = 4712
|
const val NOTIFICATION_ID_SERVICE = 4712
|
||||||
|
const val NOTIFICATION_ID_FIRING = 4713
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ object AlarmScheduler {
|
|||||||
private const val REQ_SHOW = 4203
|
private const val REQ_SHOW = 4203
|
||||||
|
|
||||||
const val ACTION_STANDING = "com.dcntrlized.alarm.STANDING"
|
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_ALERT_ID = "alarm.scheduler.alertId"
|
||||||
|
const val EXTRA_MESSAGE = "alarm.scheduler.message"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `USE_EXACT_ALARM` (declared in the manifest) makes this true without user
|
* `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
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||||
return try {
|
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,
|
context,
|
||||||
REQ_FIRE,
|
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
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
am.setAlarmClock(
|
am.setAlarmClock(
|
||||||
@@ -176,10 +199,10 @@ object AlarmScheduler {
|
|||||||
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||||
try {
|
try {
|
||||||
am.cancel(
|
am.cancel(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
REQ_FIRE,
|
REQ_FIRE,
|
||||||
AlarmActivity.intent(context, "", ""),
|
Intent(context, AlarmWakeReceiver::class.java).apply { action = ACTION_FIRE },
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
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}")
|
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 -> {
|
AlarmScheduler.ACTION_STANDING -> {
|
||||||
val armed = AlarmArmStore.current(context) ?: return
|
val armed = AlarmArmStore.current(context) ?: return
|
||||||
// Chain the next wake before doing anything that could fail.
|
// Chain the next wake before doing anything that could fail.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.5",
|
"react-native-svg": "15.15.5",
|
||||||
"react-native-vision-camera": "5.2.3",
|
"react-native-vision-camera": "5.2.3",
|
||||||
|
"react-native-vision-camera-barcode-scanner": "^5.2.3",
|
||||||
"react-native-worklets": "0.10.0",
|
"react-native-worklets": "0.10.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.3.1",
|
"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": ["[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-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=="],
|
"react-refresh": ["[email protected]", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.5",
|
"react-native-svg": "15.15.5",
|
||||||
"react-native-vision-camera": "5.2.3",
|
"react-native-vision-camera": "5.2.3",
|
||||||
|
"react-native-vision-camera-barcode-scanner": "5.2.3",
|
||||||
"react-native-worklets": "0.10.0",
|
"react-native-worklets": "0.10.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.3.1",
|
"tailwind-variants": "^3.3.1",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import {
|
import {
|
||||||
|
bg,
|
||||||
border,
|
border,
|
||||||
danger,
|
danger,
|
||||||
dangerDeep,
|
dangerDeep,
|
||||||
@@ -536,7 +537,7 @@ export const styles = StyleSheet.create({
|
|||||||
paddingVertical: 48,
|
paddingVertical: 48,
|
||||||
},
|
},
|
||||||
emptyAction: { marginTop: space6, alignSelf: 'stretch' },
|
emptyAction: { marginTop: space6, alignSelf: 'stretch' },
|
||||||
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48 },
|
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, backgroundColor: bg },
|
||||||
errorBanner: {
|
errorBanner: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(248,113,113,0.3)',
|
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',
|
title: 'This alert was deleted',
|
||||||
body: 'The alert and its tokens are gone.',
|
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() {
|
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.');
|
setNotice('The service URL is not known yet — re-open the alert in a moment.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wsUrl = alarmSocketUrl(resolved, alertId, token);
|
const wsUrl = alarmSocketUrl(resolved, alertId, token, alert?.deviceToken);
|
||||||
if (!wsUrl) {
|
if (!wsUrl) {
|
||||||
setNotice(
|
setNotice(
|
||||||
'No instance address is configured, so background listening is unavailable.',
|
'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.'
|
? 'Listening again — this device is armed.'
|
||||||
: `Could not start listening (${res.reason ?? 'unknown'}).`,
|
: `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(
|
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
||||||
isAdmin ? alertId : null,
|
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.
|
// Read the authoritative state on mount; the local copy is only a cache.
|
||||||
const loadServerAlert = useCallback(async () => {
|
const loadServerAlert = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const fresh = await api.getAlert(alertId);
|
const fresh = await api.getAlert(alertId, alert?.deviceToken);
|
||||||
setServerAlert(fresh);
|
setServerAlert(fresh);
|
||||||
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -296,7 +300,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
}
|
}
|
||||||
// Offline is fine — the cached copy renders.
|
// Offline is fine — the cached copy renders.
|
||||||
}
|
}
|
||||||
}, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]);
|
}, [
|
||||||
|
alert?.deviceToken,
|
||||||
|
alert?.expiresAt,
|
||||||
|
alertId,
|
||||||
|
markEnded,
|
||||||
|
serverAlert?.expiresAt,
|
||||||
|
updateAlertLocal,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveAlertId(isAdmin ? null : alertId);
|
setActiveAlertId(isAdmin ? null : alertId);
|
||||||
|
|||||||
+61
-19
@@ -64,11 +64,12 @@ const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>
|
|||||||
expired: 'Expired — its lifetime ran out.',
|
expired: 'Expired — its lifetime ran out.',
|
||||||
disarmed: 'Ended — you disarmed it.',
|
disarmed: 'Ended — you disarmed it.',
|
||||||
deleted: 'Deleted.',
|
deleted: 'Deleted.',
|
||||||
|
left: 'Acknowledged — this device unsubscribed.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { username, logout } = useAuth();
|
const { username, anonymous, logout } = useAuth();
|
||||||
const { online } = useConnection();
|
const { online } = useConnection();
|
||||||
const {
|
const {
|
||||||
adminAlerts,
|
adminAlerts,
|
||||||
@@ -79,11 +80,14 @@ export default function HomeScreen() {
|
|||||||
liveEvent,
|
liveEvent,
|
||||||
delivery,
|
delivery,
|
||||||
socketState,
|
socketState,
|
||||||
|
backgroundListening,
|
||||||
lastEnding,
|
lastEnding,
|
||||||
clearLastEnding,
|
clearLastEnding,
|
||||||
} = useAlarm();
|
} = 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 [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
@@ -102,7 +106,11 @@ export default function HomeScreen() {
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
||||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
<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>
|
</AppText>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -131,6 +139,30 @@ export default function HomeScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : 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}>
|
<View style={styles.tabs}>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="My alerts"
|
label="My alerts"
|
||||||
@@ -147,6 +179,7 @@ export default function HomeScreen() {
|
|||||||
onPress={() => setTab('subscribed')}
|
onPress={() => setTab('subscribed')}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.content}
|
contentContainerStyle={styles.content}
|
||||||
@@ -215,12 +248,17 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{tab === 'subscribed' ? (
|
{tab === 'subscribed' ? (
|
||||||
|
// Delivery truth, most durable fact first: the foreground service
|
||||||
|
// is what keeps ringing while the app is closed. The JS socket only
|
||||||
|
// covers the on-screen case and must not be presented as coverage.
|
||||||
<AppText style={styles.deliveryNote}>
|
<AppText style={styles.deliveryNote}>
|
||||||
{socketState === 'open'
|
{backgroundListening?.state === 'running'
|
||||||
? 'Live delivery connected — triggers arrive over the instance socket.'
|
? 'Listening in the background — triggers arrive even when this app is closed.'
|
||||||
: delivery === 'socket'
|
: delivery !== 'socket'
|
||||||
? `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.`
|
? 'Not listening — set your instance address in Settings.'
|
||||||
: 'Not listening — set your instance address in Settings.'}
|
: socketState === 'open'
|
||||||
|
? 'Listening while this app is open only — background listening is off. Open the alert to see what is missing.'
|
||||||
|
: `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.`}
|
||||||
</AppText>
|
</AppText>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -241,17 +279,21 @@ export default function HomeScreen() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => navigation.navigate('Scanner')}
|
onPress={() => navigation.navigate('Scanner')}
|
||||||
/>
|
/>
|
||||||
<Button
|
{/* A subscriber has no session to end: the device token lives with the
|
||||||
label="Sign out"
|
subscription, so there is nothing to sign out of (rev 10). */}
|
||||||
variant="ghost"
|
{anonymous ? null : (
|
||||||
height={44}
|
<Button
|
||||||
onPress={() =>
|
label="Sign out"
|
||||||
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
|
variant="ghost"
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
height={44}
|
||||||
{ text: 'Sign out', style: 'destructive', onPress: logout },
|
onPress={() =>
|
||||||
])
|
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
|
||||||
}
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
/>
|
{ text: 'Sign out', style: 'destructive', onPress: logout },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { BellOff, OctagonX, Siren, VolumeX } from 'lucide-react-native';
|
import { BellOff, ChevronLeft, OctagonX, Siren, VolumeX } from 'lucide-react-native';
|
||||||
import { useAlarm } from '../state/AlarmContext';
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
import { useAuth } from '../state/AuthContext';
|
import { useAuth } from '../state/AuthContext';
|
||||||
import { AppText } from '../components/ui';
|
import { AppText } from '../components/ui';
|
||||||
import * as api from '../services/alarmApi';
|
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 { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import {
|
import {
|
||||||
motionSiren,
|
motionSiren,
|
||||||
radiusFull,
|
radiusFull,
|
||||||
|
space1,
|
||||||
space2,
|
space2,
|
||||||
space3,
|
space3,
|
||||||
space4,
|
space4,
|
||||||
@@ -61,8 +68,16 @@ const STOP_LABEL = 'Stop for everyone';
|
|||||||
export default function LiveAlarmScreen() {
|
export default function LiveAlarmScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { params } = useRoute<Rt>();
|
const { params } = useRoute<Rt>();
|
||||||
const { liveEvent, clearLiveEvent, adminAlerts, adminTokenFor, subscribedAlerts, updateAlertLocal } =
|
const {
|
||||||
useAlarm();
|
liveEvent,
|
||||||
|
clearLiveEvent,
|
||||||
|
adminAlerts,
|
||||||
|
adminTokenFor,
|
||||||
|
subscribedAlerts,
|
||||||
|
updateAlertLocal,
|
||||||
|
acknowledgeAlarm,
|
||||||
|
endingFor,
|
||||||
|
} = useAlarm();
|
||||||
const { username } = useAuth();
|
const { username } = useAuth();
|
||||||
|
|
||||||
const alertId = params.alertId;
|
const alertId = params.alertId;
|
||||||
@@ -146,10 +161,23 @@ export default function LiveAlarmScreen() {
|
|||||||
};
|
};
|
||||||
}, [fade, shake, reduceMotion]);
|
}, [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
|
// 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
|
// refuses the alarm stream, the only thing that can still reach the user is
|
||||||
// the volume warning — the screen says so instead of pretending.
|
// the volume warning — the screen says so instead of pretending.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!live) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await playAlarm(alertId, message ?? '');
|
const res = await playAlarm(alertId, message ?? '');
|
||||||
@@ -161,12 +189,16 @@ export default function LiveAlarmScreen() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopAlarm();
|
stopAlarm();
|
||||||
};
|
};
|
||||||
}, [alertId, message]);
|
}, [alertId, message, live]);
|
||||||
|
|
||||||
const leave = useCallback(() => {
|
const leave = useCallback(() => {
|
||||||
clearLiveEvent();
|
clearLiveEvent();
|
||||||
if (navigation.canGoBack()) navigation.goBack();
|
if (navigation.canGoBack()) navigation.goBack();
|
||||||
else navigation.navigate('Home');
|
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]);
|
}, [clearLiveEvent, navigation]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,11 +213,28 @@ export default function LiveAlarmScreen() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Best effort: the volume warning (if any) is the only remaining signal.
|
// Best effort: the volume warning (if any) is the only remaining signal.
|
||||||
}
|
}
|
||||||
|
// Persist the dismissal. Without this the service still believes this device
|
||||||
|
// owes the alarm and replays it on the next connect/poll, which is exactly
|
||||||
|
// how a dismissed alarm comes back — so the ack is enqueued durably (it
|
||||||
|
// survives the app dying) and the local memory stops the same trigger being
|
||||||
|
// raised again meanwhile.
|
||||||
|
try {
|
||||||
|
await acknowledgeAlarm(alertId, event?.eventId ?? params.eventId);
|
||||||
|
} catch {
|
||||||
|
// The queue retries; nothing to show the user while an alarm is ringing.
|
||||||
|
}
|
||||||
// Once the native player is stopped this device is silent, so the live
|
// Once the native player is stopped this device is silent, so the live
|
||||||
// event no longer needs to drive a takeover — but the screen stays mounted
|
// event no longer needs to drive a takeover.
|
||||||
// in its acknowledged state until the user leaves it.
|
|
||||||
clearLiveEvent();
|
clearLiveEvent();
|
||||||
}, [clearLiveEvent]);
|
}, [acknowledgeAlarm, alertId, clearLiveEvent, event?.eventId, params.eventId]);
|
||||||
|
|
||||||
|
// The alert can end while the takeover is on screen (the owner disarmed it, its
|
||||||
|
// lifetime ran out, or the last owed ack destroyed it). The screen has no
|
||||||
|
// purpose after that — leave instead of stranding the user on a dead alarm.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!endingFor(alertId)) return;
|
||||||
|
leave();
|
||||||
|
}, [alertId, endingFor, leave]);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
if (!adminToken) {
|
if (!adminToken) {
|
||||||
@@ -206,12 +255,17 @@ export default function LiveAlarmScreen() {
|
|||||||
}
|
}
|
||||||
}, [adminToken, alertId, leave, updateAlertLocal]);
|
}, [adminToken, alertId, leave, updateAlertLocal]);
|
||||||
|
|
||||||
// Hardware back and the back gesture are consumed: the only exits are
|
// While the alarm is live, back is consumed: the only exits are Acknowledge
|
||||||
// Acknowledge and Stop alarm (spec, Cross-screen rules).
|
// and Stop alarm (spec, Cross-screen rules). Once this device has been
|
||||||
|
// acknowledged it is silent, so back is the way home again.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
if (!acknowledged) return true;
|
||||||
|
leave();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, []);
|
}, [acknowledged, leave]);
|
||||||
|
|
||||||
// `steps(1, end)` as a square wave: a linear driver sampled at the halfway
|
// `steps(1, end)` as a square wave: a linear driver sampled at the halfway
|
||||||
// point jumps −8° → +8° with no interpolation, which is the mechanical klaxon
|
// point jumps −8° → +8° with no interpolation, which is the mechanical klaxon
|
||||||
@@ -236,7 +290,7 @@ export default function LiveAlarmScreen() {
|
|||||||
{/* The alert name is the headline: at the moment of maximum stress this
|
{/* The alert name is the headline: at the moment of maximum stress this
|
||||||
is the one piece of information the user needs (D-5). */}
|
is the one piece of information the user needs (D-5). */}
|
||||||
<AppText style={styles.name} numberOfLines={2}>
|
<AppText style={styles.name} numberOfLines={2}>
|
||||||
{alert?.name ?? alertId}
|
{alert?.name ?? 'Alarm'}
|
||||||
</AppText>
|
</AppText>
|
||||||
<AppText style={styles.owner}>{attribution}</AppText>
|
<AppText style={styles.owner}>{attribution}</AppText>
|
||||||
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
|
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
|
||||||
@@ -311,6 +365,18 @@ export default function LiveAlarmScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{acknowledged ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={leave}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Back to your alerts"
|
||||||
|
style={styles.back}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} color={onAlarm} />
|
||||||
|
<AppText style={styles.backLabel}>Back to alerts</AppText>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AppText style={styles.footer}>
|
<AppText style={styles.footer}>
|
||||||
{isOwner
|
{isOwner
|
||||||
? 'Acknowledge silences this device only · Stop ends it for everyone'
|
? 'Acknowledge silences this device only · Stop ends it for everyone'
|
||||||
@@ -392,6 +458,16 @@ const styles = StyleSheet.create({
|
|||||||
gap: space2,
|
gap: space2,
|
||||||
},
|
},
|
||||||
stopLabel: { ...typeBodyStrong, color: onAlarm },
|
stopLabel: { ...typeBodyStrong, color: onAlarm },
|
||||||
|
back: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: space1,
|
||||||
|
},
|
||||||
|
backLabel: { ...typeLabel, color: onAlarm },
|
||||||
disabled: { opacity: 0.4 },
|
disabled: { opacity: 0.4 },
|
||||||
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
|
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Screen,
|
Screen,
|
||||||
TextInputBox,
|
TextInputBox,
|
||||||
} from '../components/ui';
|
} 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';
|
type Mode = 'signin' | 'register';
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export default function LoginScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1, backgroundColor: bg }}>
|
||||||
{!online ? <OfflineStrip /> : null}
|
{!online ? <OfflineStrip /> : null}
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
@@ -72,14 +72,24 @@ export default function LoginScreen() {
|
|||||||
</AppText>
|
</AppText>
|
||||||
</View>
|
</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
|
<TextInputBox
|
||||||
value={username}
|
value={username}
|
||||||
onChangeText={setUsername}
|
onChangeText={setUsername}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
accessibilityLabel="Username"
|
keyboardType={isRegister ? 'default' : 'email-address'}
|
||||||
placeholder="your username"
|
accessibilityLabel={isRegister ? 'Username' : 'Email'}
|
||||||
|
placeholder={isRegister ? 'your username' : '[email protected]'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
useCameraDevice,
|
useCameraDevice,
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
useObjectOutput,
|
|
||||||
} from 'react-native-vision-camera';
|
} from 'react-native-vision-camera';
|
||||||
|
import { useBarcodeScannerOutput } from 'react-native-vision-camera-barcode-scanner';
|
||||||
import { ScanLine, X } from 'lucide-react-native';
|
import { ScanLine, X } from 'lucide-react-native';
|
||||||
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
|
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
|
||||||
import { useAlarm } from '../state/AlarmContext';
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
@@ -158,32 +158,42 @@ export default function ScannerScreen() {
|
|||||||
[busy, joinAlert, navigation, perms],
|
[busy, joinAlert, navigation, perms],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onScanned = useCallback(
|
// Both platforms scan through MLKit here. The core library's
|
||||||
(objects: readonly { type: string; value?: string }[]) => {
|
// `useObjectOutput` is Apple-Vision only: on Android its native factory
|
||||||
for (const obj of objects) {
|
// throws `CameraObjectOutput is not available on Android!` the moment the
|
||||||
const value = typeof obj.value === 'string' ? obj.value : undefined;
|
// screen mounts, which crashed the app on "Scan a QR code". The Barcode
|
||||||
if (obj.type !== 'qr' || !value) continue;
|
// Scanner output is cross-platform (MLKit on Android, Vision on iOS).
|
||||||
const parsed = parseQrPayload(value);
|
const handleScanValue = useCallback(
|
||||||
if (parsed.ok) {
|
(value: string) => {
|
||||||
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
|
const parsed = parseQrPayload(value);
|
||||||
setError('That code is for a different alert.');
|
if (parsed.ok) {
|
||||||
continue;
|
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
|
||||||
}
|
setError('That code is for a different alert.');
|
||||||
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
|
return;
|
||||||
} else {
|
|
||||||
setError(qrErrorMessage(parsed.error));
|
|
||||||
}
|
}
|
||||||
return;
|
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
|
||||||
|
} else {
|
||||||
|
setError(qrErrorMessage(parsed.error));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[complete, targetAlertId],
|
[complete, targetAlertId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// v5 ships the scanner in core: `useObjectOutput` is the code-scanner — there
|
const barcodeOutput = useBarcodeScannerOutput({
|
||||||
// is no separate vision-camera-code-scanner plugin for 5.x.
|
barcodeFormats: ['qr-code'],
|
||||||
const objectOutput = useObjectOutput({
|
onBarcodeScanned(barcodes) {
|
||||||
types: ['qr'],
|
for (const barcode of barcodes) {
|
||||||
onObjectsScanned: onScanned,
|
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 () => {
|
const submitManual = async () => {
|
||||||
@@ -236,7 +246,7 @@ export default function ScannerScreen() {
|
|||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
device={device}
|
device={device}
|
||||||
isActive={!joinedName}
|
isActive={!joinedName}
|
||||||
outputs={[objectOutput]}
|
outputs={[barcodeOutput]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.permission}>
|
<View style={styles.permission}>
|
||||||
|
|||||||
@@ -103,8 +103,15 @@ export default function SettingsScreen() {
|
|||||||
const listeningFailed = listening?.state === 'delivery-failed';
|
const listeningFailed = listening?.state === 'delivery-failed';
|
||||||
|
|
||||||
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
|
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
|
||||||
|
// The device token is the subscriber's credential (rev 10): without it this
|
||||||
|
// URL would be refused for an account-less device.
|
||||||
const wsUrl = resolved && activeAlert
|
const wsUrl = resolved && activeAlert
|
||||||
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '')
|
? alarmSocketUrl(
|
||||||
|
resolved,
|
||||||
|
activeAlert.alertId,
|
||||||
|
activeAlert.subscribeToken ?? '',
|
||||||
|
activeAlert.deviceToken,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const toggleService = async () => {
|
const toggleService = async () => {
|
||||||
@@ -132,6 +139,45 @@ export default function SettingsScreen() {
|
|||||||
{!online ? <OfflineStrip /> : null}
|
{!online ? <OfflineStrip /> : null}
|
||||||
<Header title="Settings & health" onBack={() => navigation.goBack()} />
|
<Header title="Settings & health" onBack={() => navigation.goBack()} />
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<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 ─────────────────────────────────────────────────── */}
|
{/* ── Delivery ─────────────────────────────────────────────────── */}
|
||||||
<Card>
|
<Card>
|
||||||
<View style={{ gap: space3 }}>
|
<View style={{ gap: space3 }}>
|
||||||
@@ -273,45 +319,6 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</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 ──────────────────────────────────────────────────── */}
|
{/* ── Account ──────────────────────────────────────────────────── */}
|
||||||
<Card>
|
<Card>
|
||||||
<View style={{ gap: space3 }}>
|
<View style={{ gap: space3 }}>
|
||||||
@@ -319,10 +326,14 @@ export default function SettingsScreen() {
|
|||||||
<Row
|
<Row
|
||||||
icon={Info}
|
icon={Info}
|
||||||
tint={textMuted}
|
tint={textMuted}
|
||||||
title={username ?? 'Not signed in'}
|
title={username ?? 'No account on this device'}
|
||||||
detail={email || 'Signed in on this instance'}
|
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>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+62
-19
@@ -104,6 +104,12 @@ export interface Alert {
|
|||||||
role?: 'admin' | 'subscriber';
|
role?: 'admin' | 'subscriber';
|
||||||
/** Client-side only: subscription id for the subscriber role. */
|
/** Client-side only: subscription id for the subscriber role. */
|
||||||
subscriptionId?: string;
|
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;
|
deviceLabel?: string;
|
||||||
lastTriggered?: string;
|
lastTriggered?: string;
|
||||||
subscriberCount?: number;
|
subscriberCount?: number;
|
||||||
@@ -165,8 +171,12 @@ export function createAlert(payload: {
|
|||||||
return post<Alert>('/action/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`
|
* revocation handle; there is no `subscriptionId` on the wire and no `alert`
|
||||||
* object (the join screen already holds the QR's alert data).
|
* 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: {
|
export function joinAlert(payload: {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
subscribeToken: string;
|
subscribeToken: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceLabel?: string;
|
deviceLabel?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
}): Promise<Subscription> {
|
}): Promise<Subscription & { deviceToken?: string }> {
|
||||||
return post<Subscription>('/action/joinAlert', payload);
|
return post<Subscription & { deviceToken?: string }>('/action/joinAlert', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSubscriptions(alertId: string): Promise<Subscription[]> {
|
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 : [];
|
return Array.isArray(items) ? items : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeSubscription(payload: {
|
export function removeSubscription(
|
||||||
alertId: string;
|
payload: { alertId: string; subscriptionId: string },
|
||||||
subscriptionId: string;
|
deviceToken?: string | null,
|
||||||
}): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return post<unknown>('/action/removeSubscription', payload);
|
return post<unknown>('/action/removeSubscription', payload, false, deviceToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function triggerAlert(payload: {
|
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
|
* 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.
|
* 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)}${
|
const query = `alertId=${encodeURIComponent(alertId)}${
|
||||||
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
|
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
|
||||||
}`;
|
}`;
|
||||||
try {
|
try {
|
||||||
return await get<AlarmStatus>(`/action/alarmStatus?${query}`);
|
return await get<AlarmStatus>(`/action/alarmStatus?${query}`, deviceToken);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
|
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
|
||||||
const frame = offlineFrame(e.body);
|
const frame = offlineFrame(e.body);
|
||||||
@@ -260,6 +280,18 @@ export function isAlertGone(error: unknown): boolean {
|
|||||||
return error instanceof ApiError && error.status === 404;
|
return error instanceof ApiError && error.status === 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A device token that no longer resolves. The service answers 401 for it, which
|
||||||
|
* is how a *destroyed* alert looks to a device credential: acknowledging the
|
||||||
|
* last owed alarm destroys the alert, the subscription row cascades away, and
|
||||||
|
* the token has nothing left to address. For a device-token call this is as
|
||||||
|
* terminal as a 404, and treating it as retryable is what keeps a dead
|
||||||
|
* subscription polling (and replaying) forever.
|
||||||
|
*/
|
||||||
|
export function isDeviceTokenRefused(error: unknown): boolean {
|
||||||
|
return error instanceof ApiError && error.status === 401;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit acknowledgement of a delivered alarm.
|
* Explicit acknowledgement of a delivered alarm.
|
||||||
*
|
*
|
||||||
@@ -270,13 +302,19 @@ export function isAlertGone(error: unknown): boolean {
|
|||||||
*/
|
*/
|
||||||
export function ackAlarm(input: {
|
export function ackAlarm(input: {
|
||||||
alertId: string;
|
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> {
|
}): Promise<unknown> {
|
||||||
return post<unknown>(
|
const query = input.deviceId ? `?deviceId=${encodeURIComponent(input.deviceId)}` : '';
|
||||||
`/action/ackAlarm?deviceId=${encodeURIComponent(input.deviceId)}`,
|
return post<unknown>(`/action/ackAlarm${query}`, input, false, input.deviceToken);
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
|
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. */
|
/** Optional convenience: the service renders the PNG; resolved as base64. */
|
||||||
export function qrImage(alertId: string): Promise<string> {
|
export function qrImage(alertId: string, deviceToken?: string | null): Promise<string> {
|
||||||
return requestBase64('GET', `/action/qrImage?alertId=${encodeURIComponent(alertId)}`);
|
return requestBase64(
|
||||||
|
'GET',
|
||||||
|
`/action/qrImage?alertId=${encodeURIComponent(alertId)}`,
|
||||||
|
undefined,
|
||||||
|
deviceToken,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface AlarmServiceState {
|
|||||||
interface AlarmNativeModule {
|
interface AlarmNativeModule {
|
||||||
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
||||||
stopAlarm(): Promise<AlarmNativeResult>;
|
stopAlarm(): Promise<AlarmNativeResult>;
|
||||||
|
finishTakeover(): Promise<AlarmNativeResult>;
|
||||||
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
||||||
stopService(): Promise<AlarmNativeResult>;
|
stopService(): Promise<AlarmNativeResult>;
|
||||||
getPermissions(): Promise<AlarmPermissions>;
|
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
|
* Keeps the socket alive while armed: a foreground service with a persistent
|
||||||
* notification, so the trigger still lands when the app is backgrounded.
|
* notification, so the trigger still lands when the app is backgrounded.
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ async function perform<T>(
|
|||||||
payload: unknown,
|
payload: unknown,
|
||||||
raw: boolean,
|
raw: boolean,
|
||||||
bodyMode: BodyMode,
|
bodyMode: BodyMode,
|
||||||
|
deviceToken?: string | null,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!config.baseUrl) {
|
if (!config.baseUrl) {
|
||||||
throw new ApiError(null, 'Server not configured yet');
|
throw new ApiError(null, 'Server not configured yet');
|
||||||
@@ -141,7 +142,11 @@ async function perform<T>(
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
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;
|
headers['X-Access-Token'] = config.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +254,9 @@ export async function request<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
raw = false,
|
raw = false,
|
||||||
|
deviceToken?: string | null,
|
||||||
): Promise<T> {
|
): 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. */
|
/** Text response variant for endpoints that are not JSON. */
|
||||||
@@ -266,22 +272,32 @@ export async function requestBase64(
|
|||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
path: string,
|
path: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
|
deviceToken?: string | null,
|
||||||
): Promise<string> {
|
): 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> {
|
export async function get<T>(path: string, deviceToken?: string | null): Promise<T> {
|
||||||
return request<T>('GET', path);
|
return request<T>('GET', path, undefined, false, deviceToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function post<T>(path: string, payload?: unknown, raw?: boolean): Promise<T> {
|
export async function post<T>(
|
||||||
return request<T>('POST', path, payload, raw);
|
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> {
|
export async function put<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
return request<T>('PUT', path, payload);
|
return request<T>('PUT', path, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function del<T = void>(path: string, payload?: unknown): Promise<T> {
|
export async function del<T = void>(
|
||||||
return request<T>('DELETE', path, payload);
|
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 },
|
resolved: { baseUrl: string },
|
||||||
alertId: string,
|
alertId: string,
|
||||||
subscribeToken: string,
|
subscribeToken: string,
|
||||||
|
deviceToken?: string | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!resolved.baseUrl) return null;
|
if (!resolved.baseUrl) return null;
|
||||||
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
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}`;
|
return `${ws}/api/${SERVICE_NAME}/ws/alarm?${q}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-13
@@ -13,21 +13,35 @@
|
|||||||
* forever.
|
* forever.
|
||||||
*/
|
*/
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { ackAlarm, isAlertGone } from './alarmApi';
|
import { ackAlarm, isAlertGone, isDeviceTokenRefused } from './alarmApi';
|
||||||
|
|
||||||
const KEY = 'alarm.pendingAcks.v1';
|
const KEY = 'alarm.pendingAcks.v1';
|
||||||
|
|
||||||
export interface PendingAck {
|
export interface PendingAck {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
eventId: string;
|
/**
|
||||||
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. */
|
/** Merge by (alertId, eventId); a repeat dismiss never duplicates an entry. */
|
||||||
export function mergeAck(list: PendingAck[], ack: PendingAck): PendingAck[] {
|
export function mergeAck(list: PendingAck[], ack: PendingAck): PendingAck[] {
|
||||||
return list.some(a => a.alertId === ack.alertId && a.eventId === ack.eventId)
|
return list.some(a => ackKey(a) === ackKey(ack)) ? list : [...list, ack];
|
||||||
? list
|
|
||||||
: [...list, ack];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse(raw: string | null): PendingAck[] {
|
function parse(raw: string | null): PendingAck[] {
|
||||||
@@ -40,8 +54,12 @@ function parse(raw: string | null): PendingAck[] {
|
|||||||
!!v &&
|
!!v &&
|
||||||
typeof v === 'object' &&
|
typeof v === 'object' &&
|
||||||
typeof (v as PendingAck).alertId === 'string' &&
|
typeof (v as PendingAck).alertId === 'string' &&
|
||||||
typeof (v as PendingAck).eventId === 'string' &&
|
((v as PendingAck).eventId === undefined ||
|
||||||
typeof (v as PendingAck).deviceId === 'string',
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -67,8 +85,9 @@ export async function enqueueAck(ack: PendingAck): Promise<void> {
|
|||||||
await writeAcks(mergeAck(await loadAcks(), ack));
|
await writeAcks(mergeAck(await loadAcks(), ack));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dropAck(alertId: string, eventId: string): Promise<void> {
|
export async function dropAck(alertId: string, eventId?: string): Promise<void> {
|
||||||
const next = (await loadAcks()).filter(a => !(a.alertId === alertId && a.eventId === eventId));
|
const key = ackKey({ alertId, eventId });
|
||||||
|
const next = (await loadAcks()).filter(a => ackKey(a) !== key);
|
||||||
await writeAcks(next);
|
await writeAcks(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
* Send every queued ack. Stops on the first transport/server failure (the queue
|
||||||
* is retried later); drops entries on success and on 404 ("settled").
|
* 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();
|
const list = await loadAcks();
|
||||||
for (const ack of list) {
|
for (const ack of list) {
|
||||||
try {
|
try {
|
||||||
await ackAlarm(ack);
|
await ackAlarm({ ...ack, deviceId: ack.deviceId ?? fallbackDeviceId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isAlertGone(e)) return;
|
// 404: the alert is gone. 401 for a device token: the alert was destroyed
|
||||||
|
// by the very ack that preceded this retry, so the credential no longer
|
||||||
|
// resolves. Both are settled; retrying either is an infinite loop.
|
||||||
|
const settled = isAlertGone(e) || (!!ack.deviceToken && isDeviceTokenRefused(e));
|
||||||
|
if (!settled) return;
|
||||||
}
|
}
|
||||||
await dropAck(ack.alertId, ack.eventId);
|
await dropAck(ack.alertId, ack.eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
+212
-15
@@ -7,16 +7,32 @@ import { useAuth } from './AuthContext';
|
|||||||
import { useConnection } from './ConnectionContext';
|
import { useConnection } from './ConnectionContext';
|
||||||
import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket';
|
import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket';
|
||||||
import { closeLiveAlarm, openLiveAlarm } from '../navigationRef';
|
import { closeLiveAlarm, openLiveAlarm } from '../navigationRef';
|
||||||
import { stopForegroundService } from '../services/alarmNative';
|
import {
|
||||||
import { enqueueAck, flushAcks } from '../services/pendingAcks';
|
getPermissions,
|
||||||
|
isAlarmNativeAvailable,
|
||||||
|
requestPermission,
|
||||||
|
startForegroundService,
|
||||||
|
stopForegroundService,
|
||||||
|
type AlarmServiceState,
|
||||||
|
} from '../services/alarmNative';
|
||||||
|
import { enqueueAck, flushAcks, loadAcks } from '../services/pendingAcks';
|
||||||
|
import { alarmSocketUrl } from '../services/instanceUrl';
|
||||||
|
import { useListeningState } from './useListeningState';
|
||||||
import { DEVICE_ID_STORAGE_KEY, isValidDeviceId, newDeviceId } from '../lib/deviceId';
|
import { DEVICE_ID_STORAGE_KEY, isValidDeviceId, newDeviceId } from '../lib/deviceId';
|
||||||
import { isPastDeadline } from '../lib/time';
|
import { isPastDeadline } from '../lib/time';
|
||||||
|
import { createDismissals } from '../lib/dismissals';
|
||||||
|
|
||||||
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
|
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
|
||||||
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
|
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
|
||||||
|
|
||||||
/** Why an alert no longer exists. Each ending has its own explicit copy. */
|
/** 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 {
|
export interface LiveAlarmEvent {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
@@ -48,6 +64,12 @@ interface AlarmContextValue {
|
|||||||
acknowledgeAlarm: (alertId: string, eventId: string | undefined) => Promise<void>;
|
acknowledgeAlarm: (alertId: string, eventId: string | undefined) => Promise<void>;
|
||||||
lastStatus: api.AlarmStatus | null;
|
lastStatus: api.AlarmStatus | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Whether the background listener (the foreground service that holds the
|
||||||
|
* socket when the app is not focused) is actually running. `null` until read.
|
||||||
|
*/
|
||||||
|
backgroundListening: AlarmServiceState | null;
|
||||||
|
refreshBackgroundListening: () => Promise<void>;
|
||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
createAlert: (input: {
|
createAlert: (input: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -110,6 +132,41 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [endings, setEndings] = useState<Record<string, EndingReason>>({});
|
const [endings, setEndings] = useState<Record<string, EndingReason>>({});
|
||||||
const [lastEnding, setLastEnding] = useState<{ alertId: string; reason: EndingReason } | null>(null);
|
const [lastEnding, setLastEnding] = useState<{ alertId: string; reason: EndingReason } | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers this device has already dismissed, per alert.
|
||||||
|
*
|
||||||
|
* The service keeps an alert "triggered" until the owner disarms it or every
|
||||||
|
* subscriber acknowledges, so its status keeps reporting the same trigger
|
||||||
|
* after this device dismissed it. Without this memory the reconciliation
|
||||||
|
* effect raises the takeover again on the next poll — the alarm that nobody
|
||||||
|
* can keep silenced. `*` is the entry for a dismissal that could not name its
|
||||||
|
* event (native takeover launch): it suppresses the trigger current at
|
||||||
|
* dismissal time and is replaced by the concrete id as soon as one is known.
|
||||||
|
*/
|
||||||
|
const dismissals = useRef(createDismissals());
|
||||||
|
/**
|
||||||
|
* 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
|
// A per-install device id: stable across launches, unique per device. It
|
||||||
// addresses this device in the delivery ledger (the service hashes it), so it
|
// addresses this device in the delivery ledger (the service hashes it), so it
|
||||||
// must come from SecureRandom, not a timestamp-guessable generator.
|
// must come from SecureRandom, not a timestamp-guessable generator.
|
||||||
@@ -201,10 +258,27 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const endingFor = useCallback((alertId: string) => endings[alertId] ?? null, [endings]);
|
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 clearLastEnding = useCallback(() => setLastEnding(null), []);
|
||||||
|
|
||||||
const handleTrigger = useCallback(
|
const handleTrigger = useCallback(
|
||||||
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
|
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
|
||||||
|
// 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 = {
|
const event: LiveAlarmEvent = {
|
||||||
alertId: frame.alertId,
|
alertId: frame.alertId,
|
||||||
eventId: frame.eventId,
|
eventId: frame.eventId,
|
||||||
@@ -253,8 +327,11 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolved,
|
resolved,
|
||||||
alertId: activeAlert?.alertId ?? null,
|
alertId: activeAlert?.alertId ?? null,
|
||||||
subscribeToken: activeAlert?.subscribeToken ?? 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,
|
deviceId,
|
||||||
|
deviceToken: activeAlert?.deviceToken ?? null,
|
||||||
onTrigger: frame => handleTrigger(frame, true),
|
onTrigger: frame => handleTrigger(frame, true),
|
||||||
onDisarm: frame => handleDisarm(frame, true),
|
onDisarm: frame => handleDisarm(frame, true),
|
||||||
// A 404 from the check-in is terminal: the alert ended. There is nothing to
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [lastStatus, activeAlert?.alertId]);
|
}, [lastStatus, activeAlert?.alertId]);
|
||||||
|
|
||||||
|
const { state: backgroundListening, refresh: refreshBackgroundListening } = useListeningState();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arms the background listener automatically.
|
||||||
|
*
|
||||||
|
* The armed socket lives in a native foreground service, not in this JS
|
||||||
|
* runtime: Android freezes/tears down the app process when it is backgrounded,
|
||||||
|
* so a subscription whose service was never started rings only while the app
|
||||||
|
* is on screen — the exact "alarms don't trigger if the app isn't focused"
|
||||||
|
* failure. Joining (or launching with a subscription) is therefore enough:
|
||||||
|
* the first enabled subscription becomes the armed alert and the service is
|
||||||
|
* started without asking the user to find a toggle.
|
||||||
|
*
|
||||||
|
* It is idempotent and throttled: a running service for the same alert is left
|
||||||
|
* alone, and a failed start (a missing grant) is retried at most every five
|
||||||
|
* minutes, because the alert screen states what is missing rather than looping.
|
||||||
|
*/
|
||||||
|
const armedAttemptRef = useRef<{ alertId: string; at: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAlarmNativeAvailable) return;
|
||||||
|
// 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(
|
const createAlert = useCallback(
|
||||||
async (input: { name: string; message?: string; expiresInSeconds?: number }) => {
|
async (input: { name: string; message?: string; expiresInSeconds?: number }) => {
|
||||||
const created = await api.createAlert(input);
|
const created = await api.createAlert(input);
|
||||||
@@ -323,6 +482,9 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
name: input.alertId,
|
name: input.alertId,
|
||||||
status: 'armed' as AlertStatus,
|
status: 'armed' as AlertStatus,
|
||||||
subscribeToken: input.subscribeToken,
|
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',
|
role: 'subscriber',
|
||||||
// The wire's subscription view is `id`; the request field name is the
|
// The wire's subscription view is `id`; the request field name is the
|
||||||
// only place `subscriptionId` appears.
|
// only place `subscriptionId` appears.
|
||||||
@@ -344,7 +506,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const target = subscribedAlerts.find(a => a.alertId === alertId);
|
const target = subscribedAlerts.find(a => a.alertId === alertId);
|
||||||
if (target?.subscriptionId) {
|
if (target?.subscriptionId) {
|
||||||
try {
|
try {
|
||||||
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
|
await api.removeSubscription(
|
||||||
|
{ alertId, subscriptionId: target.subscriptionId },
|
||||||
|
target.deviceToken,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already gone is settled: the alert ended, so there is nothing to
|
// Already gone is settled: the alert ended, so there is nothing to
|
||||||
// leave on the server and nothing to retry.
|
// leave on the server and nothing to retry.
|
||||||
@@ -420,30 +585,60 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
*/
|
*/
|
||||||
const acknowledgeAlarm = useCallback(
|
const acknowledgeAlarm = useCallback(
|
||||||
async (alertId: string, eventId: string | undefined) => {
|
async (alertId: string, eventId: string | undefined) => {
|
||||||
if (!deviceId || !eventId) return;
|
// A dismissal whose event is unknown (the native takeover launch carries
|
||||||
await enqueueAck({ alertId, eventId, deviceId });
|
// only the alert) still names the trigger the status loop last reported;
|
||||||
await flushAcks();
|
// 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
|
// Retry the durable queue on every plausible reconnect point, plus a slow
|
||||||
// interval so a queue that could not flush at dismiss cannot linger.
|
// interval so a queue that could not flush at dismiss cannot linger.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
flushAcks().catch(() => {});
|
flushAcks(deviceId ?? undefined).catch(() => {});
|
||||||
const sub = AppState.addEventListener('change', next => {
|
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 () => {
|
return () => {
|
||||||
sub.remove();
|
sub.remove();
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [deviceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socketState === 'open') flushAcks().catch(() => {});
|
if (socketState === 'open') flushAcks(deviceId ?? undefined).catch(() => {});
|
||||||
}, [socketState]);
|
}, [socketState, deviceId]);
|
||||||
|
|
||||||
const value: AlarmContextValue = {
|
const value: AlarmContextValue = {
|
||||||
adminAlerts,
|
adminAlerts,
|
||||||
@@ -460,6 +655,8 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
acknowledgeAlarm,
|
acknowledgeAlarm,
|
||||||
lastStatus,
|
lastStatus,
|
||||||
refresh,
|
refresh,
|
||||||
|
backgroundListening,
|
||||||
|
refreshBackgroundListening,
|
||||||
reload,
|
reload,
|
||||||
createAlert,
|
createAlert,
|
||||||
joinAlert,
|
joinAlert,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface AuthContextValue {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
email: 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;
|
booting: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
@@ -95,11 +101,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} 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);
|
if (!cancelled) setBooting(false);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
// startAnonymousSession is stable (useCallback with no deps).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [persistTokens]);
|
}, [persistTokens]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
@@ -153,6 +166,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
token: session?.token ?? null,
|
token: session?.token ?? null,
|
||||||
username: session?.username ?? null,
|
username: session?.username ?? null,
|
||||||
email: session?.email ?? null,
|
email: session?.email ?? null,
|
||||||
|
anonymous: session === null,
|
||||||
booting,
|
booting,
|
||||||
busy,
|
busy,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -18,14 +18,18 @@ interface ThemeContextValue {
|
|||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(THEME_KEY);
|
const raw = await AsyncStorage.getItem(THEME_KEY);
|
||||||
const dark = raw === 'dark';
|
const dark = raw !== 'light';
|
||||||
if (!cancelled) setIsDark(dark);
|
if (!cancelled) setIsDark(dark);
|
||||||
Uniwind.setTheme(dark ? 'dark' : 'light');
|
Uniwind.setTheme(dark ? 'dark' : 'light');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+32
-11
@@ -7,7 +7,12 @@ import {
|
|||||||
type SocketState,
|
type SocketState,
|
||||||
} from '../services/alarmSocket';
|
} from '../services/alarmSocket';
|
||||||
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
|
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
|
||||||
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi';
|
import {
|
||||||
|
alarmStatus as fetchAlarmStatus,
|
||||||
|
isAlertGone,
|
||||||
|
isDeviceTokenRefused,
|
||||||
|
type AlarmStatus,
|
||||||
|
} from '../services/alarmApi';
|
||||||
|
|
||||||
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
|
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
|
||||||
|
|
||||||
@@ -24,6 +29,8 @@ export interface UseAlarmSocketOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** This device's ledger id (the service hashes it to address the debt). */
|
/** This device's ledger id (the service hashes it to address the debt). */
|
||||||
deviceId?: string | null;
|
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. */
|
/** The service answered 404: the alert is destroyed, there is nothing to ring. */
|
||||||
onGone?: () => void;
|
onGone?: () => void;
|
||||||
}
|
}
|
||||||
@@ -56,6 +63,7 @@ export function useAlarmSocket({
|
|||||||
onDisarm,
|
onDisarm,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
deviceId,
|
deviceId,
|
||||||
|
deviceToken,
|
||||||
onGone,
|
onGone,
|
||||||
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
||||||
const [socketState, setSocketState] = useState<SocketState>('idle');
|
const [socketState, setSocketState] = useState<SocketState>('idle');
|
||||||
@@ -70,8 +78,11 @@ export function useAlarmSocket({
|
|||||||
disarmRef.current = onDisarm;
|
disarmRef.current = onDisarm;
|
||||||
|
|
||||||
const url = useMemo(
|
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`. */
|
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
|
||||||
@@ -97,13 +108,14 @@ export function useAlarmSocket({
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!alertId) return;
|
if (!alertId) return;
|
||||||
try {
|
try {
|
||||||
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
|
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAlertGone(e)) onGoneRef.current?.();
|
// 404 = destroyed. 401 under a device token = the same thing: the alert
|
||||||
// A failed status read is not fatal — the socket is the primary path and
|
// was destroyed by the last ack, so the credential addresses nothing.
|
||||||
// the next poll retries.
|
// Either way the subscription is over; anything else is a transient read.
|
||||||
|
if (isAlertGone(e) || (!!deviceToken && isDeviceTokenRefused(e))) onGoneRef.current?.();
|
||||||
}
|
}
|
||||||
}, [alertId, applyStatus, deviceId]);
|
}, [alertId, applyStatus, deviceId, deviceToken]);
|
||||||
|
|
||||||
// AppState awareness: while backgrounded the JS socket is unreliable, so the
|
// AppState awareness: while backgrounded the JS socket is unreliable, so the
|
||||||
// poll cadence relaxes to the background value and the socket is left to the
|
// poll cadence relaxes to the background value and the socket is left to the
|
||||||
@@ -150,10 +162,10 @@ export function useAlarmSocket({
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
|
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken);
|
||||||
if (!cancelled) applyStatus(status);
|
if (!cancelled) applyStatus(status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAlertGone(e)) onGoneRef.current?.();
|
if (isAlertGone(e) || (!!deviceToken && isDeviceTokenRefused(e))) onGoneRef.current?.();
|
||||||
// ignore otherwise — polling must never surface a spinner
|
// ignore otherwise — polling must never surface a spinner
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -163,7 +175,16 @@ export function useAlarmSocket({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(handle);
|
clearInterval(handle);
|
||||||
};
|
};
|
||||||
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
|
}, [
|
||||||
|
enabled,
|
||||||
|
alertId,
|
||||||
|
appState,
|
||||||
|
foregroundPollMs,
|
||||||
|
backgroundPollMs,
|
||||||
|
applyStatus,
|
||||||
|
deviceId,
|
||||||
|
deviceToken,
|
||||||
|
]);
|
||||||
|
|
||||||
return { socketState, delivery, attempts, lastStatus, refresh };
|
return { socketState, delivery, attempts, lastStatus, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user