diff --git a/src/components/TriggerPad.tsx b/src/components/TriggerPad.tsx
index 0498e4a..7ac50af 100644
--- a/src/components/TriggerPad.tsx
+++ b/src/components/TriggerPad.tsx
@@ -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"
/>
- Disarm stops the alarm on every device. One tap, no confirm.
+
+ Disarm stops the alarm for everyone and ends the alert.
+
+
+ {/* Disarm is destructive now: a triggered alert is destroyed, and an armed
+ one is destroyed too, so it always takes a confirmation. */}
+ setConfirmingDisarm(false)}
+ statusBarTranslucent
+ >
+
+
+
+ {ringing ? 'Disarm and end this alert?' : 'Delete this alert by disarming?'}
+
+
+ {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.'}
+
+ {
+ setConfirmingDisarm(false);
+ onDisarm();
+ }}
+ />
+ setConfirmingDisarm(false)}
+ />
+
+
+
;
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 = {
+ 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();
const { params } = useRoute();
@@ -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(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"
/>
-
-
+
+
+ {PERSISTENCE_DAYS.map(days => (
+ setPersistDays(days)}
+ accessibilityRole="button"
+ accessibilityState={{ selected: persistDays === days }}
+ accessibilityLabel={`Delete this alert after ${days} days`}
+ style={[styles.chip, persistDays === days ? styles.chipOn : null]}
+ >
+
+ {days}d
+
+
+ ))}
+
- {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.
@@ -174,19 +216,20 @@ function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
const navigation = useNavigation();
- 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(null);
const [revealToken, setRevealToken] = useState(false);
const [serverAlert, setServerAlert] = useState(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 (
+
+ navigation.navigate('Home')} />
+
+
+ {endingCopy.title}
+ {endingCopy.body}
+ navigation.navigate('Home')} />
+
+
+ );
+ }
+
if (!alert && !serverAlert) {
return (
@@ -357,6 +471,9 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
{stateSentence}
+ {lifetime ? (
+ {lifetime}
+ ) : null}
{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". */}
+
+
+
+ >
)}
{isAdmin ? (
@@ -400,35 +523,47 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
) : (
- subs.map(sub => (
-
+ 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 (
+
- {sub.deviceLabel || sub.deviceId}
+ {deviceName}
- {sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
+ {sub.platform ?? 'android'} · joined {joinedAgo}
+ {seenAgo ? ` · last seen ${seenAgo}` : ''}
{
+ 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}
>
- ))
+ );
+ })
)}
) : 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 (
@@ -535,11 +668,6 @@ function AdminControls({
}
deviceCount={deviceCount}
/>
- {autoStopSeconds ? (
-
- Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
-
- ) : null}
@@ -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({
This device
- {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.'}
- {alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
+ {deliverySummary(perms)}
@@ -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' },
});
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index df5acb3..b31fa84 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -58,6 +58,14 @@ import {
type Nav = NativeStackNavigationProp;
type Tab = 'admin' | 'subscribed';
+/** Plain-language ending lines for the list notice. */
+const ENDING_COPY: Record['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();
const { username, logout } = useAuth();
@@ -71,6 +79,8 @@ export default function HomeScreen() {
liveEvent,
delivery,
socketState,
+ lastEnding,
+ clearLastEnding,
} = useAlarm();
const [tab, setTab] = useState('admin');
@@ -144,6 +154,24 @@ export default function HomeScreen() {
>
{error ? : null}
+ {lastEnding ? (
+
+
+
+ {ENDING_COPY[lastEnding.reason]}
+
+ Ended alerts are removed from this list — that is the alert finishing, not data loss.
+ Tap to dismiss.
+
+
+
+
+ ) : null}
+
{loading && !list.length ? (
) : list.length === 0 ? (
@@ -151,7 +179,7 @@ export default function HomeScreen() {
- {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.'}
) : null}
@@ -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 },
});
diff --git a/src/screens/LiveAlarmScreen.tsx b/src/screens/LiveAlarmScreen.tsx
index cd893c7..86d6967 100644
--- a/src/screens/LiveAlarmScreen.tsx
+++ b/src/screens/LiveAlarmScreen.tsx
@@ -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
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 4588627..c6b7433 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -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;
const HEALTH_COPY: Record = {
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();
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(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(null);
const [notice, setNotice] = useState(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() {
Live delivery
{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". */}
+
requestPermission('notifications').then(setPerms)}
+ onFix={() => request('notifications')}
okText={HEALTH_COPY.notifications.ok}
title={HEALTH_COPY.notifications.title}
bad={HEALTH_COPY.notifications.bad}
/>
- openFullScreenIntentSettings().then(() =>
- requestPermission('fullScreenIntent').then(setPerms),
- )
- }
+ onFix={() => request('fullScreenIntent')}
okText={HEALTH_COPY.fullScreenIntent.ok}
title={HEALTH_COPY.fullScreenIntent.title}
bad={HEALTH_COPY.fullScreenIntent.bad}
/>
- openExactAlarmSettings().then(() => requestPermission('exactAlarm').then(setPerms))
- }
+ onFix={() => request('exactAlarm')}
okText={HEALTH_COPY.exactAlarm.ok}
title={HEALTH_COPY.exactAlarm.title}
bad={HEALTH_COPY.exactAlarm.bad}
/>
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() {
- {wsUrl ?? 'ws:// — no alert selected'}
-
- {(['maestro', 'direct'] as const).map(mode => (
- 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]}
- >
-
- {mode === 'maestro' ? 'Maestro proxy' : 'Direct'}
-
-
- ))}
-
- 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.
-
+
+
+ {wsUrl ?? 'ws:// — no alert selected'}
+
{!isRelease ? (
- 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
) : null}
@@ -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 },
});