mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep push off after it is turned off or this device is revoked
The launch-time effect in `App.tsx` re-ran the full setup for the active account whenever a relay URL was stored, and that URL is shared by every account on the device. Turning push off for one account, or revoking this device, lasted only until the next launch or token rotation. A subscription revoked from another device was re-created without asking. - Turning push off in settings and revoking this device now go through `disablePushForAccount`, which tears the registration down and marks the account as opted out. Enabling push again clears the mark. - The effect calls `resyncPushNotifications`, which skips opted-out accounts. If the server no longer has the recorded subscription although it still had a day or more to live, it treats the subscription as revoked and turns push off for the account instead of registering again. A subscription that lapsed is re-created as before. - Setup records the expiry the server reports: from the create response (`createPushSubscription` now returns it) and from every listing.
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
||||
addTokenRefreshListener,
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
resyncPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
@@ -432,7 +432,8 @@ export default function App() {
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||
// setups intact.
|
||||
// setups intact. An account the user turned push off for, or whose device
|
||||
// registration was revoked, stays off (see resyncPushNotifications).
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
@@ -453,7 +454,7 @@ export default function App() {
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl) return;
|
||||
try {
|
||||
await setupPushNotifications({
|
||||
await resyncPushNotifications({
|
||||
relayBaseUrl,
|
||||
accountLabel: client.username ?? undefined,
|
||||
});
|
||||
|
||||
@@ -100,6 +100,30 @@ describe('push operations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPushSubscription', () => {
|
||||
const params = {
|
||||
deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'], expires: '2026-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
it('returns the expiry the server clamped the subscription to', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [[
|
||||
'PushSubscription/set',
|
||||
{ created: { new: { id: 's1', keys: null, expires: '2026-10-01T00:00:00Z' } } },
|
||||
'0',
|
||||
]],
|
||||
});
|
||||
await expect(createPushSubscription(params)).resolves.toEqual({ id: 's1', expires: '2026-10-01T00:00:00Z' });
|
||||
});
|
||||
|
||||
it('keeps the requested expiry when the server does not echo one', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['PushSubscription/set', { created: { new: { id: 's1' } } }, '0']],
|
||||
});
|
||||
await expect(createPushSubscription(params)).resolves.toEqual({ id: 's1', expires: '2026-12-01T00:00:00Z' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PushSubscription/set refusals', () => {
|
||||
const forbidden = {
|
||||
type: 'forbidden',
|
||||
|
||||
+9
-6
@@ -40,9 +40,10 @@ export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
/**
|
||||
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
||||
* Returns the server-assigned id (which the client also registers with the
|
||||
* relay so the relay can route incoming pushes to an Expo token). A refusal
|
||||
* throws a JMAPMethodError carrying the server's SetError type, so callers
|
||||
* can tell a `forbidden` emailPush map from other failures.
|
||||
* relay so the relay can route incoming pushes to an Expo token) and the
|
||||
* expiry the server settled on. A refusal throws a JMAPMethodError carrying
|
||||
* the server's SetError type, so callers can tell a `forbidden` emailPush map
|
||||
* from other failures.
|
||||
*/
|
||||
export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
@@ -54,7 +55,7 @@ export async function createPushSubscription(params: {
|
||||
// draft-ietf-jmap-emailpush delivery filter, only when the server advertises
|
||||
// urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush).
|
||||
emailPush?: Record<string, EmailPushConfig>;
|
||||
}): Promise<string> {
|
||||
}): Promise<{ id: string; expires: string | null }> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
@@ -76,11 +77,13 @@ export async function createPushSubscription(params: {
|
||||
);
|
||||
const body = requireMethodResult(res, '0', 'PushSubscription/set');
|
||||
assertSetResult(body, ['new'], 'push subscription');
|
||||
const result = body.created?.new as { id?: string } | undefined;
|
||||
const result = body.created?.new as { id?: string; expires?: string | null } | undefined;
|
||||
if (!result?.id) {
|
||||
throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`);
|
||||
}
|
||||
return result.id;
|
||||
// The server only echoes `expires` when it changed it (Stalwart clamps it to
|
||||
// seven days); otherwise the requested value stands.
|
||||
return { id: result.id, expires: result.expires ?? params.expires ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
disablePushForAccount,
|
||||
getStoredRelayBaseUrl,
|
||||
isPushEnabledForAccount,
|
||||
isPushSupported,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
revokePushDevice,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
getStoredPushTransport,
|
||||
setStoredPushTransport,
|
||||
type PushDevice,
|
||||
@@ -206,7 +206,7 @@ export function NotificationSettings() {
|
||||
});
|
||||
try {
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId);
|
||||
await disablePushForAccount(activeAccountId);
|
||||
}
|
||||
// The relay base URL is a device-wide setting shared with any other
|
||||
// accounts that are still using push. Only clear it when no accounts
|
||||
|
||||
@@ -36,6 +36,12 @@ vi.mock('react-native', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// What createPushSubscription resolves to: the new id and the expiry the
|
||||
// server settled on (unknown here unless a test says otherwise).
|
||||
const { CREATED } = vi.hoisted(() => ({
|
||||
CREATED: { id: 'new-server-id', expires: null as string | null },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
username: 'user@example.com',
|
||||
@@ -55,7 +61,7 @@ vi.mock('../../api/email', () => ({
|
||||
|
||||
vi.mock('../../api/push', () => ({
|
||||
listPushSubscriptions: vi.fn(async () => []),
|
||||
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
||||
createPushSubscription: vi.fn(async () => CREATED),
|
||||
verifyPushSubscription: vi.fn(async () => undefined),
|
||||
destroyPushSubscription: vi.fn(async () => undefined),
|
||||
updatePushSubscription: vi.fn(async () => undefined),
|
||||
@@ -65,9 +71,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
setupPushNotifications,
|
||||
deviceClientIdKey,
|
||||
disablePushForAccount,
|
||||
isValidRelayUrl,
|
||||
readPushAccountIds,
|
||||
readPushJmapAccountIds,
|
||||
PushSetupError,
|
||||
resyncPushNotifications,
|
||||
revokePushDevice,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../push-notifications';
|
||||
import {
|
||||
@@ -394,7 +404,7 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
sharedMock.mockResolvedValue([]);
|
||||
createMock.mockImplementation(async () => 'new-server-id');
|
||||
createMock.mockImplementation(async () => CREATED);
|
||||
updateMock.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
@@ -454,7 +464,7 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
const accept = refuseUnless(['jmap-primary', 'team']);
|
||||
createMock.mockImplementation(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
|
||||
accept(params.emailPush);
|
||||
return 'new-server-id';
|
||||
return CREATED;
|
||||
});
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
@@ -576,7 +586,7 @@ describe('setupPushNotifications over UnifiedPush', () => {
|
||||
if (params.emailPush && 'acl-b' in params.emailPush) {
|
||||
throw new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
}
|
||||
return 'new-server-id';
|
||||
return CREATED;
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -588,7 +598,7 @@ describe('setupPushNotifications over UnifiedPush', () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
createMock.mockImplementation(async () => 'new-server-id');
|
||||
createMock.mockImplementation(async () => CREATED);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -631,6 +641,131 @@ describe('setupPushNotifications over UnifiedPush', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resyncPushNotifications', () => {
|
||||
const OPTED_OUT_KEY = 'push:optedOut:v1:' + ACCOUNT_ID;
|
||||
const EXPIRES_KEY = 'push:subscriptionExpires:v1:' + ACCOUNT_ID;
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
createMock.mockImplementation(async () => CREATED);
|
||||
});
|
||||
|
||||
it('keeps a healthy registration up to date', async () => {
|
||||
const expires = inDays(3);
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
listMock.mockResolvedValue([
|
||||
{ id: 'existing', deviceClientId: OUR_DCID, expires, types: ['EmailDelivery'] },
|
||||
]);
|
||||
|
||||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result?.subscriptionId).toBe('existing');
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
// The expiry the server reported is what a later resync measures against.
|
||||
expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(expires);
|
||||
});
|
||||
|
||||
it('leaves an account alone after the user turned push off for it', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||||
listMock.mockResolvedValue([sub('existing', OUR_DCID)]);
|
||||
|
||||
await disablePushForAccount(ACCOUNT_ID);
|
||||
vi.clearAllMocks();
|
||||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(await readPushAccountIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves push off after this device was revoked from the device list', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
listMock.mockResolvedValue([sub('existing', OUR_DCID)]);
|
||||
|
||||
await revokePushDevice({
|
||||
accountId: ACCOUNT_ID,
|
||||
device: { id: 'existing', deviceClientId: OUR_DCID, isThisDevice: true },
|
||||
relayBaseUrl: RELAY,
|
||||
});
|
||||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-register a subscription another device revoked', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
|
||||
expect(await readPushAccountIds()).toEqual([]);
|
||||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('re-creates a subscription that simply lapsed', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(-1));
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result?.subscriptionId).toBe('new-server-id');
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-creates a missing subscription whose expiry it never learned', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result?.subscriptionId).toBe('new-server-id');
|
||||
});
|
||||
|
||||
it('changes nothing when the server cannot be asked', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||||
listMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(resyncPushNotifications({ relayBaseUrl: RELAY })).rejects.toThrow('network down');
|
||||
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('turns push back on when the user enables it again', async () => {
|
||||
await disablePushForAccount(ACCOUNT_ID);
|
||||
createMock.mockImplementation(async () => ({ id: 'new-server-id', expires: inDays(7) }));
|
||||
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||||
expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull();
|
||||
vi.clearAllMocks();
|
||||
listMock.mockResolvedValue([
|
||||
{ id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] },
|
||||
]);
|
||||
expect((await resyncPushNotifications({ relayBaseUrl: RELAY }))?.subscriptionId).toBe('new-server-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardownPushNotificationsForAccount', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -45,6 +45,14 @@ const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:';
|
||||
// emailPushFallbacks). Left out of the map from then on, so a refusal isn't
|
||||
// re-provoked on every launch.
|
||||
const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:';
|
||||
// When the recorded subscription expires, as the server last reported it. Lets
|
||||
// the launch-time resync tell a subscription that lapsed (re-create it) from
|
||||
// one that was revoked (leave push off).
|
||||
const SUBSCRIPTION_EXPIRES_PREFIX = 'push:subscriptionExpires:v1:';
|
||||
// Set when the user turned push off for the account - the settings toggle, or
|
||||
// revoking this device here or from elsewhere. The launch-time resync leaves
|
||||
// such an account alone until push is enabled for it again.
|
||||
const OPTED_OUT_PREFIX = 'push:optedOut:v1:';
|
||||
|
||||
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
||||
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
||||
@@ -77,6 +85,14 @@ function emailPushRefusedKey(accountId: string): string {
|
||||
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function subscriptionExpiresKey(accountId: string): string {
|
||||
return SUBSCRIPTION_EXPIRES_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function optedOutKey(accountId: string): string {
|
||||
return OPTED_OUT_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export async function readPushAccountIds(): Promise<string[]> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
if (!raw) return [];
|
||||
@@ -209,6 +225,10 @@ const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
||||
// When an existing subscription has less than this much lifetime left, push
|
||||
// expires forward on the next app start.
|
||||
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
||||
// A recorded subscription missing from the server was revoked only if it still
|
||||
// had at least this long to live; closer to its expiry (or with the clock a
|
||||
// little off) it may simply have lapsed, and is re-created.
|
||||
const REVOKED_EXPIRY_MARGIN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function expiresFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
@@ -873,12 +893,14 @@ async function setupPushNotificationsInner(
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires);
|
||||
if (!params.forceRecreate) {
|
||||
const refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||
if (refreshed) {
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
await addPushAccountId(accountId);
|
||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||
await AsyncStorage.removeItem(optedOutKey(accountId));
|
||||
logPhase('done', 'reused existing subscription');
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
@@ -917,6 +939,7 @@ async function setupPushNotificationsInner(
|
||||
}
|
||||
|
||||
let serverAssignedId: string;
|
||||
let serverExpires: string | null;
|
||||
try {
|
||||
const created = await writeWithEmailPush(emailPush, (filter) =>
|
||||
createPushSubscription({
|
||||
@@ -927,7 +950,8 @@ async function setupPushNotificationsInner(
|
||||
...(filter ? { emailPush: filter } : {}),
|
||||
}),
|
||||
);
|
||||
serverAssignedId = created.result;
|
||||
serverAssignedId = created.result.id;
|
||||
serverExpires = created.result.expires;
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
@@ -944,11 +968,13 @@ async function setupPushNotificationsInner(
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem(subKey, serverAssignedId);
|
||||
if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires);
|
||||
if (replacedServerId) {
|
||||
await destroyPushSubscription(replacedServerId).catch(() => undefined);
|
||||
}
|
||||
await addPushAccountId(accountId);
|
||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||
await AsyncStorage.removeItem(optedOutKey(accountId));
|
||||
logPhase('done', 'subscription verified');
|
||||
|
||||
return { subscriptionId: serverAssignedId, verified: true };
|
||||
@@ -1021,6 +1047,7 @@ async function clearAccountPushKeys(accountId: string): Promise<void> {
|
||||
lastNotifiedKey(accountId),
|
||||
notifiedIdsKey(accountId),
|
||||
emailPushRefusedKey(accountId),
|
||||
subscriptionExpiresKey(accountId),
|
||||
]);
|
||||
await writePushJmapAccountId(accountId, null);
|
||||
}
|
||||
@@ -1070,6 +1097,57 @@ export async function teardownPushNotificationsForAccount(
|
||||
await writePushAccountIds(remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn push off for one account because the user asked to: the settings
|
||||
* toggle, or revoking this device (here, or from another device - see
|
||||
* resyncPushNotifications). Tears the registration down and remembers the
|
||||
* choice, so the launch-time resync doesn't quietly register the account
|
||||
* again. Enabling push for it again clears the mark.
|
||||
*/
|
||||
export async function disablePushForAccount(accountId: string): Promise<void> {
|
||||
await teardownPushNotificationsForAccount(accountId);
|
||||
await AsyncStorage.setItem(optedOutKey(accountId), String(Date.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the server no longer has this account's recorded subscription
|
||||
* although it wasn't due to expire - someone revoked it. Without a known
|
||||
* expiry (a registration made by an older build) nothing can be told apart,
|
||||
* so it counts as not revoked. Throws when the server can't be asked.
|
||||
*/
|
||||
async function wasRevokedOnServer(accountId: string): Promise<boolean> {
|
||||
const storedServerId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
|
||||
const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? '');
|
||||
if (!storedServerId || !Number.isFinite(expires)) return false;
|
||||
if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false;
|
||||
const subs = await listPushSubscriptions();
|
||||
return !subs.some((s) => s.id === storedServerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the loaded account's registration up to date without user action -
|
||||
* on launch and when the push token or endpoint rotates. Skips an account the
|
||||
* user turned push off for, and honours a revocation: when the server dropped
|
||||
* the subscription before it was due to expire, push is turned off for the
|
||||
* account instead of being silently registered again. Resolves to null when
|
||||
* it left push off.
|
||||
*/
|
||||
export async function resyncPushNotifications(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult | null> {
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (!username || !serverUrl) return null;
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
|
||||
if (await wasRevokedOnServer(accountId)) {
|
||||
logPhase('revoked', 'subscription gone before it was due to expire; leaving push off');
|
||||
await disablePushForAccount(accountId);
|
||||
return null;
|
||||
}
|
||||
return setupPushNotifications(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1166,7 +1244,7 @@ export async function revokePushDevice(params: {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
if (params.device.isThisDevice) {
|
||||
await teardownPushNotificationsForAccount(params.accountId);
|
||||
await disablePushForAccount(params.accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user