diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts index c1d284cf33..8ef2e7a019 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -53,6 +53,9 @@ const matrixClient = vi.hoisted(() => ({ fetchRoomEvent: vi.fn<() => Promise>(), })); +const requestPushDrain = vi.hoisted(() => vi.fn<() => void>()); +const getSlidingSyncManager = vi.hoisted(() => vi.fn<() => unknown>()); + const invoke = vi.hoisted(() => vi.fn<(cmd: string, args?: Record) => Promise>() ); @@ -78,6 +81,8 @@ const addPluginListener = vi.hoisted(() => vi.mock('./UnifiedPushTransport', () => unifiedPushTransport); +vi.mock('$client/initMatrix', () => ({ getSlidingSyncManager })); + vi.mock('./TauriNotificationsApiClient', () => ({ getTauriNotificationsApi, isMobileTauri: () => false, @@ -151,6 +156,7 @@ describe('UnifiedPushNotifications', () => { } }); matrixClient.getRoom.mockReturnValue(undefined); + getSlidingSyncManager.mockReturnValue({ requestPushDrain }); invoke.mockResolvedValue(undefined); addPluginListener.mockImplementation( async (_plugin: string, _event: string, handler: (data: unknown) => void) => { @@ -568,7 +574,8 @@ describe('UnifiedPushNotifications', () => { await listenAndPush(encryptedPush('$expired:example.com')); await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); - await vi.advanceTimersByTimeAsync(5 * 60_000); + // The window, plus the grace for the attempt made as it lapses. + await vi.advanceTimersByTimeAsync(5 * 60_000 + 10_000); resolveDecryption({ clearEvent: { type: 'm.room.message', @@ -583,6 +590,61 @@ describe('UnifiedPushNotifications', () => { } }); + it('enriches from the decryption attempt made as the retry window lapses', async () => { + vi.useFakeTimers(); + try { + matrixClient.getRoom.mockReturnValue(makeRoom()); + const keyArrivesAt = Date.now() + 5 * 60_000; + matrixClient.getCrypto.mockReturnValue({ + decryptEvent: vi + .fn<() => Promise>>() + .mockImplementation(async () => { + if (Date.now() < keyArrivesAt) throw new Error('MissingRoomKey'); + return { + clearEvent: { type: 'm.room.message', content: { body: 'key arrived' } }, + }; + }), + }); + + await listenAndPush(encryptedPush('$suspended:example.com')); + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledTimes(2)); + expect(notificationsApi.sendNotification.mock.calls[1]?.[0]).toMatchObject({ + body: 'You: key arrived', + silent: true, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps the to-device drain alive while an encrypted preview is still pending', async () => { + vi.useFakeTimers(); + try { + matrixClient.getRoom.mockReturnValue(makeRoom()); + matrixClient.getCrypto.mockReturnValue({ + decryptEvent: vi + .fn<() => Promise>>() + .mockRejectedValue(new Error('MissingRoomKey')), + }); + + await listenAndPush(encryptedPush('$pending-drain:example.com')); + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); + // Only the drain the incoming push itself asked for. + expect(requestPushDrain).toHaveBeenCalledOnce(); + + // Past the two-minute drain window. + await vi.advanceTimersByTimeAsync(3 * 60_000); + + expect(requestPushDrain.mock.calls.length).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it('retries a duplicate rich event after its initial native post fails', async () => { matrixClient.getRoom.mockReturnValue(makeRoom()); let attempts = 0; diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index 270bad6269..0b4336bde8 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -475,6 +475,8 @@ function holdsPlaintext(event: MatrixEvent): boolean { const DECRYPT_RETRY_DELAYS_MS = [1000, 2000, 5000, 10_000, 30_000, 60_000] as const; +const FINAL_DECRYPT_ATTEMPT_TIMEOUT_MS = 10_000; + const supportsEventDecryption = (crypto: CryptoApi | undefined): crypto is CryptoBackend => !!crypto && 'decryptEvent' in crypto && typeof crypto.decryptEvent === 'function'; @@ -501,21 +503,41 @@ function whenDecrypted(event: MatrixEvent, apply: () => Promise, mx: Matri }; event.on(MatrixEventEvent.Decrypted, onDecrypted); + const giveUp = () => { + if (finished) return; + finish(); + unifiedPushLog.warn('notification', 'Encrypted preview never decrypted within retry window', { + roomId: event.getRoomId(), + sessionId: event.getWireContent().session_id, + attempts: retryIndex, + }); + }; const retry = () => { if (finished) return; + const crypto = mx.getCrypto(); + const attempt = supportsEventDecryption(crypto) + ? event.attemptDecryption(crypto, { isRetry: true }).catch(() => undefined) + : undefined; + const remaining = retryDeadline - Date.now(); if (remaining > 0) { - const crypto = mx.getCrypto(); - if (supportsEventDecryption(crypto)) { - void event.attemptDecryption(crypto, { isRetry: true }).catch(() => undefined); - } + // Sync is what carries the room key, and a backgrounded client only keeps + // it running while a push drain is outstanding. + getSlidingSyncManager(mx)?.requestPushDrain(); const delay = DECRYPT_RETRY_DELAYS_MS[retryIndex] ?? 60_000; retryTimer = setTimeout(retry, Math.min(delay, remaining)); retryIndex += 1; return; } - finish(); - unifiedPushLog.warn('notification', 'Encrypted preview never decrypted within retry window'); + + // The window can lapse on a tick that runs long after the key landed, so + // settle on this last attempt rather than on the clock. + if (!attempt) { + giveUp(); + return; + } + retryTimer = setTimeout(giveUp, FINAL_DECRYPT_ATTEMPT_TIMEOUT_MS); + void attempt.then(giveUp); }; retryTimer = setTimeout(retry, DECRYPT_RETRY_DELAYS_MS[0]); retryIndex += 1;