mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: turn on push notifications on iOS through the relay's APNs transport
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).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<PushTransport | null> {
|
||||
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<PushTransport> {
|
||||
// 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<void> {
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<boolean> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<NotificationTapPayload | null> {
|
||||
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<string, unknown>).BulwarkFcm as
|
||||
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
|
||||
@@ -1524,8 +1645,47 @@ export async function getInitialNotificationTap(): Promise<NotificationTapPayloa
|
||||
export function addNotificationTapListener(
|
||||
listener: (payload: NotificationTapPayload) => 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<NotificationTapPayload | null> {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user