Compare commits

..
10 Commits
Author SHA1 Message Date
Admin 0771a69d7d Acknowledge unsubscribes, and nothing re-rings after a restart
Two field reports, one chain: a dismissed alarm came back. Measured on the
emulator, three separate causes, all fixed here.

1. The alarm-clock entry launched an activity. On Android 14+ that launch is
   refused from the background and then *deferred*, so the window surfaced
   later — after the alarm had been acknowledged — and the siren played again
   for a delivered alert. The entry is now a broadcast to AlarmWakeReceiver,
   which only wakes the process and re-posts the takeover notification, and
   ignores an entry that outlived its arming. The one activity PendingIntent
   left is the alarm-clock show intent, which only the user can invoke.

2. A dismissal could be lost. `acknowledgeAlarm` returned early when the
   device identity had not loaded, and the queue's entry required a deviceId,
   so the ack was silently dropped and the ledger still owed the alarm. The
   queue accepts an entry without one (measured: the service takes a
   device-token ack with no deviceId) and the flush resolves the identity.
   Nothing raises — and the armed service does not start listening — until the
   queued dismissals have been read, so a restart cannot ring before the seed.

3. Acknowledging now unsubscribes the device (owner ruling: a dismissal is
   leaving). The ack settles the ledger first, then the subscription row goes;
   the takeover closes itself and Home reports "Acknowledged — this device
   unsubscribed". A leftover subscription was a live address that kept ringing
   on every launch.

Also: an alarm is only sounded for an alert this device still follows (or a
trigger event that actually arrived), and the takeover never renders the raw
alert id as its headline — that fallback is what made a stale launch look like
an alarm for "dbf1a4c2-…".

Verified on the emulator: join → trigger → acknowledge → subscription row gone,
alert delivered, app returns Home by itself; force-stop and reopen is silent
for the whole session (no audio, no notification, no takeover).
2026-10-20 00:48:33 +01:00
Admin 06afd92647 Reach the screen in the background, and leave it again
Measured on an Android 15 emulator with the app backgrounded and the display
asleep: the siren played, the platform refused the takeover
("Background activity launch blocked! … BAL_BLOCK"), and the alarm was
destroyed mid-ring by its own receipt ack.

Takeover:
- the foreground service posts an alarm_firing notification whose
  full-screen intent raises AlarmActivity — the only background launch
  Android 14+ permits. Verified with the display asleep: wakefulness
  Asleep → Awake, topResumedActivity=.AlarmActivity, the FSI on the record.
- the cancel lives on the four real silences (disarm, stop, JS ack, activity
  destroy), not in AlarmPlayer.stop: play() calls stop() first, which
  retracted the notification ~120 ms after posting it.
- "Back to alerts" now finishes the alarm window (AlarmModule
  finishTakeover). A route pop is not an activity: the window stayed on top
  for good with show-when-locked, keep-screen-on and no Recents entry — the
  reported "no way back to the main menu".

The native-delivery contract test said "no notification ever announces an
alarm"; that was falsified on device, so it now pins one FSI notification and
a silent firing channel instead.

Verified end to end on the emulator (two runs, one with the screen off):
join without an account, armed service, background trigger → wake + takeover
+ siren, acknowledge → silence, notification retracted, alert delivered,
takeover closed by itself, Home reachable, reopening silent.
2026-10-19 20:40:38 +01:00
Admin 98625adb25 [fix](client): arm in the background, make dismiss final, and leave the takeover
The reported failures are one missing acknowledgement and one missing service
start.

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

tsc clean, 144 tests (15 new: dismissal memory, background arming, dead
credential, unnamed ack queue).
2026-10-19 19:34:36 +01:00
Admin 31cff32824 [feat](client): join without an account; the token the join returns is the identity
There is no anonymous session to start and nothing to mint: the app comes up on
Home with no account, and the first QR scan performs the unauthenticated
joinAlert, which returns a deviceToken the client stores with the alert. That
token — not a user, not a session — is what the subscriber calls carry as
X-Device-Token, and what the socket puts in ?deviceToken=.

- apiClient takes an optional device token per request; when present it is sent
  instead of the access token, because it is the narrower credential
- alarmApi.joinAlert returns it; getAlert/alarmStatus/ackAlarm/
  removeSubscription/qrImage and the socket URL accept it
- the alert record keeps the token beside its subscribe token, so a queued ack
  for an alert that is gone still resolves the right device
- login is now a destination, never a wall: without a session the app is the
  subscriber view, and the login screen's 'continue without an account' button
  is gone because there is nothing to continue from

tsc clean, 129 tests (8 new ones pin the header transport and the
no-access-token join).
2026-10-19 18:41:49 +01:00
Admin 020a90f0d3 [fix](client): apply the window insets so nothing draws under the system bars
React Native has Flutter's SafeArea under another name: react-native-safe-area-
context. The app mounted SafeAreaProvider but never consumed a single inset, so
on Android 15+ — where targetSdk 36 forces edge-to-edge and the window extends
behind both system bars — the Home header's settings gear sat underneath the
notification bar and could not be tapped.

A single SafeAreaFrame pads the tree by all four insets once, above the
navigator, so every present and future screen inherits the safe area instead of
each one re-deriving it.
2026-10-19 16:03:17 +01:00
Admin b32df04ab9 [fix](client): scan QR codes with MLKit's barcode output, not the iOS-only object output
Tapping "Scan a QR code" crashed the app on Android:

    com.facebook.react.common.JavascriptException: Error:
    CameraFactory.createObjectOutput(...): java.lang.Error:
    CameraObjectOutput is not available on Android!

`useObjectOutput` is Apple Vision only — the comment claiming v5 ships a
cross-platform code scanner in core was wrong, and the native factory throws as
soon as the screen mounts. The Barcode Scanner output is the cross-platform one
(MLKit on Android, Vision on iOS); it is attached to the same `<Camera>` via
`outputs`, scans `qr-code` only, and keeps the hand-paste fallback. The scan
callback now takes the barcode values directly instead of the object-output
shape.
2026-10-19 15:18:31 +01:00
Admin b5c1710b99 [fix](client): sign in with the email the hub identifies, not a username
Sign-in labels the single identity field 'Username', but `login()` sends it as
`email` and dcntrlized-auth resolves accounts by the email column. Typing the
username of a seeded account ('admin') therefore fails with 'invalid
credentials' while the password is correct. The field is now labelled Email,
with an email keyboard and a matching placeholder, in sign-in mode only —
registration still has a separate username and email.
2026-10-19 15:03:29 +01:00
Admin 6cd6bf3ed3 [fix](client): give the navigator a dark theme and a light status bar
React Navigation paints every scene with its own theme background and defaults
to the light one, so a screen that did not paint `bg` itself sat on white while
the text tokens stayed light. Root-level background + a matching navigator theme
means no screen can fall through to a light surface again, and the status bar
icons are legible on the dark palette.
2026-10-19 09:28:20 +01:00
Admin 1f62137754 [fix](client): move the instance address field above the notice that points at it
The 'No instance configured' row said 'Set your instance address above' while
the field lived two cards further down, so a fresh install had an instruction
and no visible way to follow it. The Connection card is now the first card.
2026-10-19 09:20:42 +01:00
Admin ff07df7ae6 [fix](client): paint the app background and default the theme to dark
Every screen but Login set `backgroundColor: bg` itself, so the login page and
the boot loader fell through to the platform's white surface while the text
tokens stayed light — white on light, unreadable. The palette in
`theme/tokens.ts` is fixed dark by construction, and `Uniwind.setTheme` defaulted
to light, so heroUI's own surfaces were light too. Paint the background at the
root, on the login screen and on the loader, and default the theme to dark.
2026-10-19 09:20:42 +01:00
31 changed files with 1542 additions and 208 deletions
+58 -9
View File
@@ -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
// (rev 10). Login is an explicit destination, never a wall.
navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
} else {
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>
<SafeAreaFrame>
<ThemeProvider> <ThemeProvider>
<ConnectionProvider> <ConnectionProvider>
<AuthProvider> <AuthProvider>
<NavigationContainer ref={navigationRef}> <NavigationContainer ref={navigationRef} theme={NAV_THEME}>
<Root /> <Root />
</NavigationContainer> </NavigationContainer>
</AuthProvider> </AuthProvider>
</ConnectionProvider> </ConnectionProvider>
</ThemeProvider> </ThemeProvider>
</SafeAreaFrame>
</SafeAreaProvider> </SafeAreaProvider>
</HeroUINativeProviderRaw> </HeroUINativeProviderRaw>
</GestureHandlerRootView> </GestureHandlerRootView>
+74
View File
@@ -0,0 +1,74 @@
/**
* A subscription must ring while the app is not focused.
*
* On Android the JS runtime (and therefore the JS socket) stops when the app is
* backgrounded; the only supported delivery path is the native foreground
* service, which holds its own WebSocket. Nothing in the app used to start it —
* the toggle lived on one screen — so a plain subscriber only ever rang while
* the app was open. These assertions pin the automatic arming and the facts it
* depends on (a credential the service can use, and its notification).
*/
export {};
declare const __dirname: string;
const fs = require('fs') as { readFileSync(p: string, enc: string): string };
const path = require('path') as { join(...parts: string[]): string };
const context = fs.readFileSync(
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
'utf8',
);
const moduleKt = fs.readFileSync(
path.join(
__dirname,
'..',
'android',
'app',
'src',
'main',
'java',
'com',
'dcntrlized',
'alarm',
'AlarmModule.kt',
),
'utf8',
);
const home = fs.readFileSync(
path.join(__dirname, '..', 'src', 'screens', 'HomeScreen.tsx'),
'utf8',
);
describe('background arming', () => {
it('starts the foreground service from the provider, not from one screen', () => {
expect(context).toContain('startForegroundService(');
expect(context).toContain('alarmSocketUrl(');
});
it('arms the alert the provider is actively listening to', () => {
const at = context.indexOf('const armedAttemptRef');
expect(at).toBeGreaterThan(-1);
const body = context.slice(at, at + 2200);
expect(body).toContain('activeAlert.alertId');
expect(body).toContain('activeAlert.subscribeToken');
// The service cannot refresh a session, so only a device token works here.
expect(body).toContain('if (!activeAlert.deviceToken) return;');
expect(body).toContain('activeAlert.deviceToken');
});
it('asks for the notification a foreground service cannot run without', () => {
expect(context).toContain('requestPermission(');
expect(context).toContain("'notifications'");
// The native side refuses to start a service whose notification cannot show.
expect(moduleKt).toContain('notifications-not-granted');
});
it('does not retry a refused start in a tight loop', () => {
expect(context).toMatch(/300_000/);
});
it('reports whether the background listener is running, and does not pretend', () => {
expect(home).toContain('backgroundListening');
expect(home).toContain('triggers arrive even when this app is closed');
});
});
+137
View File
@@ -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' });
});
});
+186
View File
@@ -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);
});
});
+52 -9
View File
@@ -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', () => {
+88
View File
@@ -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.
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+2 -1
View File
@@ -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)',
+86
View File
@@ -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) ?? [])],
};
}
+15 -4
View File
@@ -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);
+50 -8
View File
@@ -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,6 +279,9 @@ export default function HomeScreen() {
variant="secondary" variant="secondary"
onPress={() => navigation.navigate('Scanner')} onPress={() => navigation.navigate('Scanner')}
/> />
{/* A subscriber has no session to end: the device token lives with the
subscription, so there is nothing to sign out of (rev 10). */}
{anonymous ? null : (
<Button <Button
label="Sign out" label="Sign out"
variant="ghost" variant="ghost"
@@ -252,6 +293,7 @@ export default function HomeScreen() {
]) ])
} }
/> />
)}
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
+89 -13
View File
@@ -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' },
}); });
+15 -5
View File
@@ -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>
+25 -15
View File
@@ -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 handleScanValue = useCallback(
(value: string) => {
const parsed = parseQrPayload(value); const parsed = parseQrPayload(value);
if (parsed.ok) { if (parsed.ok) {
if (targetAlertId && parsed.payload.alertId !== targetAlertId) { if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
setError('That code is for a different alert.'); setError('That code is for a different alert.');
continue; return;
} }
complete(parsed.payload.alertId, parsed.payload.subscribeToken); complete(parsed.payload.alertId, parsed.payload.subscribeToken);
} else { } else {
setError(qrErrorMessage(parsed.error)); setError(qrErrorMessage(parsed.error));
} }
return;
}
}, },
[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}>
+54 -43
View File
@@ -103,8 +103,15 @@ export default function SettingsScreen() {
const listeningFailed = listening?.state === 'delivery-failed'; const listeningFailed = listening?.state === 'delivery-failed';
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null; const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
// The device token is the subscriber's credential (rev 10): without it this
// URL would be refused for an account-less device.
const wsUrl = resolved && activeAlert const wsUrl = resolved && activeAlert
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '') ? alarmSocketUrl(
resolved,
activeAlert.alertId,
activeAlert.subscribeToken ?? '',
activeAlert.deviceToken,
)
: null; : null;
const toggleService = async () => { const toggleService = async () => {
@@ -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
View File
@@ -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 ──────────────────────────────────────────────────────────────────
+20
View File
@@ -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.
+25 -9
View File
@@ -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);
} }
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+14
View File
@@ -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,
+6 -2
View File
@@ -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
View File
@@ -7,7 +7,12 @@ import {
type SocketState, type SocketState,
} from '../services/alarmSocket'; } from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection'; import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi'; import {
alarmStatus as fetchAlarmStatus,
isAlertGone,
isDeviceTokenRefused,
type AlarmStatus,
} from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable'; export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
@@ -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 };
} }