From a78c4cea14d9a89d441dca175478e3b97d4117cc Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:34:02 +0100 Subject: [PATCH] [feat](client): show what stands between this phone and a real alarm The blocking grants (exact alarms, full-screen intent, notifications) and the advisory battery exemption are surfaced as readiness, not as warnings that still allow arming. Listening state is read from the persisted native record on resume so a process-killed service cannot leave a stale armed claim on screen. --- __tests__/takeoverReadiness.render.test.tsx | 116 ++++++++++++++++ __tests__/takeoverReadiness.test.ts | 141 ++++++++++++++++++++ src/components/ListeningWarning.tsx | 73 ++++++++++ src/components/TakeoverReadiness.tsx | 97 ++++++++++++++ src/lib/takeoverReadiness.ts | 126 +++++++++++++++++ src/state/useAlarmPermissions.ts | 61 +++++++++ src/state/useListeningState.ts | 40 ++++++ 7 files changed, 654 insertions(+) create mode 100644 __tests__/takeoverReadiness.render.test.tsx create mode 100644 __tests__/takeoverReadiness.test.ts create mode 100644 src/components/ListeningWarning.tsx create mode 100644 src/components/TakeoverReadiness.tsx create mode 100644 src/lib/takeoverReadiness.ts create mode 100644 src/state/useAlarmPermissions.ts create mode 100644 src/state/useListeningState.ts diff --git a/__tests__/takeoverReadiness.render.test.tsx b/__tests__/takeoverReadiness.render.test.tsx new file mode 100644 index 0000000..305aa24 --- /dev/null +++ b/__tests__/takeoverReadiness.render.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import renderer, { act, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; +import { TakeoverReadiness } from '../src/components/TakeoverReadiness'; +import type { AlarmPermissions } from '../src/services/alarmNative'; + +// The banner's logic and copy are what matter here; the house visual primitives +// need their native-ish modules stubbed so the component can render under Jest. +jest.mock('heroui-native/text', () => ({ Text: require('react-native').Text })); +jest.mock('heroui-native/spinner', () => ({ Spinner: require('react-native').View })); +jest.mock('heroui-native/surface', () => ({ Surface: require('react-native').View })); +jest.mock('lucide-react-native', () => { + const ReactMock = require('react'); + const { View: RNView } = require('react-native'); + const Icon = () => ReactMock.createElement(RNView); + return { + BellOff: Icon, + CircleAlert: Icon, + CircleDashed: Icon, + ShieldAlert: Icon, + ShieldCheck: Icon, + Siren: Icon, + TriangleAlert: Icon, + WifiOff: Icon, + RefreshCw: Icon, + }; +}); + +const ALL_GRANTED: AlarmPermissions = { + notifications: true, + fullScreenIntent: true, + exactAlarm: true, + batteryUnrestricted: true, + canForceVolume: true, +}; + +function render(node: React.ReactElement): ReactTestRenderer { + let tree: ReactTestRenderer | undefined; + act(() => { + tree = renderer.create(node); + }); + return tree as ReactTestRenderer; +} + +/** Concatenates every rendered string so assertions can match the visible copy. */ +function flatText(node: ReactTestInstance | string): string { + if (typeof node === 'string') return node; + return node.children.map(child => flatText(child)).join(' '); +} + +describe('TakeoverReadiness banner', () => { + it('renders the explanation and a settings action when the takeover is not granted', () => { + const onFix = jest.fn(); + const tree = render( + , + ); + + const text = flatText(tree.root); + expect(text).toContain('This device cannot deliver a real alarm yet'); + expect(text).toContain( + 'Without this, this device cannot take over the screen when the alert fires.', + ); + expect(text).toContain('Open settings'); + // Blocking, not advisory: it must say the device is not armed meanwhile. + expect(text).toContain('this device is not armed and must not be relied on for alerts'); + + const action = tree.root.findAll( + node => node.props?.accessibilityLabel === 'Open settings — Full-screen takeover', + )[0]; + expect(action).toBeDefined(); + act(() => { + action.props.onPress(); + }); + expect(onFix).toHaveBeenCalledWith('fullScreenIntent'); + }); + + it('renders nothing once the takeover path is complete', () => { + const tree = render( + , + ); + expect(tree.toJSON()).toBeNull(); + }); + + it('renders nothing while permissions are still being read', () => { + const tree = render(); + expect(tree.toJSON()).toBeNull(); + }); + + it('renders nothing off-Android, where the grants do not apply', () => { + const tree = render( + , + ); + expect(tree.toJSON()).toBeNull(); + }); + + it('keeps the battery exemption advisory but still offers the fix', () => { + const tree = render( + , + ); + const text = flatText(tree.root); + expect(text).toContain('This device may miss a trigger in the background'); + expect(text).toContain('Background reliability'); + expect(text).not.toContain('cannot deliver a real alarm yet'); + }); +}); diff --git a/__tests__/takeoverReadiness.test.ts b/__tests__/takeoverReadiness.test.ts new file mode 100644 index 0000000..6043e0a --- /dev/null +++ b/__tests__/takeoverReadiness.test.ts @@ -0,0 +1,141 @@ +import type { AlarmPermissions } from '../src/services/alarmNative'; +import { + READINESS_COPY, + canDeliverRealAlarm, + deliverySummary, + takeoverReadiness, +} from '../src/lib/takeoverReadiness'; + +const ALL_GRANTED: AlarmPermissions = { + notifications: true, + fullScreenIntent: true, + exactAlarm: true, + batteryUnrestricted: true, + canForceVolume: true, +}; + +function perms(patch: Partial): AlarmPermissions { + return { ...ALL_GRANTED, ...patch }; +} + +describe('takeover readiness', () => { + it('is unknown (and renders nothing) while permissions are still loading', () => { + const readiness = takeoverReadiness(null); + expect(readiness.known).toBe(false); + expect(readiness.blocked).toBe(false); + expect(readiness.blockers).toHaveLength(0); + }); + + it('is complete when every grant is present', () => { + const readiness = takeoverReadiness(ALL_GRANTED); + expect(readiness.known).toBe(true); + expect(readiness.blocked).toBe(false); + expect(readiness.blockers).toHaveLength(0); + }); + + it('treats missing exact alarms as blocking, because the alarm clock is the delivery', () => { + const readiness = takeoverReadiness(perms({ exactAlarm: false })); + expect(readiness.blocked).toBe(true); + const exact = readiness.blockers.find(b => b.key === 'exactAlarm'); + expect(exact).toBeDefined(); + expect(exact?.blocking).toBe(true); + expect(exact?.body).toContain('cannot fire a real alarm'); + }); + + it('treats a missing full-screen intent as blocking, never as a notification fallback', () => { + const readiness = takeoverReadiness(perms({ fullScreenIntent: false })); + expect(readiness.blocked).toBe(true); + const fsi = readiness.blockers.find(b => b.key === 'fullScreenIntent'); + expect(fsi).toBeDefined(); + expect(fsi?.blocking).toBe(true); + expect(fsi?.body).toContain('cannot take over the screen'); + // The old design promised a notification instead. That outcome is gone. + expect(fsi?.body).not.toMatch(/instead of taking over/i); + expect(fsi?.body).toContain('not usable for alerts'); + expect(fsi?.actionLabel).toBe('Open settings'); + }); + + it('treats missing notifications as blocking (the foreground service needs one)', () => { + const readiness = takeoverReadiness(perms({ notifications: false })); + expect(readiness.blocked).toBe(true); + const notifications = readiness.blockers.find(b => b.key === 'notifications'); + expect(notifications?.blocking).toBe(true); + expect(notifications?.body).toContain('cannot keep listening while the app is closed'); + }); + + it('keeps the battery exemption advisory, not blocking', () => { + const readiness = takeoverReadiness(perms({ batteryUnrestricted: false })); + expect(readiness.known).toBe(true); + expect(readiness.blocked).toBe(false); + const battery = readiness.blockers.find(b => b.key === 'battery'); + expect(battery?.blocking).toBe(false); + expect(battery?.body).toBe(READINESS_COPY.battery.body); + }); + + it('reports every blocker in priority order when nothing is granted', () => { + const readiness = takeoverReadiness( + perms({ + exactAlarm: false, + notifications: false, + fullScreenIntent: false, + batteryUnrestricted: false, + }), + ); + expect(readiness.blocked).toBe(true); + expect(readiness.blockers.map(b => b.key)).toEqual([ + 'exactAlarm', + 'fullScreenIntent', + 'notifications', + 'battery', + ]); + }); +}); + +describe('canDeliverRealAlarm', () => { + it('is true only when every blocking grant is present', () => { + expect(canDeliverRealAlarm(ALL_GRANTED)).toBe(true); + expect(canDeliverRealAlarm(perms({ exactAlarm: false }))).toBe(false); + expect(canDeliverRealAlarm(perms({ fullScreenIntent: false }))).toBe(false); + expect(canDeliverRealAlarm(perms({ notifications: false }))).toBe(false); + // Advice, not a gate. + expect(canDeliverRealAlarm(perms({ batteryUnrestricted: false }))).toBe(true); + expect(canDeliverRealAlarm(null)).toBe(false); + }); +}); + +describe('delivery summary', () => { + it('never claims a real alarm while the takeover is blocked', () => { + const summary = deliverySummary(perms({ fullScreenIntent: false })); + expect(summary).toBe( + 'This device cannot take over the screen — the full-screen takeover is not allowed.', + ); + expect(summary).not.toMatch(/maximum volume/); + expect(summary).not.toMatch(/notification/i); + }); + + it('names the exact-alarm gap first, since that is the delivery mechanism', () => { + expect(deliverySummary(perms({ exactAlarm: false }))).toBe( + 'This device cannot fire a real alarm — exact alarms are not allowed.', + ); + }); + + it('says it cannot keep listening without notifications', () => { + expect(deliverySummary(perms({ notifications: false }))).toBe( + 'This device cannot keep listening while the app is closed — notifications are not allowed.', + ); + }); + + it('claims a real alarm only when every blocking grant is present', () => { + expect(deliverySummary(ALL_GRANTED)).toBe( + 'This device fires a real alarm at maximum volume when the owner triggers the alert.', + ); + // Battery is advisory: it does not change the promise. + expect(deliverySummary(perms({ batteryUnrestricted: false }))).toBe( + 'This device fires a real alarm at maximum volume when the owner triggers the alert.', + ); + }); + + it('does not claim readiness while permissions are unread', () => { + expect(deliverySummary(null)).toMatch(/Checking/); + }); +}); diff --git a/src/components/ListeningWarning.tsx b/src/components/ListeningWarning.tsx new file mode 100644 index 0000000..6e5ce67 --- /dev/null +++ b/src/components/ListeningWarning.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { View } from 'react-native'; +import { TriangleAlert } from 'lucide-react-native'; +import { AppText, Button, Card, IconLine } from './ui'; +import type { AlarmServiceState } from '../services/alarmNative'; +import { + space3, + text, + textSecondary, + typeBodyStrong, + typeCaption, + typeHeading, + warn, +} from '../theme/tokens'; + +export interface ListeningWarningProps { + state: AlarmServiceState | null; + onStart: () => void; + busy?: boolean; +} + +/** + * The honest answer to "is this device actually armed?". + * + * Two states must never be silent: + * - `stopped-timeout`: Android stopped the service without the user asking + * (the `dataSync` cap on API 35+, or a future platform limit). Reopening the + * app does not restore it by itself, so the screen has to offer the restart. + * - `delivery-failed`: the socket delivered a trigger that could not be turned + * into a real alarm (exact alarms revoked at runtime, `setAlarmClock` + * refused). There is deliberately no notification fallback, so this is the + * only place the user can find out. + * + * Ordinary `idle`/`stopped` is not an error and renders nothing: the card below + * already says what "only while the app is open" means. + */ +export function ListeningWarning({ state, onStart, busy }: ListeningWarningProps) { + if (!state) return null; + const timedOut = state.state === 'stopped-timeout'; + const deliveryFailed = state.state === 'delivery-failed'; + if (!timedOut && !deliveryFailed) return null; + + return ( + + + + + {timedOut ? 'This device stopped listening' : 'The last alarm could not fire'} + + + + {timedOut + ? "Android's background limits were reached, so this device is not receiving triggers." + : 'Android refused the alarm clock — exact alarms may have been revoked.'} + + + Start listening again to re-arm this device. While it was down a trigger is reported when + this device checks in again, so it is late rather than lost — but it does not ring at the + moment it fires. Notifications cannot replace an alarm, so nothing was delivered in its + place. + +