[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.
This commit is contained in:
2026-10-19 02:34:02 +01:00
parent a804223139
commit a78c4cea14
7 changed files with 654 additions and 0 deletions
+73
View File
@@ -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 (
<Card accessibilityLabel={timedOut ? 'This device stopped listening' : 'The last alarm could not fire'}>
<View style={{ gap: space3 }} accessibilityLiveRegion="polite">
<IconLine icon={TriangleAlert} color={warn}>
<AppText style={{ ...typeHeading, color: text }}>
{timedOut ? 'This device stopped listening' : 'The last alarm could not fire'}
</AppText>
</IconLine>
<AppText style={{ ...typeBodyStrong, color: text }}>
{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.'}
</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
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.
</AppText>
<Button
label="Start listening again"
variant="secondary"
height={48}
disabled={busy}
onPress={onStart}
accessibilityLabel="Start listening again"
/>
</View>
</Card>
);
}
+97
View File
@@ -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 (
<Card accessibilityLabel={heading}>
<View style={{ gap: space3 }} accessibilityLiveRegion="polite">
<IconLine icon={ShieldAlert} color={readiness.blocked ? warn : stateArmed}>
<AppText style={{ ...typeHeading, color: text }}>{heading}</AppText>
</IconLine>
{readiness.blockers.map(blocker => (
<View key={blocker.key} style={{ gap: space3 }}>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>{blocker.title}</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>{blocker.body}</AppText>
</View>
<Button
label={blocker.actionLabel}
variant="secondary"
height={48}
onPress={() => onFix(blocker.key)}
accessibilityLabel={`${blocker.actionLabel} — ${blocker.title}`}
/>
</View>
))}
{readiness.blocked ? (
<AppText style={{ ...typeCaption, color: textSecondary, paddingTop: space4 }}>
Until this is fixed, this device is not armed and must not be relied on for alerts.
</AppText>
) : null}
</View>
</Card>
);
}
+126
View File
@@ -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<ReadinessKey, Omit<ReadinessBlocker, 'key'>> = {
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.';
}
+61
View File
@@ -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<void>;
/** Opens the right prompt/settings screen, then re-reads what it can. */
request: (name: FixablePermission) => Promise<void>;
}
/**
* 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<AlarmPermissions | null>(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 };
}
+40
View File
@@ -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<void>;
}
/**
* 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<AlarmServiceState | null>(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 };
}