[fix](client): arm in the background, make dismiss final, and leave the takeover

The reported failures are one missing acknowledgement and one missing service
start.

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

tsc clean, 144 tests (15 new: dismissal memory, background arming, dead
credential, unnamed ack queue).
This commit is contained in:
2026-10-19 19:34:36 +01:00
parent 31cff32824
commit 98625adb25
10 changed files with 556 additions and 38 deletions
+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');
});
});