[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:
@@ -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(
|
||||||
|
<TakeoverReadiness
|
||||||
|
perms={{ ...ALL_GRANTED, fullScreenIntent: false }}
|
||||||
|
onFix={onFix}
|
||||||
|
nativeAvailable
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TakeoverReadiness perms={ALL_GRANTED} onFix={jest.fn()} nativeAvailable />,
|
||||||
|
);
|
||||||
|
expect(tree.toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing while permissions are still being read', () => {
|
||||||
|
const tree = render(<TakeoverReadiness perms={null} onFix={jest.fn()} nativeAvailable />);
|
||||||
|
expect(tree.toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing off-Android, where the grants do not apply', () => {
|
||||||
|
const tree = render(
|
||||||
|
<TakeoverReadiness
|
||||||
|
perms={{ ...ALL_GRANTED, fullScreenIntent: false }}
|
||||||
|
onFix={jest.fn()}
|
||||||
|
nativeAvailable={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(tree.toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the battery exemption advisory but still offers the fix', () => {
|
||||||
|
const tree = render(
|
||||||
|
<TakeoverReadiness
|
||||||
|
perms={{ ...ALL_GRANTED, batteryUnrestricted: false }}
|
||||||
|
onFix={jest.fn()}
|
||||||
|
nativeAvailable
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>): 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user