From 98625adb251fc1a898aa8b9ae6065feb15ef77ae Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 19:34:36 +0100 Subject: [PATCH] [fix](client): arm in the background, make dismiss final, and leave the takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- __tests__/backgroundArm.test.ts | 74 ++++++++++++++++ __tests__/dismissalReplay.test.ts | 124 ++++++++++++++++++++++++++ src/lib/dismissals.ts | 81 +++++++++++++++++ src/screens/HomeScreen.tsx | 16 ++-- src/screens/LiveAlarmScreen.tsx | 73 +++++++++++++--- src/screens/SettingsScreen.tsx | 9 +- src/services/alarmApi.ts | 19 +++- src/services/pendingAcks.ts | 30 +++++-- src/state/AlarmContext.tsx | 139 ++++++++++++++++++++++++++++-- src/state/useAlarmSocket.ts | 29 +++++-- 10 files changed, 556 insertions(+), 38 deletions(-) create mode 100644 __tests__/backgroundArm.test.ts create mode 100644 __tests__/dismissalReplay.test.ts create mode 100644 src/lib/dismissals.ts diff --git a/__tests__/backgroundArm.test.ts b/__tests__/backgroundArm.test.ts new file mode 100644 index 0000000..ba0be6a --- /dev/null +++ b/__tests__/backgroundArm.test.ts @@ -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'); + }); +}); diff --git a/__tests__/dismissalReplay.test.ts b/__tests__/dismissalReplay.test.ts new file mode 100644 index 0000000..f7091b9 --- /dev/null +++ b/__tests__/dismissalReplay.test.ts @@ -0,0 +1,124 @@ +/** + * A dismissed alarm must stay dismissed. + * + * The service keeps an alert "triggered" until the owner disarms it or every + * subscriber acknowledges, so every status poll, check-in and socket replay keeps + * offering the same trigger to a device that already dismissed it. These tests + * pin the client's memory of dismissals (what is suppressed, and what must still + * ring), plus the two wiring facts that make dismissal reach the service at all. + */ +// The ack queue module owns AsyncStorage at module load; the storage package is +// ESM-only and this is a headless run, so the tiny surface mergeAck needs is +// stubbed (nothing here reads or writes real storage). +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + }, +})); + +import { createDismissals } from '../src/lib/dismissals'; +import { isDeviceTokenRefused, isAlertGone } from '../src/services/alarmApi'; +import { ApiError } from '../src/services/apiClient'; +import { mergeAck, type PendingAck } from '../src/services/pendingAcks'; + +declare const __dirname: string; +const fs = require('fs') as { readFileSync(p: string, enc: string): string }; +const path = require('path') as { join(...parts: string[]): string }; + +const EVENT = '11111111-1111-1111-1111-111111111111'; +const NEXT = '22222222-2222-2222-2222-222222222222'; + +describe('dismissal memory', () => { + it('suppresses the event this device dismissed and nothing else', () => { + const d = createDismissals(); + d.mark('a', EVENT); + expect(d.suppress('a', EVENT, false)).toBe(true); + expect(d.suppress('a', NEXT, false)).toBe(false); + expect(d.suppress('b', EVENT, false)).toBe(false); + }); + + it('pins an unnamed dismissal to the trigger it actually covered', () => { + const d = createDismissals(); + d.mark('a'); // native launch: the event id was never known + expect(d.suppress('a', EVENT, false)).toBe(true); + expect(d.remembered('a')).toEqual([EVENT]); + // Once pinned, a later trigger is news again. + expect(d.suppress('a', NEXT, false)).toBe(false); + }); + + it('keeps suppressing while the event id is still unknown', () => { + const d = createDismissals(); + d.mark('a'); + expect(d.suppress('a', undefined, false)).toBe(true); + expect(d.suppress('a', undefined, false)).toBe(true); + }); + + it('lets a live socket frame clear the memory — a new trigger must ring', () => { + const d = createDismissals(); + d.mark('a', EVENT); + expect(d.suppress('a', EVENT, true)).toBe(false); + expect(d.suppress('a', EVENT, false)).toBe(false); + }); + + it('seeds from the durable ack queue so a replay before the flush is silent', () => { + const d = createDismissals(); + d.seed([ + { alertId: 'a', eventId: EVENT }, + { alertId: 'b' }, + ]); + expect(d.suppress('a', EVENT, false)).toBe(true); + expect(d.suppress('b', NEXT, false)).toBe(true); + }); +}); + +describe('dismissal reaches the service', () => { + const screen = fs.readFileSync( + path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'), + 'utf8', + ); + const context = fs.readFileSync( + path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'), + 'utf8', + ); + + it('the takeover acks on dismiss, before it forgets the event', () => { + const at = screen.indexOf('const ack = useCallback'); + const body = screen.slice(at, at + 1400); + expect(body).toContain('acknowledgeAlarm('); + expect(body.indexOf('acknowledgeAlarm(')).toBeLessThan(body.indexOf('clearLiveEvent()')); + }); + + it('the ack is queued durably, not fired and forgotten', () => { + expect(context).toContain('enqueueAck('); + expect(context).toContain('flushAcks('); + }); + + it('an unnamed dismissal is queued without an eventId', () => { + // The service then clears the subscription's whole outstanding set. + expect(context).toContain('eventId: known'); + expect(context).toContain("const known = eventId ?? lastStatus?.eventId ?? undefined"); + }); +}); + +describe('a dead device credential is terminal, not retryable', () => { + it('401 from a device-token call means the subscription is gone', () => { + expect(isDeviceTokenRefused(new ApiError(401, 'invalid device token', 'ERR_UNAUTHORIZED'))).toBe( + true, + ); + expect(isAlertGone(new ApiError(404, 'gone', 'ERR_NOT_FOUND'))).toBe(true); + expect(isDeviceTokenRefused(new ApiError(500, 'boom'))).toBe(false); + }); +}); + +describe('ack queue without an event id', () => { + it('merges an unnamed dismissal with itself only', () => { + const unnamed: PendingAck = { alertId: 'a', deviceId: 'dev' }; + const named: PendingAck = { alertId: 'a', eventId: EVENT, deviceId: 'dev' }; + const once = mergeAck([], unnamed); + expect(mergeAck(once, { ...unnamed })).toHaveLength(1); + expect(mergeAck(once, named)).toHaveLength(2); + }); +}); diff --git a/src/lib/dismissals.ts b/src/lib/dismissals.ts new file mode 100644 index 0000000..988f73c --- /dev/null +++ b/src/lib/dismissals.ts @@ -0,0 +1,81 @@ +/** + * What this device has already dismissed, per alert. + * + * The service keeps an alert "triggered" until the owner disarms it or every + * subscriber acknowledges, so `alarmStatus` keeps reporting the same trigger + * after this device dismissed it — and the check-in/replay paths keep offering + * it too. Raising the takeover from those reports again is the alarm that + * cannot be kept silenced, so the client has to remember what it dismissed: + * + * - a dismissal that named its event suppresses exactly that event; + * - a dismissal that could not name one (the native takeover launch carries + * only the alert) is recorded as `*`: it suppresses the trigger current at + * dismissal time, and is pinned to that concrete event as soon as a status + * report reveals it, so a later trigger is not swallowed; + * - a live socket frame is a genuinely new trigger from the owner (replays of + * acknowledged deliveries never reach the client), so it clears the memory + * for that alert instead of being suppressed. + * + * Pure and in-memory on purpose: the durable part of a dismissal is the ack + * queue, and correctness must not depend on storage having finished writing. + */ +export interface DismissalSource { + alertId: string; + eventId?: string; +} + +export interface DismissalMemory { + mark(alertId: string, eventId?: string | null): void; + /** Seed from a persisted ack queue so a replay before the flush cannot ring. */ + seed(list: readonly DismissalSource[]): void; + /** + * Should a trigger raise be suppressed? + * + * `live` frames are new triggers and clear the memory; status/check-in + * raises are suppressed when this device already dismissed that event. + */ + suppress(alertId: string, eventId: string | undefined, live: boolean): boolean; + forget(alertId: string): void; + /** Test/debug view of what is remembered for an alert. */ + remembered(alertId: string): string[]; +} + +export function createDismissals(): DismissalMemory { + const seen = new Map>(); + + const mark = (alertId: string, eventId?: string | null) => { + const set = seen.get(alertId) ?? new Set(); + set.add(eventId || '*'); + seen.set(alertId, set); + }; + + const forget = (alertId: string) => { + seen.delete(alertId); + }; + + const suppress = (alertId: string, eventId: string | undefined, live: boolean) => { + if (live) { + forget(alertId); + return false; + } + const set = seen.get(alertId); + if (!set) return false; + if (eventId && set.has(eventId)) return true; + if (!set.has('*')) return false; + // Pin the anonymous dismissal to the event it actually covered; a later + // trigger carries a different id and must ring. + if (eventId) { + set.delete('*'); + set.add(eventId); + } + return true; + }; + + return { + mark, + forget, + suppress, + seed: list => list.forEach(a => mark(a.alertId, a.eventId)), + remembered: alertId => [...(seen.get(alertId) ?? [])], + }; +} diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 10ab6ff..84397d2 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -79,6 +79,7 @@ export default function HomeScreen() { liveEvent, delivery, socketState, + backgroundListening, lastEnding, clearLastEnding, } = useAlarm(); @@ -246,12 +247,17 @@ export default function HomeScreen() { /> ))} {tab === 'subscribed' ? ( + // Delivery truth, most durable fact first: the foreground service + // is what keeps ringing while the app is closed. The JS socket only + // covers the on-screen case and must not be presented as coverage. - {socketState === 'open' - ? 'Live delivery connected — triggers arrive over the instance socket.' - : delivery === 'socket' - ? `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.` - : 'Not listening — set your instance address in Settings.'} + {backgroundListening?.state === 'running' + ? 'Listening in the background — triggers arrive even when this app is closed.' + : delivery !== 'socket' + ? 'Not listening — set your instance address in Settings.' + : socketState === 'open' + ? 'Listening while this app is open only — background listening is off. Open the alert to see what is missing.' + : `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.`} ) : null} diff --git a/src/screens/LiveAlarmScreen.tsx b/src/screens/LiveAlarmScreen.tsx index 86d6967..6a24167 100644 --- a/src/screens/LiveAlarmScreen.tsx +++ b/src/screens/LiveAlarmScreen.tsx @@ -10,7 +10,7 @@ import { } from 'react-native'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { BellOff, OctagonX, Siren, VolumeX } from 'lucide-react-native'; +import { BellOff, ChevronLeft, OctagonX, Siren, VolumeX } from 'lucide-react-native'; import { useAlarm } from '../state/AlarmContext'; import { useAuth } from '../state/AuthContext'; import { AppText } from '../components/ui'; @@ -21,6 +21,7 @@ import type { RootStackParamList } from '../navigation'; import { motionSiren, radiusFull, + space1, space2, space3, space4, @@ -61,8 +62,16 @@ const STOP_LABEL = 'Stop for everyone'; export default function LiveAlarmScreen() { const navigation = useNavigation