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.
+
+
+
+
+ );
+}
diff --git a/src/components/TakeoverReadiness.tsx b/src/components/TakeoverReadiness.tsx
new file mode 100644
index 0000000..6128cf5
--- /dev/null
+++ b/src/components/TakeoverReadiness.tsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import { View } from 'react-native';
+import { ShieldAlert } from 'lucide-react-native';
+import {
+ AppText,
+ Button,
+ Card,
+ IconLine,
+} from './ui';
+import { isAlarmNativeAvailable, type AlarmPermissions } from '../services/alarmNative';
+import {
+ takeoverReadiness,
+ type ReadinessKey,
+} from '../lib/takeoverReadiness';
+import {
+ space3,
+ space4,
+ stateArmed,
+ text,
+ textSecondary,
+ typeBodyStrong,
+ typeCaption,
+ typeHeading,
+ warn,
+} from '../theme/tokens';
+
+export interface TakeoverReadinessProps {
+ perms: AlarmPermissions | null;
+ onFix: (key: ReadinessKey) => void;
+ /**
+ * Whether the Android primitives exist at all. Defaults to the real check;
+ * tests pass `true` explicitly so they exercise the Android path.
+ */
+ nativeAvailable?: boolean;
+ /** Heading override, e.g. on the alert screen we name the consequence. */
+ title?: string;
+}
+
+/**
+ * First-class "this device is not ready to alert you yet" surface.
+ *
+ * Android 14+ treats `USE_FULL_SCREEN_INTENT` as a restricted permission: it is
+ * auto-granted only to apps the system classifies as calling/alarm apps and
+ * revoked by default for everyone else. Notifications and the battery exemption
+ * fail the same way — silently. So the grant is explained here, where the user
+ * is looking at the alert they just subscribed to, instead of being buried in
+ * Settings.
+ *
+ * Renders nothing once every grant is in place (and while permissions are still
+ * being read — guessing would be worse than a beat of blank space).
+ */
+export function TakeoverReadiness({
+ perms,
+ onFix,
+ nativeAvailable,
+ title,
+}: TakeoverReadinessProps) {
+ const available = nativeAvailable ?? isAlarmNativeAvailable;
+ const readiness = takeoverReadiness(perms);
+ if (!available || !readiness.known || readiness.blockers.length === 0) return null;
+
+ const heading =
+ title ??
+ (readiness.blocked
+ ? 'This device cannot deliver a real alarm yet'
+ : 'This device may miss a trigger in the background');
+
+ return (
+
+
+
+ {heading}
+
+ {readiness.blockers.map(blocker => (
+
+
+ {blocker.title}
+ {blocker.body}
+
+
+ ))}
+ {readiness.blocked ? (
+
+ Until this is fixed, this device is not armed and must not be relied on for alerts.
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/lib/takeoverReadiness.ts b/src/lib/takeoverReadiness.ts
new file mode 100644
index 0000000..0b46a5b
--- /dev/null
+++ b/src/lib/takeoverReadiness.ts
@@ -0,0 +1,126 @@
+import type { AlarmPermissions } from '../services/alarmNative';
+
+/**
+ * What stands between this device and **delivering a real alarm**.
+ *
+ * Product ruling: a notification is never a delivery path. An alert must ring
+ * and take over the screen; the only notification the app may post about a
+ * firing alarm is the volume warning ("your alarm volume is not at maximum").
+ * So the grants below are not "nice to have, otherwise you get a notification"
+ * — they are **blocking**: while one is missing, this device cannot deliver a
+ * real alarm and must not present itself as armed.
+ *
+ * - **Exact alarms**: the alarm is scheduled with
+ * `AlarmManager.setAlarmClock` (see `AlarmScheduler.kt`). Without the grant
+ * there is no alarm clock, and — by the platform's own definition — no alarm
+ * app classification, which is what the takeover permission depends on.
+ * - **Full-screen intent**: restricted since Android 14 (API 34) and revoked by
+ * default for ordinary apps. Without it the takeover cannot cover the lock
+ * screen, so the device is not usable for alerts.
+ * - **Notifications**: Android requires a notification for the long-running
+ * foreground service that holds the armed socket, so without the grant the
+ * device cannot keep listening while the app is closed. This is a platform
+ * requirement, not a delivery mechanism.
+ *
+ * The battery exemption stays advisory: it makes background survival more
+ * likely but does not gate delivery.
+ */
+
+export type ReadinessKey = 'exactAlarm' | 'fullScreenIntent' | 'notifications' | 'battery';
+
+export interface ReadinessBlocker {
+ key: ReadinessKey;
+ title: string;
+ body: string;
+ actionLabel: string;
+ /** Blocking: this device cannot deliver a real alarm, so the UI must say so. */
+ blocking: boolean;
+}
+
+/** Fixed copy — the banner and the settings checklist share it. */
+export const READINESS_COPY: Record> = {
+ exactAlarm: {
+ title: 'Exact alarms',
+ body: 'Without this, this device cannot fire a real alarm at the moment it is triggered. Allow exact alarms so the alarm clock can run.',
+ actionLabel: 'Open settings',
+ blocking: true,
+ },
+ fullScreenIntent: {
+ title: 'Full-screen takeover',
+ body: 'Without this, this device cannot take over the screen when the alert fires. An alert has to be a real alarm, so this device is not usable for alerts until it is allowed.',
+ actionLabel: 'Open settings',
+ blocking: true,
+ },
+ notifications: {
+ title: 'Notifications',
+ body: 'Without this, this device cannot keep listening while the app is closed — Android requires a notification for the background connection.',
+ actionLabel: 'Allow',
+ blocking: true,
+ },
+ battery: {
+ title: 'Background reliability',
+ body: 'Without this, Android may freeze the app in the background and the armed socket stops listening.',
+ actionLabel: 'Allow',
+ blocking: false,
+ },
+};
+
+export interface TakeoverReadiness {
+ /** False while permissions are still unread — render nothing rather than guess. */
+ known: boolean;
+ /** True when this device cannot deliver a real alarm. */
+ blocked: boolean;
+ blockers: ReadinessBlocker[];
+}
+
+/**
+ * @param perms the Android permission snapshot, or `null` while it is loading
+ */
+export function takeoverReadiness(perms: AlarmPermissions | null): TakeoverReadiness {
+ if (!perms) return { known: false, blocked: false, blockers: [] };
+
+ const blockers: ReadinessBlocker[] = [];
+ if (!perms.exactAlarm) {
+ blockers.push({ key: 'exactAlarm', ...READINESS_COPY.exactAlarm });
+ }
+ if (!perms.fullScreenIntent) {
+ blockers.push({ key: 'fullScreenIntent', ...READINESS_COPY.fullScreenIntent });
+ }
+ if (!perms.notifications) {
+ blockers.push({ key: 'notifications', ...READINESS_COPY.notifications });
+ }
+ if (!perms.batteryUnrestricted) {
+ blockers.push({ key: 'battery', ...READINESS_COPY.battery });
+ }
+
+ return {
+ known: true,
+ blocked: blockers.some(b => b.blocking),
+ blockers,
+ };
+}
+
+/** True only when every blocking grant is present. */
+export function canDeliverRealAlarm(perms: AlarmPermissions | null): boolean {
+ const readiness = takeoverReadiness(perms);
+ return readiness.known && !readiness.blocked;
+}
+
+/**
+ * Honest one-liner for the "this device" card. Never claims readiness while a
+ * blocking grant is missing.
+ */
+export function deliverySummary(perms: AlarmPermissions | null): string {
+ const readiness = takeoverReadiness(perms);
+ if (!readiness.known) return 'Checking how this device can alert you…';
+ if (!perms?.exactAlarm) {
+ return 'This device cannot fire a real alarm — exact alarms are not allowed.';
+ }
+ if (!perms.fullScreenIntent) {
+ return 'This device cannot take over the screen — the full-screen takeover is not allowed.';
+ }
+ if (!perms.notifications) {
+ return 'This device cannot keep listening while the app is closed — notifications are not allowed.';
+ }
+ return 'This device fires a real alarm at maximum volume when the owner triggers the alert.';
+}
diff --git a/src/state/useAlarmPermissions.ts b/src/state/useAlarmPermissions.ts
new file mode 100644
index 0000000..665b03e
--- /dev/null
+++ b/src/state/useAlarmPermissions.ts
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { AppState } from 'react-native';
+import {
+ getPermissions,
+ requestPermission,
+ type AlarmPermissions,
+} from '../services/alarmNative';
+
+/** The grants the readiness banner and the health checklist can fix. */
+export type FixablePermission = 'notifications' | 'fullScreenIntent' | 'exactAlarm' | 'battery';
+
+export interface AlarmPermissionsState {
+ /** `null` until the first read completes — never assume "granted". */
+ perms: AlarmPermissions | null;
+ /** Re-reads every grant. Called on mount and whenever the app resumes. */
+ refresh: () => Promise;
+ /** Opens the right prompt/settings screen, then re-reads what it can. */
+ request: (name: FixablePermission) => Promise;
+}
+
+/**
+ * Shared Android-permission state for the readiness banner and the settings
+ * checklist.
+ *
+ * The resume listener is the important part: every one of these grants is
+ * changed in another app (the system Settings screen), so the only moment the
+ * new value can be observed is when this app comes back to the foreground.
+ * Without it the banner would still claim "not granted" after the user fixed it
+ * — or, worse, the checklist would claim "granted" after they revoked it.
+ */
+export function useAlarmPermissions(): AlarmPermissionsState {
+ const [perms, setPerms] = useState(null);
+ const mounted = useRef(true);
+
+ const refresh = useCallback(async () => {
+ const next = await getPermissions();
+ if (mounted.current) setPerms(next);
+ }, []);
+
+ const request = useCallback(async (name: FixablePermission) => {
+ // Full-screen intent and battery leave the app for a system screen; the
+ // listener below picks the result up on return. Notifications resolve in
+ // place, so read straight after.
+ const next = await requestPermission(name);
+ if (mounted.current) setPerms(next);
+ }, []);
+
+ useEffect(() => {
+ mounted.current = true;
+ refresh();
+ const sub = AppState.addEventListener('change', state => {
+ if (state === 'active') refresh();
+ });
+ return () => {
+ mounted.current = false;
+ sub.remove();
+ };
+ }, [refresh]);
+
+ return { perms, refresh, request };
+}
diff --git a/src/state/useListeningState.ts b/src/state/useListeningState.ts
new file mode 100644
index 0000000..ad8df28
--- /dev/null
+++ b/src/state/useListeningState.ts
@@ -0,0 +1,40 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { AppState } from 'react-native';
+import { getServiceState, type AlarmServiceState } from '../services/alarmNative';
+
+export interface ListeningStateValue {
+ /** `null` until the first read completes. */
+ state: AlarmServiceState | null;
+ refresh: () => Promise;
+}
+
+/**
+ * Reads whether the armed socket is really listening.
+ *
+ * The service persists this (`AlarmListeningState`), so it survives the app
+ * process being killed: a device whose listening stopped at 15:00 must say so
+ * when opened at 20:00, not resume a stale in-memory "armed" flag.
+ */
+export function useListeningState(): ListeningStateValue {
+ const [state, setState] = useState(null);
+ const mounted = useRef(true);
+
+ const refresh = useCallback(async () => {
+ const next = await getServiceState();
+ if (mounted.current) setState(next);
+ }, []);
+
+ useEffect(() => {
+ mounted.current = true;
+ refresh();
+ const sub = AppState.addEventListener('change', s => {
+ if (s === 'active') refresh();
+ });
+ return () => {
+ mounted.current = false;
+ sub.remove();
+ };
+ }, [refresh]);
+
+ return { state, refresh };
+}