[feat](client): surface expiry, endings, and device health in the app

The alert detail screen now creates with a lifetime (1/3/7/14 days) and shows expiresAt, lists subscriber rows by their non-identifying fields, and renders each ending (delivered, expired, disarmed, deleted) as a terminal state instead of a stale alert. Settings configures the single instance origin and reports the real readiness/listening checklist; the trigger pad keeps its 1200ms hold, and time helpers format the deadline and last-seen values.
This commit is contained in:
2026-10-19 02:34:20 +01:00
parent dcfc9a5df8
commit 7c4788afba
6 changed files with 421 additions and 141 deletions
+44 -3
View File
@@ -92,6 +92,7 @@ export function TriggerPad({
confirmTitle,
}: TriggerPadProps) {
const [confirming, setConfirming] = useState(false);
const [confirmingDisarm, setConfirmingDisarm] = useState(false);
const [width, setWidth] = useState(0);
const fill = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
@@ -179,12 +180,52 @@ export function TriggerPad({
label="Disarm"
variant="secondary"
height={56}
onPress={onDisarm}
onPress={() => setConfirmingDisarm(true)}
disabled={disabled}
icon={OctagonX}
accessibilityLabel="Disarm the alarm on every device"
accessibilityLabel="Disarm the alarm and end this alert"
/>
<AppText style={styles.hint}>Disarm stops the alarm on every device. One tap, no confirm.</AppText>
<AppText style={styles.hint}>
Disarm stops the alarm for everyone and ends the alert.
</AppText>
{/* Disarm is destructive now: a triggered alert is destroyed, and an armed
one is destroyed too, so it always takes a confirmation. */}
<Modal
visible={confirmingDisarm}
transparent
animationType="fade"
onRequestClose={() => setConfirmingDisarm(false)}
statusBarTranslucent
>
<View style={styles.scrim}>
<View style={styles.sheet}>
<AppText style={styles.sheetTitle}>
{ringing ? 'Disarm and end this alert?' : 'Delete this alert by disarming?'}
</AppText>
<AppText style={styles.sheetBody}>
{ringing
? 'Disarm stops the alarm for everyone and ends the alert. It cannot be triggered again.'
: 'Disarming deletes this alert for every subscriber — they will not be able to join again, and the QR code stops working.'}
</AppText>
<Button
label={ringing ? 'Disarm and end alert' : 'Disarm and delete alert'}
variant="danger"
height={56}
onPress={() => {
setConfirmingDisarm(false);
onDisarm();
}}
/>
<Button
label="Cancel"
variant="secondary"
height={48}
onPress={() => setConfirmingDisarm(false)}
/>
</View>
</View>
</Modal>
<Modal
visible={confirming}
+28
View File
@@ -40,6 +40,34 @@ export function relativeSince(value: string | null | undefined, now: number): st
return date ? relativeTime(date, now) : null;
}
/**
* Time left before an alert self-destructs, e.g. `Ends in 6d 4h` / `Ends in
* 12m`. Null when there is no deadline or it cannot be parsed; a deadline in
* the past renders `Ended` (the service will 404 from then on).
*/
export function remainingLifetime(expiresAt: string | null | undefined, now: number): string | null {
const date = parseServerTime(expiresAt);
if (!date) return null;
const left = date.getTime() - now;
if (left <= 0) return 'Ended';
if (left < MINUTE) return 'Ends in under a minute';
if (left < HOUR) return `Ends in ${Math.floor(left / MINUTE)}m`;
if (left < DAY) {
const hours = Math.floor(left / HOUR);
const minutes = Math.floor((left % HOUR) / MINUTE);
return minutes ? `Ends in ${hours}h ${minutes}m` : `Ends in ${hours}h`;
}
const days = Math.floor(left / DAY);
const hours = Math.floor((left % DAY) / HOUR);
return hours ? `Ends in ${days}d ${hours}h` : `Ends in ${days}d`;
}
/** True when the deadline is present and already past. */
export function isPastDeadline(expiresAt: string | null | undefined, now: number): boolean {
const date = parseServerTime(expiresAt);
return !!date && date.getTime() <= now;
}
/** Local wall-clock time for the accessibility label of a relative stamp. */
export function absoluteLocal(date: Date): string {
const hh = String(date.getHours()).padStart(2, '0');
+193 -47
View File
@@ -16,13 +16,20 @@ import {
WifiOff,
} from 'lucide-react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { useAlarm, useSubscriptions } from '../state/AlarmContext';
import { useAlarm, useSubscriptions, type EndingReason } from '../state/AlarmContext';
import type { AlarmPermissions } from '../services/alarmNative';
import { useConnection } from '../state/ConnectionContext';
import * as api from '../services/alarmApi';
import { formatAutoStop } from '../services/alarmApi';
import { relativeSince } from '../lib/time';
import { isPastDeadline, relativeSince, remainingLifetime } from '../lib/time';
import { alarmSocketUrl } from '../services/connection';
import { startForegroundService } from '../services/alarmNative';
import { truncateToken } from '../lib/qrPayload';
import { TriggerPad } from '../components/TriggerPad';
import { TakeoverReadiness } from '../components/TakeoverReadiness';
import { ListeningWarning } from '../components/ListeningWarning';
import { useAlarmPermissions } from '../state/useAlarmPermissions';
import { useListeningState } from '../state/useListeningState';
import { deliverySummary } from '../lib/takeoverReadiness';
import {
AppText,
Button,
@@ -60,6 +67,7 @@ import {
textFaint,
textMuted,
textSecondary,
typeBody,
typeBodyStrong,
typeCaption,
typeCode,
@@ -74,6 +82,29 @@ type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
const NEW = 'new';
/** The service's cap is 14 days; these are the only lifetimes offered. */
const PERSISTENCE_DAYS = [1, 3, 7, 14] as const;
/** One explicit message per ending — never a generic 404. */
const ENDING_COPY: Record<EndingReason, { title: string; body: string }> = {
delivered: {
title: 'Delivered to all subscribers',
body: 'Every subscribed device acknowledged this alarm, so the alert was destroyed. It cannot be triggered again.',
},
expired: {
title: 'This alert expired',
body: 'Its persistence window ended, so the alert and its QR code were destroyed. Create a new one if you still need it.',
},
disarmed: {
title: 'You ended this alert',
body: 'Disarming destroyed the alert for everyone. Subscribers will not ring again and the QR code no longer works.',
},
deleted: {
title: 'This alert was deleted',
body: 'The alert and its tokens are gone.',
},
};
export default function AlertDetailScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
@@ -91,7 +122,9 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
const { createAlert } = useAlarm();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [autoStop, setAutoStop] = useState('60');
// The service caps persistence at 14 days and rejects over-cap values; offer
// exactly that menu and nothing beyond it.
const [persistDays, setPersistDays] = useState<(typeof PERSISTENCE_DAYS)[number]>(7);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -100,11 +133,10 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
setBusy(true);
setError(null);
try {
const seconds = Number.parseInt(autoStop, 10);
const created = await createAlert({
name: name.trim(),
message: message.trim() || undefined,
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
expiresInSeconds: persistDays * 24 * 60 * 60,
});
onCreated(created.alertId);
} catch (e) {
@@ -137,18 +169,28 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
accessibilityLabel="Message"
/>
</Field>
<Field label="Auto-stop (seconds)">
<TextInputBox
value={autoStop}
onChangeText={setAutoStop}
keyboardType="number-pad"
placeholder="60"
accessibilityLabel="Auto-stop in seconds"
/>
<Field label="Lifetime (maximum 14 days)">
<View style={styles.chipRow}>
{PERSISTENCE_DAYS.map(days => (
<Pressable
key={days}
onPress={() => setPersistDays(days)}
accessibilityRole="button"
accessibilityState={{ selected: persistDays === days }}
accessibilityLabel={`Delete this alert after ${days} days`}
style={[styles.chip, persistDays === days ? styles.chipOn : null]}
>
<AppText style={[styles.chipText, persistDays === days ? styles.chipTextOn : null]}>
{days}d
</AppText>
</Pressable>
))}
</View>
</Field>
<AppText style={{ ...typeCaption, color: textSecondary }}>
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
after this, on every device.
When the last device acknowledges the alarm, or this window ends, the alert — its
subscriptions, QR and definition — is destroyed. There is no auto-stop: while it
rings, it rings until each device dismisses it.
</AppText>
</View>
</Card>
@@ -174,19 +216,20 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
const navigation = useNavigation<Nav>();
const { online } = useConnection();
const { online, resolved } = useConnection();
const {
adminAlerts,
getAlert,
adminTokenFor,
updateAlertLocal,
forgetAdminAlert,
leaveAlert,
liveEvent,
refresh,
setActiveAlertId,
delivery,
socketState,
markEnded,
endingFor,
} = useAlarm();
const alert = getAlert(alertId);
@@ -198,6 +241,35 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
const [notice, setNotice] = useState<string | null>(null);
const [revealToken, setRevealToken] = useState(false);
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
// Grants the takeover depends on. On the subscriber view this drives a banner,
// because a subscription that cannot take over the screen is not "done".
const { perms, request: fixPermission } = useAlarmPermissions();
const { state: listening, refresh: refreshListening } = useListeningState();
// Re-arms the background listener after a system-imposed stop. This is the
// only path back to "armed" — there is no notification fallback that keeps
// the illusion alive.
const restartListening = useCallback(async () => {
const token = getAlert(alertId)?.subscribeToken;
if (!resolved || !token) {
setNotice('The service URL is not known yet — re-open the alert in a moment.');
return;
}
const wsUrl = alarmSocketUrl(resolved, alertId, token);
if (!wsUrl) {
setNotice(
'No instance address is configured, so background listening is unavailable.',
);
return;
}
const res = await startForegroundService(alertId, token, wsUrl);
await refreshListening();
setNotice(
res.ok
? 'Listening again — this device is armed.'
: `Could not start listening (${res.reason ?? 'unknown'}).`,
);
}, [alertId, getAlert, refreshListening, resolved]);
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
isAdmin ? alertId : null,
@@ -210,10 +282,21 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
const fresh = await api.getAlert(alertId);
setServerAlert(fresh);
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
} catch {
} catch (e) {
// A 404 is a terminal ending, not an error: the alert was destroyed
// (delivered, expired, disarmed or deleted). Never ring from it.
if (api.isAlertGone(e)) {
await markEnded(
alertId,
isPastDeadline(serverAlert?.expiresAt ?? alert?.expiresAt, Date.now())
? 'expired'
: 'delivered',
);
return;
}
// Offline is fine — the cached copy renders.
}
}, [alertId, updateAlertLocal]);
}, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]);
useEffect(() => {
setActiveAlertId(isAdmin ? null : alertId);
@@ -240,6 +323,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
: 'Armed — triggering rings every subscribed device.'
: 'Idle — triggering rings every subscribed device.';
const lifetime = remainingLifetime(serverAlert?.expiresAt ?? alert?.expiresAt, Date.now());
const live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
const effectiveState = live ? 'triggered' : state;
const deviceCount = subs.length;
@@ -254,6 +338,10 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
} catch (e) {
if (api.isAlertGone(e)) {
await markEnded(alertId, isPastDeadline(alert?.expiresAt, Date.now()) ? 'expired' : 'delivered');
return;
}
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
} finally {
setBusy(false);
@@ -266,10 +354,15 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
setError(null);
try {
await api.disarmAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'disarmed' });
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
setNotice('Disarmed on every device.');
// Disarming destroys the alert — there is no detail page left to show.
await markEnded(alertId, 'disarmed');
navigation.navigate('Home');
} catch (e) {
if (api.isAlertGone(e)) {
await markEnded(alertId, 'disarmed');
navigation.navigate('Home');
return;
}
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
} finally {
setBusy(false);
@@ -282,9 +375,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
setError(null);
try {
await api.deleteAlert({ alertId, adminToken });
await forgetAdminAlert(alertId);
navigation.goBack();
await markEnded(alertId, 'deleted');
navigation.navigate('Home');
} catch (e) {
if (api.isAlertGone(e)) {
await markEnded(alertId, 'deleted');
navigation.navigate('Home');
return;
}
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
} finally {
setBusy(false);
@@ -313,6 +411,22 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
}
};
const ending = endingFor(alertId);
if (ending) {
const endingCopy = ENDING_COPY[ending];
return (
<View style={styles.root}>
<Header title="Alert ended" onBack={() => navigation.navigate('Home')} />
<View style={styles.endingWrap}>
<StateRing state="disarmed" />
<AppText style={styles.endingTitle}>{endingCopy.title}</AppText>
<AppText style={styles.endingBody}>{endingCopy.body}</AppText>
<Button label="Back to alerts" icon={BellOff} onPress={() => navigation.navigate('Home')} />
</View>
</View>
);
}
if (!alert && !serverAlert) {
return (
<View style={styles.root}>
@@ -357,6 +471,9 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
<StateRing state={effectiveState} />
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
<AppText style={styles.stateLine}>{stateSentence}</AppText>
{lifetime ? (
<AppText style={{ ...typeCaption, color: textSecondary }}>{lifetime}</AppText>
) : null}
</View>
{isAdmin ? (
@@ -371,17 +488,23 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
onTrigger={doTrigger}
onDisarm={doDisarm}
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
/>
) : (
<>
{/* Android 14+ gates the full-screen takeover behind a per-app
grant; saying so here is the difference between "subscribed" and
"will actually ring". */}
<ListeningWarning state={listening} onStart={restartListening} busy={busy} />
<TakeoverReadiness perms={perms} onFix={fixPermission} />
<SubscriberControls
alertId={alertId}
alert={alert}
delivery={delivery}
socketState={socketState}
busy={busy}
perms={perms}
onLeave={doLeave}
/>
</>
)}
{isAdmin ? (
@@ -400,35 +523,47 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
</IconLine>
</Card>
) : (
subs.map(sub => (
<Card key={sub.subscriptionId}>
subs.map((sub, index) => {
// No stable device identifier on the wire (privacy ruling), so
// the discriminator is the subscription's position in the
// server's stable `created, id` order plus its role label and
// join time. Never a token fragment, never a field the service
// does not send.
const deviceName = `${sub.deviceLabel?.trim() || 'Android phone'} ${index + 1}`;
const joinedAgo = relativeSince(sub.created, Date.now()) ?? 'recently';
const seenAgo = relativeSince(sub.lastSeenAt, Date.now());
return (
<Card key={sub.id}>
<View style={styles.subRow}>
<View style={{ flex: 1, gap: space1 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>
{sub.deviceLabel || sub.deviceId}
{deviceName}
</AppText>
<AppText style={{ ...typeMicro, color: textFaint }}>
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
{sub.platform ?? 'android'} · joined {joinedAgo}
{seenAgo ? ` · last seen ${seenAgo}` : ''}
</AppText>
</View>
<Pressable
onPress={async () => {
setError(null);
try {
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
await api.removeSubscription({ alertId, subscriptionId: sub.id });
reloadSubs();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
}
}}
accessibilityRole="button"
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
accessibilityLabel={`Revoke ${deviceName}`}
style={styles.iconButton}
>
<Trash2 size={18} color={danger} />
</Pressable>
</View>
</Card>
))
);
})
)}
</View>
) : null}
@@ -473,7 +608,6 @@ function AdminControls({
onTrigger,
onDisarm,
onShowQr,
autoStopSeconds,
}: {
adminToken: string | null;
revealToken: boolean;
@@ -486,7 +620,6 @@ function AdminControls({
onTrigger: () => void;
onDisarm: () => void;
onShowQr: () => void;
autoStopSeconds?: number;
}) {
return (
<View style={{ gap: space4 }}>
@@ -535,11 +668,6 @@ function AdminControls({
}
deviceCount={deviceCount}
/>
{autoStopSeconds ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
</AppText>
) : null}
</View>
</Card>
@@ -559,17 +687,17 @@ function AdminControls({
}
function SubscriberControls({
alert,
delivery,
socketState,
busy,
perms,
onLeave,
}: {
alertId: string;
alert: api.Alert | undefined;
delivery: string;
socketState: string;
busy: boolean;
perms: AlarmPermissions | null;
onLeave: () => void;
}) {
return (
@@ -577,12 +705,14 @@ function SubscriberControls({
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
{delivery === 'socket'
? `Connected for live delivery (${socketState}).`
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
{socketState === 'open'
? 'Connected for live delivery over the instance socket.'
: delivery === 'socket'
? `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.`
: 'Not listening — no instance address is configured.'}
</IconLine>
<IconLine icon={ShieldCheck} color={stateArmed}>
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
{deliverySummary(perms)}
</IconLine>
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
</View>
@@ -624,4 +754,20 @@ const styles = StyleSheet.create({
},
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
note: { ...typeCaption, color: textSecondary },
chipRow: { flexDirection: 'row', gap: space2 },
chip: {
minHeight: minTapTarget,
paddingHorizontal: space4,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
alignItems: 'center',
justifyContent: 'center',
},
chipOn: { backgroundColor: stateDisarmed, borderColor: stateDisarmed },
endingWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space4, padding: space6 },
endingTitle: { ...typeHeading, color: text, textAlign: 'center' },
endingBody: { ...typeBody, color: textSecondary, textAlign: 'center' },
chipText: { ...typeBodyStrong, color: text },
chipTextOn: { color: '#FFFFFF' },
});
+35 -4
View File
@@ -58,6 +58,14 @@ import {
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Tab = 'admin' | 'subscribed';
/** Plain-language ending lines for the list notice. */
const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>['reason'], string> = {
delivered: 'Delivered — every subscriber acknowledged.',
expired: 'Expired — its lifetime ran out.',
disarmed: 'Ended — you disarmed it.',
deleted: 'Deleted.',
};
export default function HomeScreen() {
const navigation = useNavigation<Nav>();
const { username, logout } = useAuth();
@@ -71,6 +79,8 @@ export default function HomeScreen() {
liveEvent,
delivery,
socketState,
lastEnding,
clearLastEnding,
} = useAlarm();
const [tab, setTab] = useState<Tab>('admin');
@@ -144,6 +154,24 @@ export default function HomeScreen() {
>
{error ? <ErrorBanner message={error} onRetry={reload} /> : null}
{lastEnding ? (
<Pressable
onPress={clearLastEnding}
accessibilityRole="button"
accessibilityLabel="Dismiss the ended-alert notice"
>
<Card>
<View style={styles.noticeStack}>
<AppText style={{ ...typeBodyStrong, color: text }}>{ENDING_COPY[lastEnding.reason]}</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
Ended alerts are removed from this list — that is the alert finishing, not data loss.
Tap to dismiss.
</AppText>
</View>
</Card>
</Pressable>
) : null}
{loading && !list.length ? (
<LoadingView label="Loading your alerts…" />
) : list.length === 0 ? (
@@ -151,7 +179,7 @@ export default function HomeScreen() {
<EmptyState
icon={ShieldCheck}
title="No alerts yet"
subtitle="Create an alert, show its QR code, and every device that scans it will ring when you trigger it."
subtitle="Create an alert, show its QR code, and every device that scans it will ring when you trigger it. Alerts end when every device acknowledges, when their lifetime runs out, or when you disarm them."
action={
<Button
label="Create an alert"
@@ -188,9 +216,11 @@ export default function HomeScreen() {
))}
{tab === 'subscribed' ? (
<AppText style={styles.deliveryNote}>
{delivery === 'socket'
? `Live delivery connected (${socketState}).`
: 'Live delivery unavailable in proxy mode — status is polled every 5s.'}
{socketState === 'open'
? 'Live delivery connected — triggers arrive over the instance socket.'
: delivery === 'socket'
? `Not listening — the socket is ${socketState}. This device is not receiving triggers right now.`
: 'Not listening — set your instance address in Settings.'}
</AppText>
) : null}
</View>
@@ -350,5 +380,6 @@ const styles = StyleSheet.create({
},
rowMeta: { flexDirection: 'row', alignItems: 'center', gap: space2 },
deliveryNote: { ...typeCaption, color: textSecondary, paddingTop: space2 },
noticeStack: { gap: space2 },
footerActions: { gap: space2, paddingTop: space6 },
});
+5 -5
View File
@@ -147,12 +147,12 @@ export default function LiveAlarmScreen() {
}, [fade, shake, reduceMotion]);
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
// refuses the alarm stream, the notification fallback carries it and the
// screen says so instead of pretending.
// refuses the alarm stream, the only thing that can still reach the user is
// the volume warning — the screen says so instead of pretending.
useEffect(() => {
let cancelled = false;
(async () => {
const res = await playAlarm(alertId, message ?? '', event?.autoStopSeconds ?? 0);
const res = await playAlarm(alertId, message ?? '');
if (cancelled) return;
if (!res.ok && res.reason !== 'native-module-unavailable') setVolumeBlocked(true);
if (res.ok && res.volumeRaised === false) setVolumeBlocked(true);
@@ -161,7 +161,7 @@ export default function LiveAlarmScreen() {
cancelled = true;
stopAlarm();
};
}, [alertId, message, event?.autoStopSeconds]);
}, [alertId, message]);
const leave = useCallback(() => {
clearLiveEvent();
@@ -179,7 +179,7 @@ export default function LiveAlarmScreen() {
try {
await stopAlarm();
} catch {
// Best effort: the notification fallback still applies.
// Best effort: the volume warning (if any) is the only remaining signal.
}
// Once the native player is stopped this device is silent, so the live
// event no longer needs to drive a takeover — but the screen stays mounted
+109 -75
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Info, RefreshCw, Server, Smartphone, Wifi, WifiOff } from 'lucide-react-native';
@@ -7,16 +7,15 @@ import { useAuth } from '../state/AuthContext';
import { useAlarm } from '../state/AlarmContext';
import { useConnection } from '../state/ConnectionContext';
import {
getPermissions,
getServiceState,
isAlarmNativeAvailable,
openExactAlarmSettings,
openFullScreenIntentSettings,
requestPermission,
startForegroundService,
stopForegroundService,
type AlarmPermissions,
type AlarmServiceState,
} from '../services/alarmNative';
import { alarmSocketUrl } from '../services/connection';
import { useAlarmPermissions } from '../state/useAlarmPermissions';
import { DEFAULT_SETTINGS, alarmSocketUrl, normalizeInstanceUrl } from '../services/connection';
import { AppText, Button, Card, Divider, HealthIcon, Header, OfflineStrip } from '../components/ui';
import type { RootStackParamList } from '../navigation';
import {
@@ -25,7 +24,6 @@ import {
focusRing,
minTapTarget,
radiusMd,
space2,
space3,
space4,
stateDisarmed,
@@ -48,18 +46,18 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; bad: string }> = {
notifications: {
title: 'Notifications',
ok: 'Allowed — alerts can post a high-priority notification.',
bad: 'Not allowed — a trigger cannot notify you while the app is closed.',
ok: 'Allowed — the background listener can hold its required notification.',
bad: 'Not allowed — this device cannot keep listening while the app is closed.',
},
fullScreenIntent: {
title: 'Full-screen alarm',
ok: 'Allowed — the takeover can cover the lock screen.',
bad: 'Not allowed — the alarm will only show as a notification when locked.',
bad: 'Not allowed — this device cannot take over the screen, so an alert cannot be a real alarm.',
},
exactAlarm: {
title: 'Exact alarms',
ok: 'Allowed — auto-stop and scheduled work fire on time.',
bad: 'Not allowed — Android may delay auto-stop.',
ok: 'Allowed — the alarm clock fires at the exact trigger time.',
bad: 'Not allowed — this device cannot fire a real alarm at all.',
},
batteryUnrestricted: {
title: 'Battery optimisation',
@@ -69,7 +67,7 @@ const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; b
canForceVolume: {
title: 'Alarm volume',
ok: 'The alarm stream can be raised to maximum automatically.',
bad: 'The OS blocks forced volume — you will get a notification instead.',
bad: 'The OS blocks forced volume — the alarm may be silent even though it takes over the screen.',
},
};
@@ -77,19 +75,32 @@ export default function SettingsScreen() {
const navigation = useNavigation<Nav>();
const { username, email, logout } = useAuth();
const { resolved, settings, updateSettings, reprobe, probing, online, isRelease } = useConnection();
const [instanceDraft, setInstanceDraft] = useState(settings.instanceUrl);
useEffect(() => setInstanceDraft(settings.instanceUrl), [settings.instanceUrl]);
const saveInstance = useCallback(() => {
const normalized = normalizeInstanceUrl(instanceDraft);
updateSettings({ instanceUrl: normalized ?? '' });
reprobe();
}, [instanceDraft, reprobe, updateSettings]);
const { activeAlertId, delivery, socketState, reconnectAttempts, subscribedAlerts } = useAlarm();
const [perms, setPerms] = useState<AlarmPermissions | null>(null);
const [serviceRunning, setServiceRunning] = useState(false);
// Re-reads on mount and every time the app resumes — the grants are changed
// in system Settings, so the only moment we can observe them is on return.
const { perms, refresh: refreshPerms, request } = useAlarmPermissions();
const [listening, setListening] = useState<AlarmServiceState | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const refreshPerms = useCallback(async () => {
setPerms(await getPermissions());
const refreshListening = useCallback(async () => {
setListening(await getServiceState());
}, []);
useEffect(() => {
refreshPerms();
}, [refreshPerms]);
refreshListening();
}, [refreshListening]);
const serviceRunning = listening?.state === 'running';
const listeningStopped = listening?.state === 'stopped-timeout';
const listeningFailed = listening?.state === 'delivery-failed';
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
const wsUrl = resolved && activeAlert
@@ -103,11 +114,11 @@ export default function SettingsScreen() {
}
if (serviceRunning) {
await stopForegroundService();
setServiceRunning(false);
await refreshListening();
setNotice('Foreground service stopped. The socket only lives while the app is open.');
} else {
const res = await startForegroundService(activeAlert.alertId, activeAlert.subscribeToken ?? '', wsUrl);
setServiceRunning(res.ok);
await refreshListening();
setNotice(
res.ok
? 'Foreground service running — the armed socket survives backgrounding.'
@@ -126,17 +137,21 @@ export default function SettingsScreen() {
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Live delivery</AppText>
<Row
icon={delivery === 'socket' ? Wifi : WifiOff}
tint={delivery === 'socket' ? stateDisarmed : warn}
icon={socketState === 'open' ? Wifi : WifiOff}
tint={socketState === 'open' ? stateDisarmed : warn}
title={
delivery === 'socket'
? `Connected (${socketState})`
: 'Unavailable in proxy mode'
socketState === 'open'
? 'Live delivery connected'
: delivery === 'socket'
? `Not listening (${socketState})`
: 'No instance configured'
}
detail={
delivery === 'socket'
? 'Triggers arrive over the service WebSocket; the target is under a second on a LAN.'
: 'Maestro has no service WebSocket passthrough, so status is polled every 5 seconds while the app is open and every 30 seconds in the background.'
socketState === 'open'
? 'Triggers arrive over the instance WebSocket — this is the alarm path.'
: delivery === 'socket'
? 'The socket is not open, so this device is not ringing live. A trigger that fired meanwhile is reported on the next check-in and rings late; a status poll is never a substitute for the socket.'
: 'Set your instance address above. Every service is a path under that one origin.'
}
/>
{delivery === 'socket' && reconnectAttempts > 0 ? (
@@ -150,10 +165,41 @@ export default function SettingsScreen() {
title={isAlarmNativeAvailable ? 'Android alarm engine present' : 'Android alarm engine unavailable'}
detail={
isAlarmNativeAvailable
? 'Max-volume STREAM_ALARM playback, vibration and the lock-screen takeover are wired.'
? 'Max-volume STREAM_ALARM playback, vibration and the lock-screen takeover are implemented.'
: 'This build has no native alarm module (expected off-Android); alerts fall back to an in-app sound.'
}
/>
{/* The honest answer to "am I actually covered?" — a system time
limit can stop the armed service, and the old persistent
notification would have kept claiming "armed". */}
<Row
icon={listening?.state === 'running' ? Smartphone : Info}
tint={
listening?.state === 'running'
? stateDisarmed
: listeningStopped || listeningFailed
? warn
: textMuted
}
title={
listening?.state === 'running'
? 'Background listening is running'
: listeningStopped
? 'Background listening stopped'
: listeningFailed
? 'The last alarm could not fire'
: 'Background listening is off'
}
detail={
listening?.state === 'running'
? 'The armed socket is held by the foreground service, so a trigger still lands while the app is closed.'
: listeningStopped
? "Android stopped the background service (its time limit was reached). This device is not listening — start the service again to re-arm."
: listeningFailed
? 'Android refused the alarm clock. Allow exact alarms, then start listening again — no notification was sent in its place.'
: 'Start the foreground service to keep the armed socket alive when the app is closed. Without it, only the open app receives triggers.'
}
/>
<Button
label={serviceRunning ? 'Stop foreground service' : 'Start foreground service'}
variant="secondary"
@@ -185,34 +231,28 @@ export default function SettingsScreen() {
<>
<HealthRow
ok={perms.notifications}
onFix={() => requestPermission('notifications').then(setPerms)}
onFix={() => request('notifications')}
okText={HEALTH_COPY.notifications.ok}
title={HEALTH_COPY.notifications.title}
bad={HEALTH_COPY.notifications.bad}
/>
<HealthRow
ok={perms.fullScreenIntent}
onFix={() =>
openFullScreenIntentSettings().then(() =>
requestPermission('fullScreenIntent').then(setPerms),
)
}
onFix={() => request('fullScreenIntent')}
okText={HEALTH_COPY.fullScreenIntent.ok}
title={HEALTH_COPY.fullScreenIntent.title}
bad={HEALTH_COPY.fullScreenIntent.bad}
/>
<HealthRow
ok={perms.exactAlarm}
onFix={() =>
openExactAlarmSettings().then(() => requestPermission('exactAlarm').then(setPerms))
}
onFix={() => request('exactAlarm')}
okText={HEALTH_COPY.exactAlarm.ok}
title={HEALTH_COPY.exactAlarm.title}
bad={HEALTH_COPY.exactAlarm.bad}
/>
<HealthRow
ok={perms.batteryUnrestricted}
onFix={() => requestPermission('battery').then(setPerms)}
onFix={() => request('battery')}
okText={HEALTH_COPY.batteryUnrestricted.ok}
title={HEALTH_COPY.batteryUnrestricted.title}
bad={HEALTH_COPY.batteryUnrestricted.bad}
@@ -240,38 +280,33 @@ export default function SettingsScreen() {
<Row
icon={Server}
tint={online ? stateDisarmed : warn}
title={online ? `Reachable — ${settings.mode} mode` : 'No host answered'}
detail={resolved ? resolved.baseUrl : 'Run a probe to look for the service.'}
title={online ? 'Instance reachable' : 'No instance answered'}
detail={resolved?.baseUrl ? resolved.baseUrl : 'No instance address set yet.'}
/>
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
<View style={styles.modeRow}>
{(['maestro', 'direct'] as const).map(mode => (
<Pressable
key={mode}
onPress={() => updateSettings({ mode })}
accessibilityRole="radio"
accessibilityState={{ selected: settings.mode === mode }}
accessibilityLabel={mode === 'maestro' ? 'Maestro proxy mode' : 'Direct service mode'}
style={[styles.modeChip, settings.mode === mode ? styles.modeChipActive : null]}
>
<AppText
style={{
...typeLabel,
color: settings.mode === mode ? focusRing : textMuted,
}}
>
{mode === 'maestro' ? 'Maestro proxy' : 'Direct'}
</AppText>
</Pressable>
))}
</View>
<AppText style={{ ...typeMicro, color: textFaint }}>
Direct mode is the only mode with live WebSocket delivery. Proxy mode polls.
Your instance exposes one address; every service is a path under it. The client
only ever talks to this origin — Maestro routes the alarm service.
</AppText>
<Button label={probing ? 'Probing…' : 'Re-run connection probe'} variant="secondary" onPress={reprobe} />
<TextInput
value={instanceDraft}
onChangeText={setInstanceDraft}
onEndEditing={saveInstance}
onSubmitEditing={saveInstance}
placeholder="http://127.0.0.1:8080"
placeholderTextColor={textFaint}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
accessibilityLabel="Instance address"
style={styles.instanceInput}
/>
<Button label="Save instance address" variant="secondary" onPress={saveInstance} />
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
<Button label={probing ? 'Checking…' : 'Check connection'} variant="secondary" onPress={reprobe} />
{!isRelease ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Dev build · host {settings.host || '(auto)'} : {settings.port || '(default)'}
Dev build · default instance {DEFAULT_SETTINGS.instanceUrl} ·
`adb reverse tcp:8080 tcp:8080` on a USB phone
</AppText>
) : null}
</View>
@@ -387,16 +422,15 @@ const styles = StyleSheet.create({
borderColor: border,
padding: space3,
},
modeRow: { flexDirection: 'row', gap: space2 },
modeChip: {
flex: 1,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radiusMd,
instanceInput: {
borderWidth: 1,
borderColor: border,
borderRadius: radiusMd,
backgroundColor: surfaceRaised,
color: text,
paddingHorizontal: space3,
paddingVertical: space3,
minHeight: minTapTarget,
fontSize: 15,
},
modeChipActive: { borderColor: focusRing },
});