mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
feat: add UnifiedPush as an alternative to FCM for push notifications
This commit is contained in:
@@ -29,9 +29,18 @@ import {
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
getStoredPushTransport,
|
||||
setStoredPushTransport,
|
||||
type PushDevice,
|
||||
type PushSetupPhase,
|
||||
type PushTransport,
|
||||
} from '../../lib/push-notifications';
|
||||
import {
|
||||
getSavedUnifiedPushDistributor,
|
||||
getUnifiedPushDistributors,
|
||||
isUnifiedPushSupported,
|
||||
saveUnifiedPushDistributor,
|
||||
} from '../../lib/unified-push';
|
||||
|
||||
type PushStatus =
|
||||
| { kind: 'idle' }
|
||||
@@ -48,7 +57,8 @@ type DevicesState =
|
||||
const PHASE_KEYS: Record<PushSetupPhase, { key: string; fallback: string }> = {
|
||||
platform: { key: 'settings.notifications.push.phase_platform', fallback: 'Platform' },
|
||||
permission: { key: 'settings.notifications.push.phase_permission', fallback: 'Notification permission' },
|
||||
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token (Firebase)' },
|
||||
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token' },
|
||||
distributor: { key: 'settings.notifications.push.phase_distributor', fallback: 'UnifiedPush distributor' },
|
||||
account: { key: 'settings.notifications.push.phase_account', fallback: 'Account' },
|
||||
relay: { key: 'settings.notifications.push.phase_relay', fallback: 'Relay registration' },
|
||||
jmap: { key: 'settings.notifications.push.phase_jmap', fallback: 'Mail server subscription' },
|
||||
@@ -71,7 +81,11 @@ export function NotificationSettings() {
|
||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
|
||||
const supported = isPushSupported();
|
||||
const upSupported = isUnifiedPushSupported();
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const [transport, setTransport] = useState<PushTransport>('fcm');
|
||||
const [distributors, setDistributors] = useState<string[]>([]);
|
||||
const [savedDistributor, setSavedDistributor] = useState<string | null>(null);
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
@@ -92,8 +106,16 @@ export function NotificationSettings() {
|
||||
if (stored) setRelayUrl(stored);
|
||||
setPushEnabled(enabled);
|
||||
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
|
||||
const storedTransport = await getStoredPushTransport();
|
||||
const upDistributors = upSupported ? await getUnifiedPushDistributors() : [];
|
||||
const upSaved = upSupported ? await getSavedUnifiedPushDistributor() : null;
|
||||
if (cancelled) return;
|
||||
setTransport(storedTransport ?? 'fcm');
|
||||
setDistributors(upDistributors);
|
||||
setSavedDistributor(upSaved);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeAccountId]);
|
||||
|
||||
const trimmed = relayUrl.trim().replace(/\/+$/, '');
|
||||
@@ -155,6 +177,27 @@ export function NotificationSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Switching the transport while push is on re-registers immediately so the
|
||||
// relay record flips to the new delivery path; while off it just records
|
||||
// the preference for the next enable.
|
||||
const handleSelectTransport = async (next: PushTransport) => {
|
||||
if (next === transport || busy) return;
|
||||
setTransport(next);
|
||||
await setStoredPushTransport(next);
|
||||
if (pushEnabled) void handleEnable(true);
|
||||
};
|
||||
|
||||
const handleSelectDistributor = async (distributor: string) => {
|
||||
if (distributor === savedDistributor || busy) return;
|
||||
setSavedDistributor(distributor);
|
||||
try {
|
||||
await saveUnifiedPushDistributor(distributor);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (pushEnabled && transport === 'unifiedpush') void handleEnable(true);
|
||||
};
|
||||
|
||||
const performDisable = async () => {
|
||||
setConfirmDisable(false);
|
||||
setPushStatus({
|
||||
@@ -239,6 +282,68 @@ export function NotificationSettings() {
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{upSupported && (
|
||||
<>
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.transport_label', 'Delivery method')}
|
||||
description={t(
|
||||
'settings.notifications.push.transport_desc',
|
||||
'How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<Button
|
||||
variant={transport === 'fcm' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onPress={() => void handleSelectTransport('fcm')}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.notifications.push.transport_fcm', 'Google (FCM)')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={transport === 'unifiedpush' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onPress={() => void handleSelectTransport('unifiedpush')}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.notifications.push.transport_unifiedpush', 'UnifiedPush')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
{transport === 'unifiedpush' && distributors.length === 0 && (
|
||||
<Text style={styles.errorText}>
|
||||
{t(
|
||||
'settings.notifications.push.up_no_distributor',
|
||||
'No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{transport === 'unifiedpush' && distributors.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.up_distributor_label', 'Distributor')}
|
||||
description={t(
|
||||
'settings.notifications.push.up_distributor_desc',
|
||||
'Several UnifiedPush distributor apps are installed - pick the one that should deliver notifications.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.distributorList}>
|
||||
{distributors.map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant={d === savedDistributor ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onPress={() => void handleSelectDistributor(d)}
|
||||
disabled={busy}
|
||||
>
|
||||
{d}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</SettingItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.relay_label', 'Push relay')}
|
||||
description={t(
|
||||
@@ -450,6 +555,7 @@ function makeStyles(c: ThemePalette) {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
urlInputInvalid: { borderColor: c.error },
|
||||
distributorList: { flexDirection: 'column', alignItems: 'flex-end', gap: spacing.sm },
|
||||
errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm },
|
||||
errorText: { ...typography.caption, color: c.error },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
|
||||
|
||||
@@ -17,6 +17,19 @@ vi.mock('react-native', () => {
|
||||
getToken: vi.fn(async () => 'fcm-token-xyz'),
|
||||
deleteToken: vi.fn(async () => undefined),
|
||||
},
|
||||
BulwarkUnifiedPush: {
|
||||
getDistributors: vi.fn(async () => ['io.heckel.ntfy']),
|
||||
getSavedDistributor: vi.fn(async () => 'io.heckel.ntfy'),
|
||||
getAckDistributor: vi.fn(async () => 'io.heckel.ntfy'),
|
||||
saveDistributor: vi.fn(async () => undefined),
|
||||
register: vi.fn(async () => undefined),
|
||||
unregister: vi.fn(async () => undefined),
|
||||
getEndpoint: vi.fn(async () => ({
|
||||
url: 'https://ntfy.sh/upAbCdEf?up=1',
|
||||
p256dh: 'B'.repeat(87),
|
||||
auth: 'a'.repeat(22),
|
||||
})),
|
||||
},
|
||||
},
|
||||
NativeEventEmitter,
|
||||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||||
@@ -273,6 +286,103 @@ describe('setupPushNotifications subscription shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications over UnifiedPush', () => {
|
||||
type UpNative = {
|
||||
getDistributors: ReturnType<typeof vi.fn>;
|
||||
getSavedDistributor: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
getEndpoint: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const upNative = () => (NativeModules as { BulwarkUnifiedPush: UpNative }).BulwarkUnifiedPush;
|
||||
|
||||
// Like installFetch, plus the two UnifiedPush relay endpoints; captures the
|
||||
// registration body so tests can assert on it.
|
||||
function installUpFetch(): { body: () => Record<string, unknown> | null } {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/vapid-public-key')) {
|
||||
return { ok: true, status: 200, json: async () => ({ publicKey: 'VAPID-PUB' }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/register/unifiedpush')) {
|
||||
captured = JSON.parse(String(init?.body ?? 'null')) as Record<string, unknown>;
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
return { body: () => captured };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
await AsyncStorage.setItem('push:transport:v1', 'unifiedpush');
|
||||
listMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('registers the distributor endpoint with the relay instead of an FCM token', async () => {
|
||||
const relayReg = installUpFetch();
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
|
||||
expect(native.getToken).not.toHaveBeenCalled();
|
||||
// The relay's VAPID key is handed to the distributor at registration.
|
||||
expect(upNative().register).toHaveBeenCalledWith('VAPID-PUB');
|
||||
expect(relayReg.body()).toEqual({
|
||||
subscriptionId: OUR_DCID,
|
||||
endpoint: 'https://ntfy.sh/upAbCdEf?up=1',
|
||||
keys: { p256dh: 'B'.repeat(87), auth: 'a'.repeat(22) },
|
||||
accountLabel: undefined,
|
||||
});
|
||||
// The JMAP subscription flow is transport-independent.
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
|
||||
upNative().getEndpoint.mockResolvedValue({
|
||||
url: 'https://legacy.example/up123456',
|
||||
p256dh: null,
|
||||
auth: null,
|
||||
});
|
||||
const relayReg = installUpFetch();
|
||||
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(relayReg.body()).toMatchObject({
|
||||
endpoint: 'https://legacy.example/up123456',
|
||||
keys: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with the distributor phase when no distributor is installed', async () => {
|
||||
upNative().getDistributors.mockResolvedValue([]);
|
||||
installUpFetch();
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('distributor');
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires an explicit choice when several distributors are installed', async () => {
|
||||
upNative().getDistributors.mockResolvedValue(['io.heckel.ntfy', 'org.unifiedpush.distributor.sunup']);
|
||||
upNative().getSavedDistributor.mockResolvedValue(null);
|
||||
installUpFetch();
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err.phase).toBe('distributor');
|
||||
expect(err.message).toContain('choose one');
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardownPushNotificationsForAccount', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
+139
-15
@@ -11,11 +11,22 @@ import { getMailboxes, getSharedMailboxes } from '../api/email';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import type { EmailPushConfig, Mailbox } from '../api/types';
|
||||
import { generateAccountId } from './account-utils';
|
||||
import {
|
||||
getUnifiedPushDistributors,
|
||||
isUnifiedPushSupported,
|
||||
registerUnifiedPush,
|
||||
unregisterUnifiedPush,
|
||||
UnifiedPushRegisterError,
|
||||
type UnifiedPushEndpoint,
|
||||
} from './unified-push';
|
||||
|
||||
// Persist identifiers across launches so we reuse the same JMAP subscription
|
||||
// after app restarts. Each account gets its own deviceClientId so the hosted
|
||||
// relay can distinguish per-account pushes via the URL slot it forwards.
|
||||
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
||||
// Which transport carries pushes to this device: FCM (default) or a
|
||||
// UnifiedPush distributor. Device-wide, like the relay URL.
|
||||
const PUSH_TRANSPORT_KEY = 'push:transport:v1';
|
||||
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
|
||||
// Local account id (username@host) → JMAP primary account id. The relay tags
|
||||
// every forwarded push with the JMAP account id it came from, which is the
|
||||
@@ -276,9 +287,28 @@ 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/FCM only). */
|
||||
/** True on platforms that have a push transport wired up (Android only). */
|
||||
export function isPushSupported(): boolean {
|
||||
return getNative() !== null;
|
||||
return getNative() !== null || isUnifiedPushSupported();
|
||||
}
|
||||
|
||||
export type PushTransport = 'fcm' | 'unifiedpush';
|
||||
|
||||
export async function getStoredPushTransport(): Promise<PushTransport | null> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
|
||||
return raw === 'fcm' || raw === 'unifiedpush' ? raw : null;
|
||||
}
|
||||
|
||||
export async function setStoredPushTransport(transport: PushTransport | null): Promise<void> {
|
||||
if (!transport) {
|
||||
await AsyncStorage.removeItem(PUSH_TRANSPORT_KEY);
|
||||
} else {
|
||||
await AsyncStorage.setItem(PUSH_TRANSPORT_KEY, transport);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEffectivePushTransport(): Promise<PushTransport> {
|
||||
return (await getStoredPushTransport()) ?? 'fcm';
|
||||
}
|
||||
|
||||
export interface PushSetupParams {
|
||||
@@ -303,6 +333,7 @@ export type PushSetupPhase =
|
||||
| 'platform'
|
||||
| 'permission'
|
||||
| 'token'
|
||||
| 'distributor'
|
||||
| 'account'
|
||||
| 'relay'
|
||||
| 'jmap'
|
||||
@@ -390,16 +421,22 @@ export async function getFcmToken(): Promise<string | null> {
|
||||
// Firebase rejects getToken() on devices without Google Play services (or
|
||||
// with a broken Firebase configuration), and briefly right after a
|
||||
// deleteToken(). Turn the raw native rejection into a phase-tagged error the
|
||||
// settings pane can explain.
|
||||
// settings pane can explain - and point de-Googled devices at UnifiedPush
|
||||
// when a distributor is around to take over.
|
||||
async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = await native.getToken();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
const distributors = await getUnifiedPushDistributors().catch(() => []);
|
||||
const hint =
|
||||
distributors.length > 0
|
||||
? ' This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.'
|
||||
: '';
|
||||
throw new PushSetupError(
|
||||
'token',
|
||||
`Firebase could not issue a device token (${detail}). Push needs Google Play services on this device.`,
|
||||
`Firebase could not issue a device token (${detail}). Push over FCM needs Google Play services on this device.${hint}`,
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
@@ -408,6 +445,39 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
|
||||
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
|
||||
* accept the relay's pushes; registration proceeds without it (503 on relays
|
||||
* without configured VAPID keys - UnifiedPush delivery still works there).
|
||||
*/
|
||||
async function fetchRelayVapidPublicKey(relayBaseUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { publicKey?: unknown };
|
||||
return typeof body.publicKey === 'string' && body.publicKey ? body.publicKey : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire a UnifiedPush endpoint, mapping failures onto setup phases. */
|
||||
async function getUnifiedPushEndpointOrThrow(
|
||||
relayBaseUrl: string,
|
||||
): Promise<UnifiedPushEndpoint> {
|
||||
const vapid = await fetchRelayVapidPublicKey(relayBaseUrl);
|
||||
try {
|
||||
return await registerUnifiedPush({ vapid });
|
||||
} catch (err) {
|
||||
if (err instanceof UnifiedPushRegisterError) {
|
||||
const phase = err.reason === 'failed' ? 'token' : 'distributor';
|
||||
throw new PushSetupError(phase, err.message);
|
||||
}
|
||||
throw new PushSetupError('token', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function buildRelayUrl(base: string, suffix: string): string {
|
||||
return base.replace(/\/+$/, '') + suffix;
|
||||
}
|
||||
@@ -454,6 +524,37 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerWithRelayUnifiedPush(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
endpoint: UnifiedPushEndpoint;
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
const { endpoint } = params;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/unifiedpush'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscriptionId: params.subscriptionId,
|
||||
endpoint: endpoint.url,
|
||||
keys:
|
||||
endpoint.p256dh && endpoint.auth
|
||||
? { p256dh: endpoint.p256dh, auth: endpoint.auth }
|
||||
: null,
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError('relay', `Could not reach the push relay at ${params.relayBaseUrl} (${detail}).`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new PushSetupError('relay', `The push relay rejected the registration: ${await readRelayError(res)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The relay's view of a subscription - see relayStatusFor. */
|
||||
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
|
||||
|
||||
@@ -570,10 +671,14 @@ function logPhase(phase: string, detail?: string): void {
|
||||
async function setupPushNotificationsInner(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const transport = await getEffectivePushTransport();
|
||||
const native = getNative();
|
||||
if (!native) {
|
||||
if (transport === 'fcm' && !native) {
|
||||
throw new PushSetupError('platform', 'Push notifications are only available on Android.');
|
||||
}
|
||||
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
|
||||
throw new PushSetupError('platform', 'UnifiedPush is only available on Android.');
|
||||
}
|
||||
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required');
|
||||
@@ -587,8 +692,14 @@ async function setupPushNotificationsInner(
|
||||
throw new PushSetupError('permission', 'Notification permission was not granted.');
|
||||
}
|
||||
|
||||
logPhase('token');
|
||||
const fcmToken = await getFcmTokenOrThrow(native);
|
||||
logPhase('token', transport);
|
||||
let fcmToken: string | null = null;
|
||||
let upEndpoint: UnifiedPushEndpoint | null = null;
|
||||
if (transport === 'unifiedpush') {
|
||||
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
|
||||
} else {
|
||||
fcmToken = await getFcmTokenOrThrow(native!);
|
||||
}
|
||||
|
||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||
// need its username/serverUrl up-front so we can key per-account state.
|
||||
@@ -606,15 +717,25 @@ async function setupPushNotificationsInner(
|
||||
|
||||
// Register this account's device-client-id with the relay. Multiple
|
||||
// accounts on the same device end up as separate registrations sharing
|
||||
// one fcmToken - the relay forwards each push individually and tags it
|
||||
// with the JMAP account id so the headless task can route it.
|
||||
// one FCM token / UnifiedPush endpoint - the relay forwards each push
|
||||
// individually and tags it with the JMAP account id so the headless task
|
||||
// can route it.
|
||||
logPhase('relay', relayBaseUrl);
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
fcmToken,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
if (upEndpoint) {
|
||||
await registerWithRelayUnifiedPush({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
endpoint: upEndpoint,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
} else {
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
fcmToken: fcmToken!,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
}
|
||||
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
@@ -853,6 +974,9 @@ export async function teardownPushNotifications(): Promise<void> {
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
}
|
||||
// Also release any UnifiedPush registration so the distributor stops
|
||||
// holding a channel for us. No-op when never registered.
|
||||
await unregisterUnifiedPush().catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface PushDevice {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
|
||||
// Thin JS surface over the BulwarkUnifiedPush native module. UnifiedPush
|
||||
// (https://unifiedpush.org) delivers pushes through a user-installed
|
||||
// distributor app (ntfy, NextPush, ...) instead of Google Play services -
|
||||
// the transport for de-Googled devices. Incoming messages themselves are
|
||||
// dispatched natively through the same headless task / `fcm:message` event
|
||||
// as FCM, so only registration management lives here.
|
||||
|
||||
export interface UnifiedPushEndpoint {
|
||||
url: string;
|
||||
// RFC 8291 Web Push keys from the connector; null with legacy distributors,
|
||||
// in which case the relay falls back to plain (unencrypted ping) delivery.
|
||||
p256dh: string | null;
|
||||
auth: string | null;
|
||||
}
|
||||
|
||||
type BulwarkUnifiedPushNative = {
|
||||
getDistributors(): Promise<string[]>;
|
||||
getSavedDistributor(): Promise<string | null>;
|
||||
getAckDistributor(): Promise<string | null>;
|
||||
saveDistributor(distributor: string): Promise<void>;
|
||||
register(vapid: string | null): Promise<void>;
|
||||
unregister(): Promise<void>;
|
||||
getEndpoint(): Promise<UnifiedPushEndpoint | null>;
|
||||
};
|
||||
|
||||
function getNative(): BulwarkUnifiedPushNative | null {
|
||||
if (Platform.OS !== 'android') return null;
|
||||
return (
|
||||
((NativeModules as Record<string, unknown>).BulwarkUnifiedPush as
|
||||
| BulwarkUnifiedPushNative
|
||||
| undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the app build carries the UnifiedPush connector (Android only). */
|
||||
export function isUnifiedPushSupported(): boolean {
|
||||
return getNative() !== null;
|
||||
}
|
||||
|
||||
/** Package names of the distributor apps installed on this device. */
|
||||
export async function getUnifiedPushDistributors(): Promise<string[]> {
|
||||
const native = getNative();
|
||||
if (!native) return [];
|
||||
try {
|
||||
return await native.getDistributors();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedUnifiedPushDistributor(): Promise<string | null> {
|
||||
const native = getNative();
|
||||
if (!native) return null;
|
||||
try {
|
||||
return await native.getSavedDistributor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUnifiedPushDistributor(distributor: string): Promise<void> {
|
||||
const native = getNative();
|
||||
if (!native) throw new Error('UnifiedPush is not available on this platform.');
|
||||
await native.saveDistributor(distributor);
|
||||
}
|
||||
|
||||
export async function getUnifiedPushEndpoint(): Promise<UnifiedPushEndpoint | null> {
|
||||
const native = getNative();
|
||||
if (!native) return null;
|
||||
try {
|
||||
return await native.getEndpoint();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterUnifiedPush(): Promise<void> {
|
||||
const native = getNative();
|
||||
if (!native) return;
|
||||
await native.unregister().catch(() => undefined);
|
||||
}
|
||||
|
||||
function emitter(): NativeEventEmitter {
|
||||
return new NativeEventEmitter(NativeModules.BulwarkUnifiedPush);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires whenever the distributor hands out a (new) endpoint - including
|
||||
* rotations while the app runs. Callers re-register the endpoint with the
|
||||
* relay, mirroring the FCM token-refresh listener.
|
||||
*/
|
||||
export function addUnifiedPushEndpointListener(
|
||||
listener: (endpoint: UnifiedPushEndpoint) => void,
|
||||
): () => void {
|
||||
if (!isUnifiedPushSupported()) return () => undefined;
|
||||
const sub = emitter().addListener('up:newEndpoint', listener);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
export function addUnifiedPushUnregisteredListener(listener: () => void): () => void {
|
||||
if (!isUnifiedPushSupported()) return () => undefined;
|
||||
const sub = emitter().addListener('up:unregistered', listener);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
export class UnifiedPushRegisterError extends Error {
|
||||
// 'no-distributor' - nothing installed; 'choose-distributor' - several
|
||||
// installed and none selected yet; 'failed' - the distributor rejected or
|
||||
// never answered the registration.
|
||||
readonly reason: 'no-distributor' | 'choose-distributor' | 'failed';
|
||||
|
||||
constructor(reason: UnifiedPushRegisterError['reason'], message: string) {
|
||||
super(message);
|
||||
this.name = 'UnifiedPushRegisterError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with the saved distributor (auto-selecting it when exactly one is
|
||||
* installed) and resolve with the endpoint. The endpoint arrives as an async
|
||||
* event from the distributor; a previously stored endpoint short-circuits the
|
||||
* wait - if the distributor then rotates it, the up:newEndpoint listener
|
||||
* re-runs setup with the fresh one.
|
||||
*/
|
||||
export async function registerUnifiedPush(params: {
|
||||
vapid?: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<UnifiedPushEndpoint> {
|
||||
const native = getNative();
|
||||
if (!native) {
|
||||
throw new UnifiedPushRegisterError('failed', 'UnifiedPush is not available on this platform.');
|
||||
}
|
||||
|
||||
const distributors = await getUnifiedPushDistributors();
|
||||
if (distributors.length === 0) {
|
||||
throw new UnifiedPushRegisterError(
|
||||
'no-distributor',
|
||||
'No UnifiedPush distributor app is installed. Install one (for example ntfy) and try again.',
|
||||
);
|
||||
}
|
||||
let saved = await getSavedUnifiedPushDistributor();
|
||||
if (!saved || !distributors.includes(saved)) {
|
||||
if (distributors.length > 1) {
|
||||
throw new UnifiedPushRegisterError(
|
||||
'choose-distributor',
|
||||
'Several UnifiedPush distributors are installed - choose one in the notification settings.',
|
||||
);
|
||||
}
|
||||
saved = distributors[0];
|
||||
await native.saveDistributor(saved);
|
||||
}
|
||||
|
||||
const timeoutMs = params.timeoutMs ?? 30_000;
|
||||
return new Promise<UnifiedPushEndpoint>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanups: Array<() => void> = [];
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const c of cleanups) c();
|
||||
fn();
|
||||
};
|
||||
|
||||
const epSub = emitter().addListener('up:newEndpoint', (endpoint: UnifiedPushEndpoint) => {
|
||||
finish(() => resolve(endpoint));
|
||||
});
|
||||
cleanups.push(() => epSub.remove());
|
||||
const failSub = emitter().addListener(
|
||||
'up:registrationFailed',
|
||||
(payload: { reason?: string }) => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new UnifiedPushRegisterError(
|
||||
'failed',
|
||||
`The distributor rejected the registration (${payload?.reason ?? 'unknown'}).`,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
cleanups.push(() => failSub.remove());
|
||||
const timer = setTimeout(() => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new UnifiedPushRegisterError(
|
||||
'failed',
|
||||
`The distributor did not provide an endpoint within ${Math.round(timeoutMs / 1000)} s.`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
cleanups.push(() => clearTimeout(timer));
|
||||
|
||||
void native
|
||||
.register(params.vapid ?? null)
|
||||
.then(async () => {
|
||||
// A re-registration usually keeps the existing endpoint, and the
|
||||
// distributor may not re-announce it - use the stored one if present.
|
||||
const stored = await native.getEndpoint().catch(() => null);
|
||||
if (stored) finish(() => resolve(stored));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new UnifiedPushRegisterError(
|
||||
'failed',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user