diff --git a/App.tsx b/App.tsx
index 456919c..4901d43 100644
--- a/App.tsx
+++ b/App.tsx
@@ -132,11 +132,9 @@ function Root() {
useEffect(() => {
if (token === prevToken.current) return;
prevToken.current = token;
- if (token) {
- navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
- } else {
- navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
- }
+ // Home is always reachable: without an account it is the subscriber view
+ // (rev 10). Login is an explicit destination, never a wall.
+ navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
}, [token, navigation]);
useNativeLaunchTakeover();
@@ -144,7 +142,7 @@ function Root() {
if (booting) return ;
return (
-
+
);
}
diff --git a/__tests__/deviceToken.test.ts b/__tests__/deviceToken.test.ts
new file mode 100644
index 0000000..c80367e
--- /dev/null
+++ b/__tests__/deviceToken.test.ts
@@ -0,0 +1,137 @@
+/**
+ * Rev 10: a subscriber is authenticated by a device token, not by an account
+ * and not by a fake user. The client must send it as `X-Device-Token` on every
+ * subscriber call — and it must win over an account token when both exist,
+ * because it is the narrower credential.
+ */
+import { configureApi, get, post } from '../src/services/apiClient';
+import { ackAlarm, alarmStatus, getAlert, joinAlert, qrImage } from '../src/services/alarmApi';
+
+const g = globalThis as unknown as { fetch: typeof fetch };
+const realFetch = g.fetch;
+
+interface Call {
+ url: string;
+ headers: Record;
+ body?: string;
+}
+
+let calls: Call[] = [];
+
+function stub(status = 200, body: unknown = {}): void {
+ calls = [];
+ g.fetch = (async (url: string, init: RequestInit) => {
+ calls.push({
+ url: String(url),
+ headers: (init?.headers ?? {}) as Record,
+ body: typeof init?.body === 'string' ? init.body : undefined,
+ });
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ text: async () => JSON.stringify(body),
+ json: async () => body,
+ };
+ }) as unknown as typeof fetch;
+}
+
+beforeEach(() => {
+ configureApi({
+ baseUrl: 'http://instance.test',
+ token: 'account-token',
+ refreshToken: null,
+ onUnauthorized: () => {},
+ onSessionUpdate: () => {},
+ });
+});
+
+afterEach(() => {
+ g.fetch = realFetch;
+});
+
+describe('device token transport', () => {
+ it('is sent as X-Device-Token and replaces the account token', async () => {
+ stub(200, {});
+ await get('/action/getAlert?alertId=a', 'dev-token');
+ expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
+ expect(calls[0].headers['X-Access-Token']).toBeUndefined();
+ });
+
+ it('is omitted (and the account token used) when not supplied', async () => {
+ stub(200, {});
+ await get('/action/getAlert?alertId=a');
+ expect(calls[0].headers['X-Device-Token']).toBeUndefined();
+ expect(calls[0].headers['X-Access-Token']).toBe('account-token');
+ });
+
+ it('survives a request with no session at all', async () => {
+ configureApi({ token: null, refreshToken: null });
+ stub(200, {});
+ await post('/action/ackAlarm', { alertId: 'a', eventId: 'e' }, false, 'dev-token');
+ expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
+ expect(calls[0].headers['X-Access-Token']).toBeUndefined();
+ });
+});
+
+describe('subscriber calls use the device token', () => {
+ it('getAlert passes it through', async () => {
+ stub(200, { alertId: 'a' });
+ await getAlert('a', 'dev-token');
+ expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
+ });
+
+ it('alarmStatus passes it through', async () => {
+ stub(200, { active: false });
+ await alarmStatus('a', undefined, 'dev-token');
+ expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
+ });
+
+ it('ackAlarm passes it through and omits deviceId when the token names the row', async () => {
+ stub(200, { acked: 1 });
+ await ackAlarm({ alertId: 'a', eventId: 'e', deviceToken: 'dev-token' });
+ expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
+ expect(calls[0].url).not.toContain('deviceId=');
+ });
+
+ it('qrImage passes it through', async () => {
+ g.fetch = (async (url: string, init: RequestInit) => {
+ calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record });
+ const blob = { data: 'png' };
+ return {
+ ok: true,
+ status: 200,
+ blob: async () => blob,
+ };
+ }) as unknown as typeof fetch;
+ const RealReader = (globalThis as { FileReader?: unknown }).FileReader;
+ class FakeReader {
+ result: string | null = null;
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ readAsDataURL() {
+ this.result = 'data:image/png;base64,cG5n';
+ this.onload?.();
+ }
+ }
+ (globalThis as { FileReader?: unknown }).FileReader = FakeReader;
+ try {
+ await qrImage('a', 'dev-token');
+ } finally {
+ (globalThis as { FileReader?: unknown }).FileReader = RealReader;
+ }
+ expect(calls[0].headers['X-Device-Token']).toBe('dev-token');
+ });
+});
+
+describe('joinAlert', () => {
+ it('is the unauthenticated path: no access token, and it returns the device token once', async () => {
+ configureApi({ token: null, refreshToken: null });
+ stub(200, { id: 'sub-1', deviceToken: 'issued-once' });
+ const joined = await joinAlert({ alertId: 'a', subscribeToken: 's', deviceId: 'd' });
+ expect(joined.deviceToken).toBe('issued-once');
+ expect(calls[0].headers['X-Access-Token']).toBeUndefined();
+ expect(calls[0].headers['X-Device-Token']).toBeUndefined();
+ // The payload wrapper the service expects, with no credential fields.
+ expect(JSON.parse(calls[0].body ?? '{}').payload).toMatchObject({ alertId: 'a', deviceId: 'd' });
+ });
+});
diff --git a/src/screens/AlertDetailScreen.tsx b/src/screens/AlertDetailScreen.tsx
index 81e739a..8532b23 100644
--- a/src/screens/AlertDetailScreen.tsx
+++ b/src/screens/AlertDetailScreen.tsx
@@ -255,7 +255,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
setNotice('The service URL is not known yet — re-open the alert in a moment.');
return;
}
- const wsUrl = alarmSocketUrl(resolved, alertId, token);
+ const wsUrl = alarmSocketUrl(resolved, alertId, token, alert?.deviceToken);
if (!wsUrl) {
setNotice(
'No instance address is configured, so background listening is unavailable.',
@@ -269,7 +269,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
? 'Listening again — this device is armed.'
: `Could not start listening (${res.reason ?? 'unknown'}).`,
);
- }, [alertId, getAlert, refreshListening, resolved]);
+ }, [alert?.deviceToken, alertId, getAlert, refreshListening, resolved]);
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
isAdmin ? alertId : null,
@@ -279,7 +279,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
// Read the authoritative state on mount; the local copy is only a cache.
const loadServerAlert = useCallback(async () => {
try {
- const fresh = await api.getAlert(alertId);
+ const fresh = await api.getAlert(alertId, alert?.deviceToken);
setServerAlert(fresh);
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
} catch (e) {
@@ -296,7 +296,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
}
// Offline is fine — the cached copy renders.
}
- }, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]);
+ }, [
+ alert?.deviceToken,
+ alert?.expiresAt,
+ alertId,
+ markEnded,
+ serverAlert?.expiresAt,
+ updateAlertLocal,
+ ]);
useEffect(() => {
setActiveAlertId(isAdmin ? null : alertId);
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index b31fa84..10ab6ff 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -68,7 +68,7 @@ const ENDING_COPY: Record['lastEnding']>
export default function HomeScreen() {
const navigation = useNavigation