mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: renew push for every signed-in account and show each account's push state in the notification settings
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
notificationTapJmapAccountId,
|
||||
renewOtherPushAccounts,
|
||||
resyncPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
@@ -672,13 +673,16 @@ export default function App() {
|
||||
relayBaseUrl,
|
||||
accountLabel: client.username ?? undefined,
|
||||
});
|
||||
if (cancelled) return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[push] relay setup failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
if (cancelled) return;
|
||||
// The other signed-in accounts' subscriptions expire too (webmail #1054); each
|
||||
// is renewed through a client of its own.
|
||||
await renewOtherPushAccounts().catch(() => undefined);
|
||||
};
|
||||
|
||||
void doSetup();
|
||||
|
||||
@@ -1041,6 +1041,7 @@
|
||||
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
|
||||
},
|
||||
"push": {
|
||||
"accounts_desc_mobile": "Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.",
|
||||
"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.",
|
||||
|
||||
@@ -13,6 +13,7 @@ import Dialog from '../Dialog';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
@@ -28,12 +29,14 @@ import {
|
||||
mailAccountsWithoutPush,
|
||||
PushSetupError,
|
||||
readPushAccountIds,
|
||||
readPushAccountStates,
|
||||
revokePushDevice,
|
||||
serverSupportsEmailPush,
|
||||
setStoredRelayBaseUrl,
|
||||
enablePushFromSettings,
|
||||
getStoredPushTransport,
|
||||
setStoredPushTransport,
|
||||
type PushAccountState,
|
||||
type PushDevice,
|
||||
type PushSetupPhase,
|
||||
type PushTransport,
|
||||
@@ -178,6 +181,22 @@ export function NotificationSettings() {
|
||||
return () => { cancelled = true; };
|
||||
}, [supported, client, mailboxes, pushStatus.kind]);
|
||||
|
||||
// Push is set up per account, and the switch above is the shown account's.
|
||||
// List every signed-in account's state so the others aren't a guess (webmail #1054).
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const accountIdsKey = accounts.map((a) => a.id).join('\n');
|
||||
const [accountStates, setAccountStates] = useState<Record<string, PushAccountState>>({});
|
||||
useEffect(() => {
|
||||
if (!supported || accounts.length < 2) return;
|
||||
let cancelled = false;
|
||||
void readPushAccountStates(accounts.map((a) => a.id)).then(
|
||||
(states) => { if (!cancelled) setAccountStates(states); },
|
||||
() => undefined,
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [supported, accountIdsKey, pushEnabled, pushStatus.kind]);
|
||||
|
||||
const describeError = (error: unknown): string => {
|
||||
if (error instanceof PushSetupError) {
|
||||
const phase = PHASE_KEYS[error.phase];
|
||||
@@ -500,6 +519,34 @@ export function NotificationSettings() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{supported && accounts.length > 1 && (
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.push_all.title', 'All accounts')}
|
||||
description={t(
|
||||
'settings.notifications.push.accounts_desc_mobile',
|
||||
'Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.',
|
||||
)}
|
||||
>
|
||||
{accounts.map((account, index) => {
|
||||
const on = accountStates[account.id]?.enabled === true;
|
||||
const address = account.email || account.username;
|
||||
const state = on
|
||||
? t('settings.notifications.push_all.state_on', 'notifications on')
|
||||
: t('settings.notifications.push_all.state_off', 'notifications off');
|
||||
return (
|
||||
<SettingItem
|
||||
key={account.id}
|
||||
label={account.displayName || address}
|
||||
description={`${address} · ${state}`}
|
||||
noBorder={index === accounts.length - 1}
|
||||
>
|
||||
{on ? <CheckCircle2 size={16} color={c.success} /> : <View />}
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.email.title', 'Email Notifications')}
|
||||
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
|
||||
|
||||
@@ -63,6 +63,9 @@ const { DETACHED, SYNC_SESSION } = vi.hoisted(() => {
|
||||
loaded: true,
|
||||
types: ['EmailDelivery'] as string[],
|
||||
deviceClientId: null as string | null,
|
||||
// The subscription's expiry, and what an update of it settles on.
|
||||
expires: null as string | null,
|
||||
settles: null as string | null,
|
||||
log: [] as unknown[],
|
||||
session: SYNC_SESSION,
|
||||
},
|
||||
@@ -84,14 +87,29 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
DETACHED.log.push(['load', id]);
|
||||
return DETACHED.loaded;
|
||||
}
|
||||
get username() {
|
||||
return 'bob';
|
||||
}
|
||||
get accountId() {
|
||||
return 'bob-jmap';
|
||||
}
|
||||
async request(calls: Array<[string, Record<string, any>, string]>) {
|
||||
const [name, args, id] = calls[0];
|
||||
DETACHED.log.push([name, args]);
|
||||
if (name === 'PushSubscription/get') {
|
||||
const listed = { id: args.ids[0], types: DETACHED.types, ...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}) };
|
||||
return { methodResponses: [[name, { list: [listed] }, id]] };
|
||||
}
|
||||
return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] };
|
||||
const methodResponses = calls.map(([name, args, id]) => {
|
||||
DETACHED.log.push([name, args]);
|
||||
if (name === 'PushSubscription/get') {
|
||||
const listed = {
|
||||
id: args.ids[0],
|
||||
types: DETACHED.types,
|
||||
...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}),
|
||||
...(DETACHED.expires ? { expires: DETACHED.expires } : {}),
|
||||
};
|
||||
return [name, { list: [listed] }, id];
|
||||
}
|
||||
const target = Object.keys(args.update)[0];
|
||||
if (args.update[target].expires && DETACHED.settles) DETACHED.expires = DETACHED.settles;
|
||||
return [name, { updated: { [target]: null } }, id];
|
||||
});
|
||||
return { methodResponses };
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -126,7 +144,9 @@ import {
|
||||
readPushJmapAccountIds,
|
||||
PushSetupError,
|
||||
pushTypesFor,
|
||||
readPushAccountStates,
|
||||
refreshPushSubscriptionTypes,
|
||||
renewOtherPushAccounts,
|
||||
resyncPushNotifications,
|
||||
revokePushDevice,
|
||||
teardownPushNotificationsForAccount,
|
||||
@@ -1683,6 +1703,78 @@ describe('resyncPushNotifications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewOtherPushAccounts (#1054)', () => {
|
||||
const OTHER = 'bob@other.example.net';
|
||||
const OTHER_DCID = 'bobsphonebobsphonebobsphonebobsp';
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID, OTHER]));
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
|
||||
await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID);
|
||||
Object.assign(DETACHED, { loaded: true, deviceClientId: OTHER_DCID, expires: inDays(2), settles: inDays(7), log: [] });
|
||||
installFetch({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(DETACHED, { deviceClientId: null, expires: null, settles: null });
|
||||
});
|
||||
|
||||
it("extends another account's subscription through a client of its own and records the server's expiry", async () => {
|
||||
const settles = DETACHED.settles;
|
||||
|
||||
await renewOtherPushAccounts();
|
||||
|
||||
const calls = DETACHED.log.map((entry) => (entry as unknown[])[0]);
|
||||
// The loaded account is the launch-time resync's; only the other is touched here.
|
||||
expect(calls).toEqual(['load', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
|
||||
expect(DETACHED.log[0]).toEqual(['load', OTHER]);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(settles);
|
||||
// Its relay registration carries this device's current token.
|
||||
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
||||
const registration = fetchMock.mock.calls.find((c) => String(c[0]) === `${RELAY}/api/push/register`);
|
||||
expect(JSON.parse(String((registration![1] as RequestInit).body))).toEqual({
|
||||
subscriptionId: OTHER_DCID,
|
||||
fcmToken: 'fcm-token-xyz',
|
||||
accountLabel: 'bob',
|
||||
});
|
||||
});
|
||||
|
||||
it('only records the expiry of a subscription with more than a week left', async () => {
|
||||
DETACHED.expires = inDays(30);
|
||||
await renewOtherPushAccounts();
|
||||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(DETACHED.expires);
|
||||
});
|
||||
|
||||
it("leaves alone another client's subscription that reuses the id, and an account push was turned off for", async () => {
|
||||
DETACHED.deviceClientId = 'webmailwebmailwebmailwebmailwebm';
|
||||
await renewOtherPushAccounts();
|
||||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||||
|
||||
DETACHED.log = [];
|
||||
await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1');
|
||||
await renewOtherPushAccounts();
|
||||
expect(DETACHED.log).toEqual([]);
|
||||
});
|
||||
|
||||
it('tells the settings which accounts have push on this device', async () => {
|
||||
await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1');
|
||||
await AsyncStorage.removeItem(`push:subscriptionId:v2:${OTHER}`);
|
||||
expect(await readPushAccountStates([ACCOUNT_ID, OTHER, 'carl@example.org'])).toEqual({
|
||||
[ACCOUNT_ID]: { enabled: true, optedOut: false },
|
||||
[OTHER]: { enabled: false, optedOut: true },
|
||||
'carl@example.org': { enabled: false, optedOut: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardownPushNotificationsForAccount', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -34,6 +34,7 @@ import { credentialsKey } from '../api/jmap-client';
|
||||
import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared';
|
||||
import {
|
||||
getUnifiedPushDistributors,
|
||||
getUnifiedPushEndpoint,
|
||||
isUnifiedPushSupported,
|
||||
registerUnifiedPush,
|
||||
unregisterUnifiedPush,
|
||||
@@ -1853,6 +1854,121 @@ export async function refreshPushSubscriptionTypes(accountId: string): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep push alive for the signed-in accounts the app is not showing (webmail #1054).
|
||||
* The launch-time resync works on the loaded account only, and Stalwart keeps
|
||||
* a subscription for seven days from its last refresh, so another account's
|
||||
* push stopped about a week after the user last switched to it. Each account
|
||||
* goes through a client of its own, never the singleton: its registration
|
||||
* with the relay is renewed (the device token may have rotated since), its
|
||||
* subscription's expiry pushed forward and the expiry the server settled on
|
||||
* recorded. A subscription the server no longer has - lapsed, or revoked
|
||||
* elsewhere - is left to the account's own resync, which can tell the two
|
||||
* apart and verify a new one; so are its delivery filter and push types.
|
||||
* Best effort: whatever fails is tried again on the next launch.
|
||||
*/
|
||||
export async function renewOtherPushAccounts(): Promise<void> {
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
const served = username && serverUrl ? generateAccountId(username, serverUrl) : null;
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
for (const accountId of await readPushAccountIds()) {
|
||||
if (accountId === served) continue;
|
||||
try {
|
||||
await renewPushAccount(accountId, relayBaseUrl);
|
||||
} catch (err) {
|
||||
logPhase('renew', `${accountId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ListedSubscription = { id: string; deviceClientId?: string | null; expires?: string | null };
|
||||
|
||||
async function renewPushAccount(accountId: string, relayBaseUrl: string | null): Promise<void> {
|
||||
if (await AsyncStorage.getItem(optedOutKey(accountId))) return;
|
||||
const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
|
||||
if (!subscriptionId) return;
|
||||
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||
const client = new JMAPClient();
|
||||
if (!(await client.loadAccount(accountId))) return;
|
||||
if (relayBaseUrl && deviceClientId) {
|
||||
await registerDeviceWithRelay(relayBaseUrl, deviceClientId, client.username ?? undefined).catch(() => undefined);
|
||||
}
|
||||
const using = [CAPABILITIES.CORE];
|
||||
const properties = ['id', 'deviceClientId', 'expires'];
|
||||
const ownIn = (res: Awaited<ReturnType<JMAPClient['request']>>, callId: string) =>
|
||||
(requireMethodResult(res, callId, 'PushSubscription/get').list as ListedSubscription[] | undefined)
|
||||
?.find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
|
||||
const current = ownIn(
|
||||
await client.request([['PushSubscription/get', { ids: [subscriptionId], properties }, '0']], using),
|
||||
'0',
|
||||
);
|
||||
if (!current) return;
|
||||
let expires = current.expires ?? null;
|
||||
const remainingMs = Date.parse(expires ?? '') - Date.now();
|
||||
if (!(remainingMs > SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000)) {
|
||||
const res = await client.request(
|
||||
[
|
||||
['PushSubscription/set', { update: { [subscriptionId]: { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) } } }, '0'],
|
||||
// The update's answer doesn't say what the server settled on.
|
||||
['PushSubscription/get', { ids: [subscriptionId], properties }, '1'],
|
||||
],
|
||||
using,
|
||||
);
|
||||
expires = ownIn(res, '1')?.expires ?? expires;
|
||||
}
|
||||
if (expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires);
|
||||
let jmapAccountId: string | null = null;
|
||||
try {
|
||||
jmapAccountId = client.accountId;
|
||||
} catch {
|
||||
jmapAccountId = null;
|
||||
}
|
||||
await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an account's device client id with the relay again, with the
|
||||
* device token or endpoint this device has now. Never asks for anything: no
|
||||
* permission prompt, no new UnifiedPush registration.
|
||||
*/
|
||||
async function registerDeviceWithRelay(relayBaseUrl: string, deviceClientId: string, accountLabel?: string): Promise<void> {
|
||||
const transport = await getEffectivePushTransport();
|
||||
if (transport === 'apns') {
|
||||
const token = (await Notifications.getDevicePushTokenAsync()).data;
|
||||
if (typeof token !== 'string' || !token) return;
|
||||
await registerWithRelayApns({ relayBaseUrl, subscriptionId: deviceClientId, deviceToken: token, accountLabel });
|
||||
} else if (transport === 'unifiedpush') {
|
||||
const endpoint = await getUnifiedPushEndpoint();
|
||||
if (!endpoint) return;
|
||||
await registerWithRelayUnifiedPush({ relayBaseUrl, subscriptionId: deviceClientId, endpoint, accountLabel });
|
||||
} else {
|
||||
const token = await getFcmToken();
|
||||
if (!token) return;
|
||||
await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, fcmToken: token, accountLabel });
|
||||
}
|
||||
}
|
||||
|
||||
export interface PushAccountState {
|
||||
// A subscription is recorded for the account on this device.
|
||||
enabled: boolean;
|
||||
// The user turned push off for it here (see disablePushForAccount).
|
||||
optedOut: boolean;
|
||||
}
|
||||
|
||||
/** What this device knows about each account's push, for the settings. */
|
||||
export async function readPushAccountStates(accountIds: string[]): Promise<Record<string, PushAccountState>> {
|
||||
await migrateLegacyPushKeys();
|
||||
const out: Record<string, PushAccountState> = {};
|
||||
for (const accountId of accountIds) {
|
||||
out[accountId] = {
|
||||
enabled: (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null,
|
||||
optedOut: (await AsyncStorage.getItem(optedOutKey(accountId))) !== null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push for ALL accounts on this device. Used by the logout-all
|
||||
* flow; best-effort because we typically aren't authenticated to every
|
||||
|
||||
Reference in New Issue
Block a user