[feat](client): deliver a trigger as a real alarm-clock takeover
A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.
This commit is contained in:
@@ -5,8 +5,8 @@ import { NativeModules, Platform } from 'react-native';
|
||||
*
|
||||
* Every call is defensive: on iOS, in a JS-only test run, or on a build where
|
||||
* the native module failed to register, the promise resolves to a "unavailable"
|
||||
* result instead of throwing. The UI must degrade to in-app sound + a
|
||||
* high-priority notification, and say so, rather than break.
|
||||
* result instead of throwing, and the UI must say what it can actually do
|
||||
* (in-app sound only) rather than invent a delivery path.
|
||||
*/
|
||||
export interface AlarmNativeResult {
|
||||
ok: boolean;
|
||||
@@ -22,7 +22,6 @@ export interface AlarmNativeResult {
|
||||
export interface AlarmLaunchAlert {
|
||||
alertId: string;
|
||||
message: string;
|
||||
autoStopSeconds: number;
|
||||
}
|
||||
|
||||
export interface AlarmPermissions {
|
||||
@@ -34,14 +33,32 @@ export interface AlarmPermissions {
|
||||
canForceVolume: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the armed foreground service is actually listening.
|
||||
*
|
||||
* `stopped-timeout` is the important one: Android's background limits (the
|
||||
* a platform time limit, e.g. the `dataSync` 6-hour cap on API 35+) stopped the
|
||||
* socket without the user asking,
|
||||
* so the app must not keep claiming the device is armed.
|
||||
*/
|
||||
export interface AlarmServiceState {
|
||||
state: 'running' | 'idle' | 'stopped' | 'stopped-timeout' | 'delivery-failed' | 'unknown';
|
||||
/** Epoch millis of the last state change, or 0 when unknown. */
|
||||
since: number;
|
||||
/** The alert the service was holding, when it recorded one. */
|
||||
alertId?: string;
|
||||
}
|
||||
|
||||
interface AlarmNativeModule {
|
||||
playAlarm(alertId: string, message: string, autoStopSeconds: number): Promise<AlarmNativeResult>;
|
||||
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
||||
stopAlarm(): Promise<AlarmNativeResult>;
|
||||
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
||||
stopService(): Promise<AlarmNativeResult>;
|
||||
getPermissions(): Promise<AlarmPermissions>;
|
||||
requestPermission(name: string): Promise<AlarmPermissions>;
|
||||
getLaunchAlert(): Promise<AlarmLaunchAlert | null>;
|
||||
getServiceState(): Promise<AlarmServiceState>;
|
||||
randomToken(chars: number): Promise<string | null>;
|
||||
isIgnoringBatteryOptimizations(): Promise<boolean>;
|
||||
openSoundSettings(): Promise<AlarmNativeResult>;
|
||||
openAppSettings(): Promise<AlarmNativeResult>;
|
||||
@@ -66,11 +83,10 @@ const PERMISSIONS_UNAVAILABLE: AlarmPermissions = {
|
||||
export async function playAlarm(
|
||||
alertId: string,
|
||||
message = '',
|
||||
autoStopSeconds = 0,
|
||||
): Promise<AlarmNativeResult> {
|
||||
if (!native) return UNAVAILABLE;
|
||||
try {
|
||||
return await native.playAlarm(alertId, message, autoStopSeconds);
|
||||
return await native.playAlarm(alertId, message);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: e instanceof Error ? e.message : 'play-failed' };
|
||||
}
|
||||
@@ -145,6 +161,34 @@ export async function requestPermission(name: string): Promise<AlarmPermissions>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads whether the armed foreground service is really listening. On a build
|
||||
* without the native module (iOS, tests) this reports `unknown`, which the UI
|
||||
* treats as "cannot confirm" — never as "covered".
|
||||
*/
|
||||
export async function getServiceState(): Promise<AlarmServiceState> {
|
||||
if (!native) return { state: 'unknown', since: 0 };
|
||||
try {
|
||||
return await native.getServiceState();
|
||||
} catch {
|
||||
return { state: 'unknown', since: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cryptographically random lowercase hex token (`SecureRandom` on Android).
|
||||
* Returns `null` when the native module is unavailable — callers must fail
|
||||
* loudly rather than fall back to a non-cryptographic source.
|
||||
*/
|
||||
export async function randomToken(chars: number): Promise<string | null> {
|
||||
if (!native) return null;
|
||||
try {
|
||||
return await native.randomToken(chars);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the system volume UI — the takeover's volume-blocked recovery action. */
|
||||
export async function openSoundSettings(): Promise<AlarmNativeResult> {
|
||||
if (!native) return UNAVAILABLE;
|
||||
|
||||
Reference in New Issue
Block a user