diff --git a/locales/rn/en.json b/locales/rn/en.json index fbd4f959..2b932ba7 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -639,6 +639,12 @@ "up_to_date": "Already up to date" } }, + "push": { + "ios": { + "more_messages": "and {count} more", + "no_subject": "(no subject)" + } + }, "push_prompt": { "body": "Turn on background notifications so new messages reach you while the app is closed.", "dismiss": "Not now", @@ -1008,6 +1014,8 @@ "confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.", "description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", "err_android_only": "Push notifications are only available on Android.", + "err_apns_empty": "iOS returned an empty push token.", + "err_apns_token": "iOS could not issue a push token ({detail}).", "err_fcm_empty": "Firebase returned an empty device token.", "err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.", "err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.", @@ -1015,6 +1023,7 @@ "err_no_account": "No account loaded - cannot set up push.", "err_permission": "Notification permission was not granted.", "err_relay_https": "The relay URL must use https://.", + "err_relay_no_apns": "The push relay at {url} does not deliver to iPhones and iPads yet.", "err_relay_rejected": "The push relay rejected the registration: {detail}", "err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).", "err_up_android_only": "UnifiedPush is only available on Android.", diff --git a/src/components/AppIconBadge.tsx b/src/components/AppIconBadge.tsx index fd5a5c4a..2cbc4213 100644 --- a/src/components/AppIconBadge.tsx +++ b/src/components/AppIconBadge.tsx @@ -1,21 +1,50 @@ import { useEffect } from 'react'; +import { Platform } from 'react-native'; import { useEmailStore } from '../stores/email-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useLocaleStore } from '../stores/locale-store'; +import { useAuthStore } from '../stores/auth-store'; import { appBadgeCount, setAppBadge } from '../lib/app-badge'; +import { jmapClient } from '../api/jmap-client'; +import { writeIosPushSettings } from '../lib/ios-push-shared'; /** * Keeps the app icon badge on the active account's inbox unread count (see * `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last * account signed out, so the badge is cleared then. Renders nothing. + * + * On iOS it also tells the notification extension (plugins/ios-push) which + * account the badge follows and the few texts it writes itself, so a push + * that arrives while the app is closed updates the badge the same way. */ export function AppIconBadge() { const enabled = useSettingsStore((s) => s.appIconUnreadBadge); const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled)); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const t = useLocaleStore((s) => s.t); useEffect(() => { void setAppBadge(count); }, [count]); + useEffect(() => { + if (Platform.OS !== 'ios') return; + let jmapAccountId: string | null = null; + try { + jmapAccountId = jmapClient.accountId; + } catch { + jmapAccountId = null; + } + void writeIosPushSettings({ + badge: enabled, + activeAccountId: jmapAccountId, + strings: { + noSubject: t('push.ios.no_subject', '(no subject)'), + more: t('push.ios.more_messages', 'and {count} more'), + }, + }).catch(() => undefined); + }, [enabled, activeAccountId, t]); + useEffect(() => () => { void setAppBadge(0); }, []); return null; diff --git a/src/lib/__tests__/ios-push-tap.test.ts b/src/lib/__tests__/ios-push-tap.test.ts new file mode 100644 index 00000000..98aa33b7 --- /dev/null +++ b/src/lib/__tests__/ios-push-tap.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { iosTapPayload } from '../push-notifications'; + +// A tapped iOS mail notification: the relay's ids arrive as the +// notification's data (APNs `body`), the notification extension adds the +// message it showed, and the app opens that message under the account whose +// push setup recorded the JMAP account id. + +describe('iosTapPayload', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' })); + }); + + it('opens the message the extension showed, in the right account', async () => { + const payload = await iosTapPayload({ + kind: 'jmap-email-push', + accountId: 'c', + emailIds: ['e1', 'e2'], + emailId: 'e2', + threadId: 't2', + }); + expect(payload).toEqual({ emailId: 'e2', threadId: 't2', accountId: 'ada@mail.example.org', jmapAccountId: 'c' }); + }); + + it('falls back to the first pushed id when the extension could not fetch', async () => { + const payload = await iosTapPayload({ kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] }); + expect(payload?.emailId).toBe('e1'); + expect(payload?.threadId).toBe(''); + }); + + it('leaves calendar reminders and other local notifications alone', async () => { + expect(await iosTapPayload({ eventId: 'ev1', calendarId: 'cal' })).toBeNull(); + expect(await iosTapPayload(null)).toBeNull(); + }); + + it('does nothing for a push without a message id', async () => { + expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull(); + }); +}); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index ddc58c04..1c923466 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -29,6 +29,9 @@ import { persistedAccounts, } from '../device-sync/app/prefs'; import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../device-sync/types'; +import * as Notifications from 'expo-notifications'; +import { credentialsKey } from '../api/jmap-client'; +import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared'; import { getUnifiedPushDistributors, isUnifiedPushSupported, @@ -524,12 +527,12 @@ function getNative(): BulwarkFcmNative | null { return (NativeModules as Record).BulwarkFcm as BulwarkFcmNative | undefined ?? null; } -/** True on platforms that have a push transport wired up (Android only). */ +/** True on platforms that have a push transport wired up: FCM or UnifiedPush on Android, APNs on iOS. */ export function isPushSupported(): boolean { - return getNative() !== null || isUnifiedPushSupported(); + return Platform.OS === 'ios' || getNative() !== null || isUnifiedPushSupported(); } -export type PushTransport = 'fcm' | 'unifiedpush'; +export type PushTransport = 'fcm' | 'unifiedpush' | 'apns'; export async function getStoredPushTransport(): Promise { const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY); @@ -545,6 +548,8 @@ export async function setStoredPushTransport(transport: PushTransport | null): P } export async function getEffectivePushTransport(): Promise { + // iOS has one way in: APNs, through the relay. + if (Platform.OS === 'ios') return 'apns'; return (await getStoredPushTransport()) ?? 'fcm'; } @@ -637,6 +642,14 @@ export async function dismissPushPrompt(accountId: string): Promise { } export async function requestNotificationPermission(): Promise { + if (Platform.OS === 'ios') { + const current = await Notifications.getPermissionsAsync(); + if (current.granted) return true; + const asked = await Notifications.requestPermissionsAsync({ + ios: { allowAlert: true, allowBadge: true, allowSound: true }, + }); + return asked.granted; + } if (Platform.OS !== 'android') return true; if (Platform.Version < 33) return true; const status = await PermissionsAndroid.request( @@ -688,6 +701,26 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise { return token; } +// The APNs device token (hex). iOS hands it out once notifications are +// allowed; expo-notifications asks the OS for it. +async function getApnsTokenOrThrow(): Promise { + let token: string | null = null; + try { + const result = await Notifications.getDevicePushTokenAsync(); + token = typeof result.data === 'string' ? result.data : null; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError( + 'token', + t('settings.notifications.push.err_apns_token', 'iOS could not issue a push token ({detail}).', { detail }), + ); + } + if (!token) { + throw new PushSetupError('token', t('settings.notifications.push.err_apns_empty', 'iOS returned an empty push token.')); + } + return token; +} + /** * The relay's VAPID public key, when it has one. Passed to the UnifiedPush * distributor at registration so distributors that pin the application server @@ -778,6 +811,64 @@ async function registerWithRelay(params: { } } +async function registerWithRelayApns(params: { + relayBaseUrl: string; + subscriptionId: string; + deviceToken: string; + accountLabel?: string; +}): Promise { + let res: Response; + try { + res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/apns'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: params.subscriptionId, + deviceToken: params.deviceToken, + environment: apnsEnvironment(), + accountLabel: params.accountLabel, + }), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError( + 'relay', + t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', { + url: params.relayBaseUrl, + detail, + }), + ); + } + if (res.status === 503) { + throw new PushSetupError( + 'relay', + t('settings.notifications.push.err_relay_no_apns', 'The push relay at {url} does not deliver to iPhones and iPads yet.', { + url: params.relayBaseUrl, + }), + ); + } + if (!res.ok) { + throw new PushSetupError( + 'relay', + t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', { + detail: await readRelayError(res), + }), + ); + } +} + +/** + * Tell the iOS notification extension how to reach an account: which stored + * credentials to use and the JMAP API URL. Without it the extension shows the + * relay's placeholder text. + */ +async function shareAccountWithExtension(accountId: string, jmapAccountId: string | null): Promise { + if (Platform.OS !== 'ios' || !jmapAccountId) return; + const apiUrl = jmapClient.currentSession?.apiUrl; + if (!apiUrl) return; + await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined); +} + async function registerWithRelayUnifiedPush(params: { relayBaseUrl: string; subscriptionId: string; @@ -948,6 +1039,9 @@ async function setupPushNotificationsInner( if (transport === 'fcm' && !native) { throw new PushSetupError('platform', t('settings.notifications.push.err_android_only', 'Push notifications are only available on Android.')); } + if (transport === 'apns' && Platform.OS !== 'ios') { + throw new PushSetupError('platform', 'APNs is only available on iOS.'); + } if (transport === 'unifiedpush' && !isUnifiedPushSupported()) { throw new PushSetupError('platform', t('settings.notifications.push.err_up_android_only', 'UnifiedPush is only available on Android.')); } @@ -967,8 +1061,11 @@ async function setupPushNotificationsInner( logPhase('token', transport); let fcmToken: string | null = null; let upEndpoint: UnifiedPushEndpoint | null = null; + let apnsToken: string | null = null; if (transport === 'unifiedpush') { upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl); + } else if (transport === 'apns') { + apnsToken = await getApnsTokenOrThrow(); } else { fcmToken = await getFcmTokenOrThrow(native!); } @@ -993,7 +1090,14 @@ async function setupPushNotificationsInner( // individually and tags it with the JMAP account id so the headless task // can route it. logPhase('relay', relayBaseUrl); - if (upEndpoint) { + if (apnsToken) { + await registerWithRelayApns({ + relayBaseUrl, + subscriptionId: deviceClientId, + deviceToken: apnsToken, + accountLabel: params.accountLabel, + }); + } else if (upEndpoint) { await registerWithRelayUnifiedPush({ relayBaseUrl, subscriptionId: deviceClientId, @@ -1043,6 +1147,7 @@ async function setupPushNotificationsInner( await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); + await shareAccountWithExtension(accountId, jmapAccountId); await AsyncStorage.removeItem(optedOutKey(accountId)); logPhase('done', 'reused existing subscription'); return { subscriptionId: storedServerId, verified: true }; @@ -1125,6 +1230,7 @@ async function setupPushNotificationsInner( } await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); + await shareAccountWithExtension(accountId, jmapAccountId); await AsyncStorage.removeItem(optedOutKey(accountId)); logPhase('done', 'subscription verified'); @@ -1196,6 +1302,10 @@ async function deregisterFromRelay( } async function clearAccountPushKeys(accountId: string): Promise { + if (Platform.OS === 'ios') { + const jmapAccountId = (await readPushJmapAccountIds())[accountId]; + if (jmapAccountId) await removeIosPushAccount(jmapAccountId); + } await AsyncStorage.multiRemove([ subscriptionIdKey(accountId), deviceClientIdKey(accountId), @@ -1509,6 +1619,17 @@ export function notificationTapJmapAccountId(payload: NotificationTapPayload): s // before JS was ready to handle it. Subsequent taps while running are delivered // via addNotificationTapListener. export async function getInitialNotificationTap(): Promise { + if (Platform.OS === 'ios') { + const response = await Notifications.getLastNotificationResponseAsync().catch(() => null); + if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return null; + const handledKey = 'push:lastHandledResponse:v1'; + const id = response.notification.request.identifier; + // The last response survives app restarts; open it once. + if ((await AsyncStorage.getItem(handledKey)) === id) return null; + const payload = await iosTapPayload(response.notification.request.content.data); + if (payload) await AsyncStorage.setItem(handledKey, id); + return payload; + } if (Platform.OS !== 'android') return null; const native = (NativeModules as Record).BulwarkFcm as | { getInitialNotification?: () => Promise } @@ -1524,8 +1645,47 @@ export async function getInitialNotificationTap(): Promise void, ): () => void { + if (Platform.OS === 'ios') { + const sub = Notifications.addNotificationResponseReceivedListener((response) => { + if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return; + const id = response.notification.request.identifier; + void iosTapPayload(response.notification.request.content.data).then((payload) => { + if (!payload) return; + void AsyncStorage.setItem('push:lastHandledResponse:v1', id); + listener(payload); + }); + }); + return () => sub.remove(); + } if (Platform.OS !== 'android') return () => undefined; const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm); const sub = emitter.addListener('fcm:notificationTap', listener); return () => sub.remove(); } + +/** + * A tapped iOS mail notification as the app opens it. The relay puts the + * JMAP account and message ids into the payload's `body`, which + * expo-notifications hands over as `data`; the notification extension adds + * the message (and thread) it showed. Calendar reminders and other local + * notifications carry no `kind` and are left to their own handlers. + */ +export async function iosTapPayload(data: unknown): Promise { + if (!data || typeof data !== 'object') return null; + const d = data as Record; + if (d.kind !== 'jmap-email-push' && d.kind !== 'jmap-state-change') return null; + const jmapAccountId = typeof d.accountId === 'string' ? d.accountId : ''; + const ids = Array.isArray(d.emailIds) ? d.emailIds.filter((v): v is string => typeof v === 'string') : []; + const emailId = typeof d.emailId === 'string' && d.emailId ? d.emailId : ids[0]; + if (!emailId) return null; + // The local account the subscription belongs to: the one whose push + // setup recorded this JMAP account id. + const byLocal = await readPushJmapAccountIds(); + const accountId = Object.keys(byLocal).find((local) => byLocal[local] === jmapAccountId); + return { + emailId, + threadId: typeof d.threadId === 'string' ? d.threadId : '', + accountId, + jmapAccountId: jmapAccountId || undefined, + }; +}