[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
+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.';
}