Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AppKeyboardProvider,
AppThemeProvider,
} from '@mobile/components/shell/app-providers';
import { NotificationObserver } from '@mobile/components/shell/notification-observer';
import { MobileProductAnalyticsProvider } from '@mobile/components/shell/product-analytics-provider';
import { RootNavigator } from '@mobile/components/shell/root-navigator';
import { ThemeController } from '@mobile/components/shell/theme-controller';
Expand Down Expand Up @@ -84,6 +85,7 @@ function RootLayout() {
]}
>
<ThemeController />
<NotificationObserver />
<RootNavigator />
</ComposeContextProvider>
</GestureHandlerRootView>
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/app/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DevicesSection } from '@mobile/components/account/devices-section';
import { ProfileRow } from '@mobile/components/account/profile-row';
import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account';
import { Redirect, Stack } from 'expo-router';
import { Alert } from 'react-native';
import { useTranslations } from 'use-intl';

/** Account screen: profile, the account's device registry, and sign-out. */
Expand Down Expand Up @@ -30,9 +31,7 @@ export default function AccountScreen(): React.ReactNode {
<Button
role="destructive"
label={t('signOut')}
onPress={() => {
void signOutOfCloud();
}}
onPress={() => signOutOfCloud().catch(() => Alert.alert(t('signOutError')))}
/>
</Section>
</>
Expand Down
13 changes: 3 additions & 10 deletions apps/mobile/src/components/account/devices-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import { badge, buttonStyle, disabled, foregroundStyle } from '@expo/ui/swift-ui
import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles';
import { signOutOfCloud } from '@mobile/runtime/cloud/account';
import type { CloudDevice } from '@mobile/runtime/cloud/devices';
import {
clearDeviceEnrollment,
fetchDevices,
getEnrolledDeviceId,
revokeDevice,
} from '@mobile/runtime/cloud/devices';
import { fetchDevices, getEnrolledDeviceId, revokeDevice } from '@mobile/runtime/cloud/devices';
import { formatRelativeShort } from '@mobile/utils/relative-time';
import { noop } from 'foxact/noop';
import { useCallback, useEffect, useState } from 'react';
Expand Down Expand Up @@ -58,10 +53,8 @@ export function DevicesSection(): React.ReactNode {
try {
await revokeDevice(device.id);
if (device.id === enrolledId) {
// The cloud already killed this phone's sessions along with the device; the
// sign-out is local cookie/enrollment cleanup against a dead session.
await clearDeviceEnrollment().catch(noop);
await signOutOfCloud().catch(noop);
// The device revoke already removed its push token and killed this phone's sessions.
await signOutOfCloud({ revokePushToken: false }).catch(noop);
return;
}
refresh();
Expand Down
57 changes: 55 additions & 2 deletions apps/mobile/src/components/settings/settings-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { Form, Host, Link, Picker, Section, Text, Toggle, VStack } from '@expo/ui/swift-ui';
import { font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers';
import { disabled, font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers';
import { AgentKindSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema';
import { NavigationRow } from '@mobile/components/form/navigation-row';
import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items';
import { useCloudAccount } from '@mobile/runtime/cloud/account';
import {
disableDeviceNotifications,
enableDeviceNotifications,
} from '@mobile/runtime/notifications';
import { setMobileProductAnalyticsEnabled } from '@mobile/runtime/product-analytics';
import { useAnalyticsPreferenceStore } from '@mobile/stores/analytics-store';
import type { ThemePreference } from '@mobile/stores/settings-store';
import { useSettingsStore } from '@mobile/stores/settings-store';
import { Stack, useRouter } from 'expo-router';
import { View } from 'react-native';
import { useRef, useState } from 'react';
import { Alert, Linking, View } from 'react-native';
import { useTranslations } from 'use-intl';

const THEME_PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark'];
Expand Down Expand Up @@ -37,9 +42,39 @@ export function SettingsScreen(): React.ReactNode {
const hostMenuItems = useHostMenuItems();
const productAnalyticsEnabled = useAnalyticsPreferenceStore((state) => state.enabled);
const themePreference = useSettingsStore((state) => state.themePreference);
const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled);
const setThemePreference = useSettingsStore((state) => state.setThemePreference);
const keepHostsConnected = useSettingsStore((state) => state.keepHostsConnected);
const setKeepHostsConnected = useSettingsStore((state) => state.setKeepHostsConnected);
const [notificationUpdatePending, setNotificationUpdatePending] = useState(false);
const notificationUpdatePendingRef = useRef(false);

const updateNotifications = async (enabled: boolean) => {
if (account.status !== 'signed-in' || notificationUpdatePendingRef.current) return;
notificationUpdatePendingRef.current = true;
setNotificationUpdatePending(true);
try {
if (!enabled) {
await disableDeviceNotifications();
return;
}
if (await enableDeviceNotifications(account.user.id)) return;
Alert.alert(t('notificationsDeniedTitle'), t('notificationsDenied'), [
{ text: t('cancel'), style: 'cancel' },
{
text: t('openSettings'),
onPress() {
void Linking.openSettings();
},
},
]);
} catch {
Alert.alert(t('notificationsErrorTitle'), t('notificationsError'));
} finally {
notificationUpdatePendingRef.current = false;
setNotificationUpdatePending(false);
}
};

// The flex container is load-bearing: a SwiftUI host left as the screen's direct child is
// proposed the whole window and paints straight over the large title.
Expand Down Expand Up @@ -103,6 +138,24 @@ export function SettingsScreen(): React.ReactNode {
/>
</Section>

<Section
title={t('notifications')}
footer={
<Text>
{account.status === 'signed-in'
? t('notificationsHint')
: t('notificationsRequiresCloud')}
</Text>
}
>
<Toggle
isOn={notificationsEnabled}
onIsOnChange={updateNotifications}
Comment thread
Zerlight marked this conversation as resolved.
label={t('notifications')}
modifiers={[disabled(account.status !== 'signed-in' || notificationUpdatePending)]}
/>
</Section>

{/* Native links open the URL themselves — no Linking.openURL fallback to get wrong. */}
<Section title={t('legalAndSupport')}>
<Link label={t('privacyPolicy')} destination={PRIVACY_POLICY_URL} />
Expand Down
70 changes: 70 additions & 0 deletions apps/mobile/src/components/shell/notification-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useCloudAccount } from '@mobile/runtime/cloud/account';
import { resolveNotificationRoute } from '@mobile/runtime/notification-route';
import { activateNotificationSync, syncDevicePushToken } from '@mobile/runtime/notifications';
import { useHostRegistryHydrated, useHostRegistryStore } from '@mobile/stores/host-store';
import { useSettingsStore } from '@mobile/stores/settings-store';
import * as Sentry from '@sentry/react-native';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
import { useEffect, useEffectEvent } from 'react';
import { AppState } from 'react-native';

export function NotificationObserver(): null {
const router = useRouter();
const account = useCloudAccount();
const hydrated = useHostRegistryHydrated();
const hosts = useHostRegistryStore((state) => state.hosts);
const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId);
const enabled = useSettingsStore((state) => state.notificationsEnabled);
const userId = account.status === 'signed-in' ? account.user.id : null;

const openNotification = useEffectEvent((response: Notifications.NotificationResponse) => {
try {
const target = resolveNotificationRoute(response.notification.request.content.data, hosts);
if (target?.type === 'session') {
setLastActiveHostId(target.hostId);
router.push({
pathname: '/session/[sessionId]',
params: { sessionId: target.sessionId },
});
} else if (target?.type === 'connect') {
router.push('/connect');
}
} finally {
Notifications.clearLastNotificationResponse();
}
});

useEffect(() => {
if (!hydrated) return;

Comment thread
Zerlight marked this conversation as resolved.
const initial = Notifications.getLastNotificationResponse();
if (initial) openNotification(initial);
const subscription = Notifications.addNotificationResponseReceivedListener(openNotification);
return () => subscription.remove();
}, [hydrated]);

useEffect(() => {
if (!enabled || !userId) return;

const deactivate = activateNotificationSync(userId);
const sync = (devicePushToken?: Notifications.DevicePushToken) => {
syncDevicePushToken(userId, devicePushToken).catch((error: unknown) =>
Sentry.captureException(error),
);
};

sync();
const tokenSubscription = Notifications.addPushTokenListener(sync);
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') sync();
});
return () => {
deactivate();
tokenSubscription.remove();
appStateSubscription.remove();
};
}, [enabled, userId]);

return null;
}
19 changes: 19 additions & 0 deletions apps/mobile/src/runtime/__tests__/notification-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { resolveNotificationRoute } from '../notification-route';

describe('resolveNotificationRoute', () => {
const hosts = [{ id: 'local host', tunnelHostId: 'tunnel-1' }];

it('routes known tunnel hosts, falls back for unknown hosts, and rejects invalid data', () => {
expect(
resolveNotificationRoute({ tunnelHostId: 'tunnel-1', sessionId: 'session-1' }, hosts),
).toEqual({ type: 'session', hostId: 'local host', sessionId: 'session-1' });
expect(
resolveNotificationRoute({ tunnelHostId: 'tunnel-2', sessionId: 'session-2' }, hosts),
).toEqual({ type: 'connect' });
expect(resolveNotificationRoute({ tunnelHostId: 'tunnel-1' }, hosts)).toBeNull();
expect(
resolveNotificationRoute({ hostId: 'tunnel-1', sessionId: 'session-1' }, hosts),
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { noop } from 'foxts/noop';
import { describe, expect, it } from 'vitest';
import { createNotificationTokenCoordinator } from '../notification-token-coordinator';

describe('notification token coordination', () => {
it('drops a token acquired after notifications are disabled', async () => {
const coordinator = createNotificationTokenCoordinator();
const events: string[] = [];
let releaseToken!: () => void;
let markAcquiring!: () => void;
const tokenGate = new Promise<void>((resolve) => {
releaseToken = resolve;
});
const acquiring = new Promise<void>((resolve) => {
markAcquiring = resolve;
});

coordinator.selectUser('user-1');
const sync = coordinator.sync(
'user-1',
async () => {
events.push('acquire');
markAcquiring();
await tokenGate;
return 'token';
},
() => {
events.push('register');
},
);
await acquiring;
coordinator.selectUser(null);
const revoke = coordinator.revoke(
() => {
events.push('revoke');
},
() => {
events.push('unregister');
},
);
releaseToken();

await Promise.all([sync, revoke]);
expect(events).toEqual(['acquire', 'revoke', 'unregister']);
});

it('runs revocation after an in-flight registration', async () => {
const coordinator = createNotificationTokenCoordinator();
const events: string[] = [];
let releaseRegistration!: () => void;
let markRegistering!: () => void;
const registrationGate = new Promise<void>((resolve) => {
releaseRegistration = resolve;
});
const registering = new Promise<void>((resolve) => {
markRegistering = resolve;
});

coordinator.selectUser('user-1');
const sync = coordinator.sync(
'user-1',
() => 'token',
async () => {
events.push('register:start');
markRegistering();
await registrationGate;
events.push('register:end');
},
);
await registering;
coordinator.selectUser(null);
const revoke = coordinator.revoke(
() => {
events.push('revoke');
},
() => {
events.push('unregister');
},
);
releaseRegistration();

await Promise.all([sync, revoke]);
expect(events).toEqual(['register:start', 'register:end', 'revoke', 'unregister']);
});

it('fails revocation only when both the server and native token removal fail', async () => {
const coordinator = createNotificationTokenCoordinator();
coordinator.selectUser(null);

await expect(
coordinator.revoke(() => Promise.reject(new Error('server failed')), noop),
).resolves.toBeUndefined();
await expect(
coordinator.revoke(
() => Promise.reject(new Error('server failed')),
() => Promise.reject(new Error('native failed')),
),
).rejects.toThrow('server failed');
});
});
28 changes: 22 additions & 6 deletions apps/mobile/src/runtime/cloud/account.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { noop } from 'foxact/noop';
import { disableDeviceNotifications } from '@mobile/runtime/notifications';
import { falseFn, noop, trueFn } from 'foxts/noop';
import { cloudAuthClient } from './client';
import { clearDeviceEnrollment } from './devices';

Expand Down Expand Up @@ -38,9 +39,24 @@ export async function signInToCloud(): Promise<void> {
if (error) throw new Error(`sign-in failed (${error.status})`);
}

export async function signOutOfCloud(): Promise<void> {
await cloudAuthClient.signOut();
// Forget the enrollment so a different account signing in on this phone
// registers the device under itself instead of silently skipping.
await clearDeviceEnrollment().catch(noop);
export async function signOutOfCloud(options: { revokePushToken?: boolean } = {}): Promise<void> {
let pushDeliveryDisabled = false;
let signedOut = false;
try {
pushDeliveryDisabled = await disableDeviceNotifications({
revokeToken: options.revokePushToken,
rollbackOnFailure: false,
})
.then(trueFn)
.catch(falseFn);
const { error } = await cloudAuthClient.signOut();
if (error) throw new Error(`sign-out failed (${error.status})`);
signedOut = true;
} finally {
// Retain enrollment whenever a still-live device binding may need recovery after sign-out.
const deviceAlreadyRevoked = options.revokePushToken === false;
if (pushDeliveryDisabled && (signedOut || deviceAlreadyRevoked)) {
await clearDeviceEnrollment().catch(noop);
}
}
}
Loading
Loading