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. + +