[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:
@@ -92,6 +92,7 @@ export function TriggerPad({
|
|||||||
confirmTitle,
|
confirmTitle,
|
||||||
}: TriggerPadProps) {
|
}: TriggerPadProps) {
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [confirmingDisarm, setConfirmingDisarm] = useState(false);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
const fill = useRef(new Animated.Value(0)).current;
|
const fill = useRef(new Animated.Value(0)).current;
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
@@ -179,12 +180,52 @@ export function TriggerPad({
|
|||||||
label="Disarm"
|
label="Disarm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
height={56}
|
height={56}
|
||||||
onPress={onDisarm}
|
onPress={() => setConfirmingDisarm(true)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
icon={OctagonX}
|
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
|
<Modal
|
||||||
visible={confirming}
|
visible={confirming}
|
||||||
|
|||||||
@@ -40,6 +40,34 @@ export function relativeSince(value: string | null | undefined, now: number): st
|
|||||||
return date ? relativeTime(date, now) : null;
|
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. */
|
/** Local wall-clock time for the accessibility label of a relative stamp. */
|
||||||
export function absoluteLocal(date: Date): string {
|
export function absoluteLocal(date: Date): string {
|
||||||
const hh = String(date.getHours()).padStart(2, '0');
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
|||||||
@@ -16,13 +16,20 @@ import {
|
|||||||
WifiOff,
|
WifiOff,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import Clipboard from '@react-native-clipboard/clipboard';
|
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 { useConnection } from '../state/ConnectionContext';
|
||||||
import * as api from '../services/alarmApi';
|
import * as api from '../services/alarmApi';
|
||||||
import { formatAutoStop } from '../services/alarmApi';
|
import { isPastDeadline, relativeSince, remainingLifetime } from '../lib/time';
|
||||||
import { relativeSince } from '../lib/time';
|
import { alarmSocketUrl } from '../services/connection';
|
||||||
|
import { startForegroundService } from '../services/alarmNative';
|
||||||
import { truncateToken } from '../lib/qrPayload';
|
import { truncateToken } from '../lib/qrPayload';
|
||||||
import { TriggerPad } from '../components/TriggerPad';
|
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 {
|
import {
|
||||||
AppText,
|
AppText,
|
||||||
Button,
|
Button,
|
||||||
@@ -60,6 +67,7 @@ import {
|
|||||||
textFaint,
|
textFaint,
|
||||||
textMuted,
|
textMuted,
|
||||||
textSecondary,
|
textSecondary,
|
||||||
|
typeBody,
|
||||||
typeBodyStrong,
|
typeBodyStrong,
|
||||||
typeCaption,
|
typeCaption,
|
||||||
typeCode,
|
typeCode,
|
||||||
@@ -74,6 +82,29 @@ type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
|
|||||||
|
|
||||||
const NEW = 'new';
|
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() {
|
export default function AlertDetailScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { params } = useRoute<Rt>();
|
const { params } = useRoute<Rt>();
|
||||||
@@ -91,7 +122,9 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
|
|||||||
const { createAlert } = useAlarm();
|
const { createAlert } = useAlarm();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [message, setMessage] = 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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -100,11 +133,10 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const seconds = Number.parseInt(autoStop, 10);
|
|
||||||
const created = await createAlert({
|
const created = await createAlert({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
message: message.trim() || undefined,
|
message: message.trim() || undefined,
|
||||||
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
|
expiresInSeconds: persistDays * 24 * 60 * 60,
|
||||||
});
|
});
|
||||||
onCreated(created.alertId);
|
onCreated(created.alertId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -137,18 +169,28 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
|
|||||||
accessibilityLabel="Message"
|
accessibilityLabel="Message"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Auto-stop (seconds)">
|
<Field label="Lifetime (maximum 14 days)">
|
||||||
<TextInputBox
|
<View style={styles.chipRow}>
|
||||||
value={autoStop}
|
{PERSISTENCE_DAYS.map(days => (
|
||||||
onChangeText={setAutoStop}
|
<Pressable
|
||||||
keyboardType="number-pad"
|
key={days}
|
||||||
placeholder="60"
|
onPress={() => setPersistDays(days)}
|
||||||
accessibilityLabel="Auto-stop in seconds"
|
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>
|
</Field>
|
||||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||||
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
|
When the last device acknowledges the alarm, or this window ends, the alert — its
|
||||||
after this, on every device.
|
subscriptions, QR and definition — is destroyed. There is no auto-stop: while it
|
||||||
|
rings, it rings until each device dismisses it.
|
||||||
</AppText>
|
</AppText>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -174,19 +216,20 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
|
|||||||
|
|
||||||
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
|
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { online } = useConnection();
|
const { online, resolved } = useConnection();
|
||||||
const {
|
const {
|
||||||
adminAlerts,
|
adminAlerts,
|
||||||
getAlert,
|
getAlert,
|
||||||
adminTokenFor,
|
adminTokenFor,
|
||||||
updateAlertLocal,
|
updateAlertLocal,
|
||||||
forgetAdminAlert,
|
|
||||||
leaveAlert,
|
leaveAlert,
|
||||||
liveEvent,
|
liveEvent,
|
||||||
refresh,
|
refresh,
|
||||||
setActiveAlertId,
|
setActiveAlertId,
|
||||||
delivery,
|
delivery,
|
||||||
socketState,
|
socketState,
|
||||||
|
markEnded,
|
||||||
|
endingFor,
|
||||||
} = useAlarm();
|
} = useAlarm();
|
||||||
|
|
||||||
const alert = getAlert(alertId);
|
const alert = getAlert(alertId);
|
||||||
@@ -198,6 +241,35 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [revealToken, setRevealToken] = useState(false);
|
const [revealToken, setRevealToken] = useState(false);
|
||||||
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
|
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(
|
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
||||||
isAdmin ? alertId : null,
|
isAdmin ? alertId : null,
|
||||||
@@ -210,10 +282,21 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
const fresh = await api.getAlert(alertId);
|
const fresh = await api.getAlert(alertId);
|
||||||
setServerAlert(fresh);
|
setServerAlert(fresh);
|
||||||
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
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.
|
// Offline is fine — the cached copy renders.
|
||||||
}
|
}
|
||||||
}, [alertId, updateAlertLocal]);
|
}, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveAlertId(isAdmin ? null : alertId);
|
setActiveAlertId(isAdmin ? null : alertId);
|
||||||
@@ -240,6 +323,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
|
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
|
||||||
: 'Armed — triggering rings every subscribed device.'
|
: 'Armed — triggering rings every subscribed device.'
|
||||||
: 'Idle — 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 live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
|
||||||
const effectiveState = live ? 'triggered' : state;
|
const effectiveState = live ? 'triggered' : state;
|
||||||
const deviceCount = subs.length;
|
const deviceCount = subs.length;
|
||||||
@@ -254,6 +338,10 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
|
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
|
||||||
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
|
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
|
||||||
} catch (e) {
|
} 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.');
|
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -266,10 +354,15 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.disarmAlert({ alertId, adminToken });
|
await api.disarmAlert({ alertId, adminToken });
|
||||||
updateAlertLocal(alertId, { status: 'disarmed' });
|
// Disarming destroys the alert — there is no detail page left to show.
|
||||||
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
|
await markEnded(alertId, 'disarmed');
|
||||||
setNotice('Disarmed on every device.');
|
navigation.navigate('Home');
|
||||||
} catch (e) {
|
} 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.');
|
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -282,9 +375,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.deleteAlert({ alertId, adminToken });
|
await api.deleteAlert({ alertId, adminToken });
|
||||||
await forgetAdminAlert(alertId);
|
await markEnded(alertId, 'deleted');
|
||||||
navigation.goBack();
|
navigation.navigate('Home');
|
||||||
} catch (e) {
|
} 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.');
|
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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) {
|
if (!alert && !serverAlert) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
@@ -357,6 +471,9 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
<StateRing state={effectiveState} />
|
<StateRing state={effectiveState} />
|
||||||
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
|
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
|
||||||
<AppText style={styles.stateLine}>{stateSentence}</AppText>
|
<AppText style={styles.stateLine}>{stateSentence}</AppText>
|
||||||
|
{lifetime ? (
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>{lifetime}</AppText>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
@@ -371,17 +488,23 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
onTrigger={doTrigger}
|
onTrigger={doTrigger}
|
||||||
onDisarm={doDisarm}
|
onDisarm={doDisarm}
|
||||||
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
|
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
|
||||||
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SubscriberControls
|
<>
|
||||||
alertId={alertId}
|
{/* Android 14+ gates the full-screen takeover behind a per-app
|
||||||
alert={alert}
|
grant; saying so here is the difference between "subscribed" and
|
||||||
delivery={delivery}
|
"will actually ring". */}
|
||||||
socketState={socketState}
|
<ListeningWarning state={listening} onStart={restartListening} busy={busy} />
|
||||||
busy={busy}
|
<TakeoverReadiness perms={perms} onFix={fixPermission} />
|
||||||
onLeave={doLeave}
|
<SubscriberControls
|
||||||
/>
|
alertId={alertId}
|
||||||
|
delivery={delivery}
|
||||||
|
socketState={socketState}
|
||||||
|
busy={busy}
|
||||||
|
perms={perms}
|
||||||
|
onLeave={doLeave}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
@@ -400,35 +523,47 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
</IconLine>
|
</IconLine>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
subs.map(sub => (
|
subs.map((sub, index) => {
|
||||||
<Card key={sub.subscriptionId}>
|
// 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={styles.subRow}>
|
||||||
<View style={{ flex: 1, gap: space1 }}>
|
<View style={{ flex: 1, gap: space1 }}>
|
||||||
<AppText style={{ ...typeBodyStrong, color: text }}>
|
<AppText style={{ ...typeBodyStrong, color: text }}>
|
||||||
{sub.deviceLabel || sub.deviceId}
|
{deviceName}
|
||||||
</AppText>
|
</AppText>
|
||||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
|
{sub.platform ?? 'android'} · joined {joinedAgo}
|
||||||
|
{seenAgo ? ` · last seen ${seenAgo}` : ''}
|
||||||
</AppText>
|
</AppText>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
|
await api.removeSubscription({ alertId, subscriptionId: sub.id });
|
||||||
reloadSubs();
|
reloadSubs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
|
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
|
accessibilityLabel={`Revoke ${deviceName}`}
|
||||||
style={styles.iconButton}
|
style={styles.iconButton}
|
||||||
>
|
>
|
||||||
<Trash2 size={18} color={danger} />
|
<Trash2 size={18} color={danger} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -473,7 +608,6 @@ function AdminControls({
|
|||||||
onTrigger,
|
onTrigger,
|
||||||
onDisarm,
|
onDisarm,
|
||||||
onShowQr,
|
onShowQr,
|
||||||
autoStopSeconds,
|
|
||||||
}: {
|
}: {
|
||||||
adminToken: string | null;
|
adminToken: string | null;
|
||||||
revealToken: boolean;
|
revealToken: boolean;
|
||||||
@@ -486,7 +620,6 @@ function AdminControls({
|
|||||||
onTrigger: () => void;
|
onTrigger: () => void;
|
||||||
onDisarm: () => void;
|
onDisarm: () => void;
|
||||||
onShowQr: () => void;
|
onShowQr: () => void;
|
||||||
autoStopSeconds?: number;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: space4 }}>
|
<View style={{ gap: space4 }}>
|
||||||
@@ -535,11 +668,6 @@ function AdminControls({
|
|||||||
}
|
}
|
||||||
deviceCount={deviceCount}
|
deviceCount={deviceCount}
|
||||||
/>
|
/>
|
||||||
{autoStopSeconds ? (
|
|
||||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
|
||||||
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
|
|
||||||
</AppText>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -559,17 +687,17 @@ function AdminControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SubscriberControls({
|
function SubscriberControls({
|
||||||
alert,
|
|
||||||
delivery,
|
delivery,
|
||||||
socketState,
|
socketState,
|
||||||
busy,
|
busy,
|
||||||
|
perms,
|
||||||
onLeave,
|
onLeave,
|
||||||
}: {
|
}: {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
alert: api.Alert | undefined;
|
|
||||||
delivery: string;
|
delivery: string;
|
||||||
socketState: string;
|
socketState: string;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
perms: AlarmPermissions | null;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -577,12 +705,14 @@ function SubscriberControls({
|
|||||||
<View style={{ gap: space3 }}>
|
<View style={{ gap: space3 }}>
|
||||||
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
|
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
|
||||||
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
|
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
|
||||||
{delivery === 'socket'
|
{socketState === 'open'
|
||||||
? `Connected for live delivery (${socketState}).`
|
? 'Connected for live delivery over the instance socket.'
|
||||||
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
|
: 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>
|
||||||
<IconLine icon={ShieldCheck} color={stateArmed}>
|
<IconLine icon={ShieldCheck} color={stateArmed}>
|
||||||
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
|
{deliverySummary(perms)}
|
||||||
</IconLine>
|
</IconLine>
|
||||||
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
|
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
|
||||||
</View>
|
</View>
|
||||||
@@ -624,4 +754,20 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||||
note: { ...typeCaption, color: textSecondary },
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ import {
|
|||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
type Tab = 'admin' | 'subscribed';
|
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() {
|
export default function HomeScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { username, logout } = useAuth();
|
const { username, logout } = useAuth();
|
||||||
@@ -71,6 +79,8 @@ export default function HomeScreen() {
|
|||||||
liveEvent,
|
liveEvent,
|
||||||
delivery,
|
delivery,
|
||||||
socketState,
|
socketState,
|
||||||
|
lastEnding,
|
||||||
|
clearLastEnding,
|
||||||
} = useAlarm();
|
} = useAlarm();
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>('admin');
|
const [tab, setTab] = useState<Tab>('admin');
|
||||||
@@ -144,6 +154,24 @@ export default function HomeScreen() {
|
|||||||
>
|
>
|
||||||
{error ? <ErrorBanner message={error} onRetry={reload} /> : null}
|
{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 ? (
|
{loading && !list.length ? (
|
||||||
<LoadingView label="Loading your alerts…" />
|
<LoadingView label="Loading your alerts…" />
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
@@ -151,7 +179,7 @@ export default function HomeScreen() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
title="No alerts yet"
|
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={
|
action={
|
||||||
<Button
|
<Button
|
||||||
label="Create an alert"
|
label="Create an alert"
|
||||||
@@ -188,9 +216,11 @@ export default function HomeScreen() {
|
|||||||
))}
|
))}
|
||||||
{tab === 'subscribed' ? (
|
{tab === 'subscribed' ? (
|
||||||
<AppText style={styles.deliveryNote}>
|
<AppText style={styles.deliveryNote}>
|
||||||
{delivery === 'socket'
|
{socketState === 'open'
|
||||||
? `Live delivery connected (${socketState}).`
|
? 'Live delivery connected — triggers arrive over the instance socket.'
|
||||||
: 'Live delivery unavailable in proxy mode — status is polled every 5s.'}
|
: 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>
|
</AppText>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -350,5 +380,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
rowMeta: { flexDirection: 'row', alignItems: 'center', gap: space2 },
|
rowMeta: { flexDirection: 'row', alignItems: 'center', gap: space2 },
|
||||||
deliveryNote: { ...typeCaption, color: textSecondary, paddingTop: space2 },
|
deliveryNote: { ...typeCaption, color: textSecondary, paddingTop: space2 },
|
||||||
|
noticeStack: { gap: space2 },
|
||||||
footerActions: { gap: space2, paddingTop: space6 },
|
footerActions: { gap: space2, paddingTop: space6 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ export default function LiveAlarmScreen() {
|
|||||||
}, [fade, shake, reduceMotion]);
|
}, [fade, shake, reduceMotion]);
|
||||||
|
|
||||||
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
|
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
|
||||||
// refuses the alarm stream, the notification fallback carries it and the
|
// refuses the alarm stream, the only thing that can still reach the user is
|
||||||
// screen says so instead of pretending.
|
// the volume warning — the screen says so instead of pretending.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await playAlarm(alertId, message ?? '', event?.autoStopSeconds ?? 0);
|
const res = await playAlarm(alertId, message ?? '');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!res.ok && res.reason !== 'native-module-unavailable') setVolumeBlocked(true);
|
if (!res.ok && res.reason !== 'native-module-unavailable') setVolumeBlocked(true);
|
||||||
if (res.ok && res.volumeRaised === false) setVolumeBlocked(true);
|
if (res.ok && res.volumeRaised === false) setVolumeBlocked(true);
|
||||||
@@ -161,7 +161,7 @@ export default function LiveAlarmScreen() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopAlarm();
|
stopAlarm();
|
||||||
};
|
};
|
||||||
}, [alertId, message, event?.autoStopSeconds]);
|
}, [alertId, message]);
|
||||||
|
|
||||||
const leave = useCallback(() => {
|
const leave = useCallback(() => {
|
||||||
clearLiveEvent();
|
clearLiveEvent();
|
||||||
@@ -179,7 +179,7 @@ export default function LiveAlarmScreen() {
|
|||||||
try {
|
try {
|
||||||
await stopAlarm();
|
await stopAlarm();
|
||||||
} catch {
|
} 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
|
// 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
|
// event no longer needs to drive a takeover — but the screen stays mounted
|
||||||
|
|||||||
+109
-75
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
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 { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { Info, RefreshCw, Server, Smartphone, Wifi, WifiOff } from 'lucide-react-native';
|
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 { useAlarm } from '../state/AlarmContext';
|
||||||
import { useConnection } from '../state/ConnectionContext';
|
import { useConnection } from '../state/ConnectionContext';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getServiceState,
|
||||||
isAlarmNativeAvailable,
|
isAlarmNativeAvailable,
|
||||||
openExactAlarmSettings,
|
|
||||||
openFullScreenIntentSettings,
|
|
||||||
requestPermission,
|
|
||||||
startForegroundService,
|
startForegroundService,
|
||||||
stopForegroundService,
|
stopForegroundService,
|
||||||
type AlarmPermissions,
|
type AlarmPermissions,
|
||||||
|
type AlarmServiceState,
|
||||||
} from '../services/alarmNative';
|
} 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 { AppText, Button, Card, Divider, HealthIcon, Header, OfflineStrip } from '../components/ui';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +24,6 @@ import {
|
|||||||
focusRing,
|
focusRing,
|
||||||
minTapTarget,
|
minTapTarget,
|
||||||
radiusMd,
|
radiusMd,
|
||||||
space2,
|
|
||||||
space3,
|
space3,
|
||||||
space4,
|
space4,
|
||||||
stateDisarmed,
|
stateDisarmed,
|
||||||
@@ -48,18 +46,18 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|||||||
const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; bad: string }> = {
|
const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; bad: string }> = {
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
ok: 'Allowed — alerts can post a high-priority notification.',
|
ok: 'Allowed — the background listener can hold its required notification.',
|
||||||
bad: 'Not allowed — a trigger cannot notify you while the app is closed.',
|
bad: 'Not allowed — this device cannot keep listening while the app is closed.',
|
||||||
},
|
},
|
||||||
fullScreenIntent: {
|
fullScreenIntent: {
|
||||||
title: 'Full-screen alarm',
|
title: 'Full-screen alarm',
|
||||||
ok: 'Allowed — the takeover can cover the lock screen.',
|
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: {
|
exactAlarm: {
|
||||||
title: 'Exact alarms',
|
title: 'Exact alarms',
|
||||||
ok: 'Allowed — auto-stop and scheduled work fire on time.',
|
ok: 'Allowed — the alarm clock fires at the exact trigger time.',
|
||||||
bad: 'Not allowed — Android may delay auto-stop.',
|
bad: 'Not allowed — this device cannot fire a real alarm at all.',
|
||||||
},
|
},
|
||||||
batteryUnrestricted: {
|
batteryUnrestricted: {
|
||||||
title: 'Battery optimisation',
|
title: 'Battery optimisation',
|
||||||
@@ -69,7 +67,7 @@ const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; b
|
|||||||
canForceVolume: {
|
canForceVolume: {
|
||||||
title: 'Alarm volume',
|
title: 'Alarm volume',
|
||||||
ok: 'The alarm stream can be raised to maximum automatically.',
|
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 navigation = useNavigation<Nav>();
|
||||||
const { username, email, logout } = useAuth();
|
const { username, email, logout } = useAuth();
|
||||||
const { resolved, settings, updateSettings, reprobe, probing, online, isRelease } = useConnection();
|
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 { activeAlertId, delivery, socketState, reconnectAttempts, subscribedAlerts } = useAlarm();
|
||||||
|
|
||||||
const [perms, setPerms] = useState<AlarmPermissions | null>(null);
|
// Re-reads on mount and every time the app resumes — the grants are changed
|
||||||
const [serviceRunning, setServiceRunning] = useState(false);
|
// 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 [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const refreshPerms = useCallback(async () => {
|
const refreshListening = useCallback(async () => {
|
||||||
setPerms(await getPermissions());
|
setListening(await getServiceState());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshPerms();
|
refreshListening();
|
||||||
}, [refreshPerms]);
|
}, [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 activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
|
||||||
const wsUrl = resolved && activeAlert
|
const wsUrl = resolved && activeAlert
|
||||||
@@ -103,11 +114,11 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
if (serviceRunning) {
|
if (serviceRunning) {
|
||||||
await stopForegroundService();
|
await stopForegroundService();
|
||||||
setServiceRunning(false);
|
await refreshListening();
|
||||||
setNotice('Foreground service stopped. The socket only lives while the app is open.');
|
setNotice('Foreground service stopped. The socket only lives while the app is open.');
|
||||||
} else {
|
} else {
|
||||||
const res = await startForegroundService(activeAlert.alertId, activeAlert.subscribeToken ?? '', wsUrl);
|
const res = await startForegroundService(activeAlert.alertId, activeAlert.subscribeToken ?? '', wsUrl);
|
||||||
setServiceRunning(res.ok);
|
await refreshListening();
|
||||||
setNotice(
|
setNotice(
|
||||||
res.ok
|
res.ok
|
||||||
? 'Foreground service running — the armed socket survives backgrounding.'
|
? 'Foreground service running — the armed socket survives backgrounding.'
|
||||||
@@ -126,17 +137,21 @@ export default function SettingsScreen() {
|
|||||||
<View style={{ gap: space3 }}>
|
<View style={{ gap: space3 }}>
|
||||||
<AppText style={{ ...typeHeading, color: text }}>Live delivery</AppText>
|
<AppText style={{ ...typeHeading, color: text }}>Live delivery</AppText>
|
||||||
<Row
|
<Row
|
||||||
icon={delivery === 'socket' ? Wifi : WifiOff}
|
icon={socketState === 'open' ? Wifi : WifiOff}
|
||||||
tint={delivery === 'socket' ? stateDisarmed : warn}
|
tint={socketState === 'open' ? stateDisarmed : warn}
|
||||||
title={
|
title={
|
||||||
delivery === 'socket'
|
socketState === 'open'
|
||||||
? `Connected (${socketState})`
|
? 'Live delivery connected'
|
||||||
: 'Unavailable in proxy mode'
|
: delivery === 'socket'
|
||||||
|
? `Not listening (${socketState})`
|
||||||
|
: 'No instance configured'
|
||||||
}
|
}
|
||||||
detail={
|
detail={
|
||||||
delivery === 'socket'
|
socketState === 'open'
|
||||||
? 'Triggers arrive over the service WebSocket; the target is under a second on a LAN.'
|
? 'Triggers arrive over the instance WebSocket — this is the alarm path.'
|
||||||
: '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.'
|
: 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 ? (
|
{delivery === 'socket' && reconnectAttempts > 0 ? (
|
||||||
@@ -150,10 +165,41 @@ export default function SettingsScreen() {
|
|||||||
title={isAlarmNativeAvailable ? 'Android alarm engine present' : 'Android alarm engine unavailable'}
|
title={isAlarmNativeAvailable ? 'Android alarm engine present' : 'Android alarm engine unavailable'}
|
||||||
detail={
|
detail={
|
||||||
isAlarmNativeAvailable
|
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.'
|
: '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
|
<Button
|
||||||
label={serviceRunning ? 'Stop foreground service' : 'Start foreground service'}
|
label={serviceRunning ? 'Stop foreground service' : 'Start foreground service'}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -185,34 +231,28 @@ export default function SettingsScreen() {
|
|||||||
<>
|
<>
|
||||||
<HealthRow
|
<HealthRow
|
||||||
ok={perms.notifications}
|
ok={perms.notifications}
|
||||||
onFix={() => requestPermission('notifications').then(setPerms)}
|
onFix={() => request('notifications')}
|
||||||
okText={HEALTH_COPY.notifications.ok}
|
okText={HEALTH_COPY.notifications.ok}
|
||||||
title={HEALTH_COPY.notifications.title}
|
title={HEALTH_COPY.notifications.title}
|
||||||
bad={HEALTH_COPY.notifications.bad}
|
bad={HEALTH_COPY.notifications.bad}
|
||||||
/>
|
/>
|
||||||
<HealthRow
|
<HealthRow
|
||||||
ok={perms.fullScreenIntent}
|
ok={perms.fullScreenIntent}
|
||||||
onFix={() =>
|
onFix={() => request('fullScreenIntent')}
|
||||||
openFullScreenIntentSettings().then(() =>
|
|
||||||
requestPermission('fullScreenIntent').then(setPerms),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
okText={HEALTH_COPY.fullScreenIntent.ok}
|
okText={HEALTH_COPY.fullScreenIntent.ok}
|
||||||
title={HEALTH_COPY.fullScreenIntent.title}
|
title={HEALTH_COPY.fullScreenIntent.title}
|
||||||
bad={HEALTH_COPY.fullScreenIntent.bad}
|
bad={HEALTH_COPY.fullScreenIntent.bad}
|
||||||
/>
|
/>
|
||||||
<HealthRow
|
<HealthRow
|
||||||
ok={perms.exactAlarm}
|
ok={perms.exactAlarm}
|
||||||
onFix={() =>
|
onFix={() => request('exactAlarm')}
|
||||||
openExactAlarmSettings().then(() => requestPermission('exactAlarm').then(setPerms))
|
|
||||||
}
|
|
||||||
okText={HEALTH_COPY.exactAlarm.ok}
|
okText={HEALTH_COPY.exactAlarm.ok}
|
||||||
title={HEALTH_COPY.exactAlarm.title}
|
title={HEALTH_COPY.exactAlarm.title}
|
||||||
bad={HEALTH_COPY.exactAlarm.bad}
|
bad={HEALTH_COPY.exactAlarm.bad}
|
||||||
/>
|
/>
|
||||||
<HealthRow
|
<HealthRow
|
||||||
ok={perms.batteryUnrestricted}
|
ok={perms.batteryUnrestricted}
|
||||||
onFix={() => requestPermission('battery').then(setPerms)}
|
onFix={() => request('battery')}
|
||||||
okText={HEALTH_COPY.batteryUnrestricted.ok}
|
okText={HEALTH_COPY.batteryUnrestricted.ok}
|
||||||
title={HEALTH_COPY.batteryUnrestricted.title}
|
title={HEALTH_COPY.batteryUnrestricted.title}
|
||||||
bad={HEALTH_COPY.batteryUnrestricted.bad}
|
bad={HEALTH_COPY.batteryUnrestricted.bad}
|
||||||
@@ -240,38 +280,33 @@ export default function SettingsScreen() {
|
|||||||
<Row
|
<Row
|
||||||
icon={Server}
|
icon={Server}
|
||||||
tint={online ? stateDisarmed : warn}
|
tint={online ? stateDisarmed : warn}
|
||||||
title={online ? `Reachable — ${settings.mode} mode` : 'No host answered'}
|
title={online ? 'Instance reachable' : 'No instance answered'}
|
||||||
detail={resolved ? resolved.baseUrl : 'Run a probe to look for the service.'}
|
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 }}>
|
<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>
|
</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 ? (
|
{!isRelease ? (
|
||||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
<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>
|
</AppText>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -387,16 +422,15 @@ const styles = StyleSheet.create({
|
|||||||
borderColor: border,
|
borderColor: border,
|
||||||
padding: space3,
|
padding: space3,
|
||||||
},
|
},
|
||||||
modeRow: { flexDirection: 'row', gap: space2 },
|
instanceInput: {
|
||||||
modeChip: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 44,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: radiusMd,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: border,
|
borderColor: border,
|
||||||
|
borderRadius: radiusMd,
|
||||||
backgroundColor: surfaceRaised,
|
backgroundColor: surfaceRaised,
|
||||||
|
color: text,
|
||||||
|
paddingHorizontal: space3,
|
||||||
|
paddingVertical: space3,
|
||||||
|
minHeight: minTapTarget,
|
||||||
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
modeChipActive: { borderColor: focusRing },
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user