diff --git a/plugins/ios-push/Jmap.swift b/plugins/ios-push/Jmap.swift index 6f1ed71e..5cb6fa60 100644 --- a/plugins/ios-push/Jmap.swift +++ b/plugins/ios-push/Jmap.swift @@ -17,6 +17,8 @@ struct JmapFetchResult { enum JmapError: Error { case noCredentials + /// The access token needs a refresh the caller didn't allow. + case needsRefresh case http(Int) case badResponse } @@ -47,11 +49,18 @@ enum Jmap { /// access token that is about to expire. A refreshed bundle is written back /// to the shared keychain item so the app picks it up (and doesn't refresh /// again with a refresh token the server has already replaced). - static func authorization(credentialsKey: String) async throws -> String { + /// + /// `allowRefresh: false` throws instead of refreshing: the app's foreground + /// renewal leaves that to the app's own code, which runs then too and would + /// race it for the same refresh token. + static func authorization(credentialsKey: String, allowRefresh: Bool = true) async throws -> String { guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials } var creds = StoredCredentials(json: json) if let token = creds.accessToken { let nowMs = Date().timeIntervalSince1970 * 1000 + if !allowRefresh, let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000 { + throw JmapError.needsRefresh + } if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000, let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId, let url = URL(string: endpoint) { @@ -99,6 +108,67 @@ enum Jmap { ) } + /// One JMAP request; the method responses as the server sent them. + static func call(apiUrl: URL, authorization: String, using: [String], methodCalls: [[Any]]) async throws -> [[Any]] { + var request = URLRequest(url: apiUrl) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(authorization, forHTTPHeaderField: "Authorization") + let body: [String: Any] = ["using": using, "methodCalls": methodCalls] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse } + guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) } + guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse } + return responses + } + + struct ListedPushSubscription { + let id: String + let deviceClientId: String? + let expires: String? + } + + static let pushSubscriptionProperties = ["id", "deviceClientId", "expires"] + + /// Subscription `id` as the PushSubscription/get answer `callId` lists it. + static func listedPushSubscription(_ responses: [[Any]], callId: String, id: String) -> ListedPushSubscription? { + for response in responses where response.count >= 3 { + guard response[0] as? String == "PushSubscription/get", response[2] as? String == callId, + let args = response[1] as? [String: Any], + let list = args["list"] as? [[String: Any]] else { continue } + for item in list where item["id"] as? String == id { + return ListedPushSubscription( + id: id, + deviceClientId: item["deviceClientId"] as? String, + expires: item["expires"] as? String + ) + } + } + return nil + } + + static func pushSubscription(apiUrl: URL, id: String, authorization: String) async throws -> ListedPushSubscription? { + let methodCalls: [[Any]] = [ + ["PushSubscription/get", ["ids": [id], "properties": pushSubscriptionProperties], "0"], + ] + let responses = try await call(apiUrl: apiUrl, authorization: authorization, using: ["urn:ietf:params:jmap:core"], methodCalls: methodCalls) + return listedPushSubscription(responses, callId: "0", id: id) + } + + /// Asks for a later expiry and reads back the one the server settled on: + /// servers clamp it, and the update's answer doesn't say. + static func extendPushSubscription(apiUrl: URL, id: String, expires: String, authorization: String) async throws -> ListedPushSubscription? { + let methodCalls: [[Any]] = [ + ["PushSubscription/set", ["update": [id: ["expires": expires]]], "0"], + ["PushSubscription/get", ["ids": [id], "properties": pushSubscriptionProperties], "1"], + ] + let responses = try await call(apiUrl: apiUrl, authorization: authorization, using: ["urn:ietf:params:jmap:core"], methodCalls: methodCalls) + return listedPushSubscription(responses, callId: "1", id: id) + } + static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult { var request = URLRequest(url: apiUrl) request.httpMethod = "POST" diff --git a/plugins/ios-push/NotificationService.swift b/plugins/ios-push/NotificationService.swift index e639d372..6dbd518e 100644 --- a/plugins/ios-push/NotificationService.swift +++ b/plugins/ios-push/NotificationService.swift @@ -2,16 +2,15 @@ import Foundation import os import UserNotifications -/// Ids and steps only, never credentials or message content. -let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push") - /// Turns the relay's content-blind push ("New message" plus ids) into a real /// notification: sender, subject and preview, fetched over JMAP on this /// device with the account's own credentials. Nothing about the message ever /// passes through the relay or Apple. /// /// What the app shares through the keychain group (src/lib/ios-push-shared.ts): -/// bulwark_push_account__ {credentialsKey, apiUrl, label} +/// bulwark_push_account__ {credentialsKey, apiUrl, label, +/// subscriptionId, deviceClientId, +/// subscriptionExpires} /// bulwark_push_settings {badge, activeAccountId, strings{...}} /// the account's stored credentials /// If anything is missing or the server can't be reached, the relay's @@ -38,6 +37,15 @@ final class NotificationService: UNNotificationServiceExtension { let push = request.content.userInfo["body"] as? [String: Any] work = Task { [weak self] in let enriched = await NotificationService.enrich(content, push: push) + // A timeout from here on shows what was fetched. + self?.placeholder = enriched + // Keep the account's subscription alive (PushRenewal.swift). Does + // nothing until it is less than six days from expiring, so this costs + // a request or two about once a day. After the fetch, not beside it: + // both may refresh the same OAuth token. + if let accountId = push?["accountId"] as? String, !accountId.isEmpty { + await PushRenewal.renewIfDue(jmapAccountId: accountId) + } self?.finish(enriched) } } @@ -54,7 +62,7 @@ final class NotificationService: UNNotificationServiceExtension { } static func sanitize(_ id: String) -> String { - String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" }) + SharedKeychain.keyPart(id) } static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent { diff --git a/plugins/ios-push/PushRenewal.swift b/plugins/ios-push/PushRenewal.swift new file mode 100644 index 00000000..68ac8523 --- /dev/null +++ b/plugins/ios-push/PushRenewal.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Keeps push alive on iOS. Stalwart (0.16 and 1.0) drops a PushSubscription +/// seven days after its last refresh, and the app's JS refreshes it only when +/// the app starts. On Android a push handled in the background extends it +/// (src/lib/push-background-task.ts extendPushSubscription); on iOS pushes +/// reach the notification extension, not the app's JS, so someone who reads +/// mail from the notifications without opening the app lost push after a +/// week. The same steps run here instead: in the notification extension for +/// the pushed account, and in the app (PushRenewalTask.swift) on foreground +/// and from a background refresh task for every account. +/// +/// The app shares each account's subscription in the account's entry +/// (src/lib/ios-push-shared.ts IosPushAccount): subscriptionId, +/// deviceClientId and subscriptionExpires. Only a subscription that is +/// recorded there and has less than six days left (or an unknown expiry) is +/// looked at; then the server's copy is read first, since the record can be +/// older than it, and only this device's own subscription is extended. +enum PushRenewal { + static let accountPrefix = "bulwark_push_account__" + /// BACKGROUND_EXTEND_BELOW_MS in src/lib/push-notifications.ts. + static let extendBelowSeconds: TimeInterval = 6 * 24 * 60 * 60 + /// SUBSCRIPTION_EXPIRES_DAYS in src/lib/push-notifications.ts; servers clamp it. + static let wantedSeconds: TimeInterval = 90 * 24 * 60 * 60 + + static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } + + /// A JMAP UTCDate, without fractional seconds. + static func formatDate(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + + /// pushSubscriptionNeedsExtension in src/lib/push-notifications.ts. + static func needsExtension(_ expires: String?, now: Date = Date()) -> Bool { + guard let at = parseDate(expires) else { return true } + return at.timeIntervalSince(now) < extendBelowSeconds + } + + /// A server that reused the id may hand back another device's subscription. + static func isOwn(_ listed: Jmap.ListedPushSubscription, deviceClientId: String?) -> Bool { + guard let theirs = listed.deviceClientId, let ours = deviceClientId else { return true } + return theirs == ours + } + + /// The pushed account's subscription, if it is due. + static func renewIfDue(jmapAccountId: String) async { + await renew(key: accountPrefix + SharedKeychain.keyPart(jmapAccountId), allowRefresh: true) + } + + /// Every shared account's subscription that is due, one after the other. + static func renewAll(allowRefresh: Bool) async { + for key in SharedKeychain.keys(withPrefix: accountPrefix) { + if Task.isCancelled { return } + await renew(key: key, allowRefresh: allowRefresh) + } + } + + /// True when the subscription was extended. Best effort: anything that + /// fails is tried again on the next push, foreground or refresh task. + @discardableResult + static func renew(key: String, allowRefresh: Bool) async -> Bool { + guard let account = SharedKeychain.readJSON(key), + let subscriptionId = account["subscriptionId"] as? String, !subscriptionId.isEmpty, + let credentialsKey = account["credentialsKey"] as? String, + let apiString = account["apiUrl"] as? String, + let apiUrl = URL(string: apiString) else { return false } + guard needsExtension(account["subscriptionExpires"] as? String) else { return false } + let deviceClientId = account["deviceClientId"] as? String + do { + let authorization = try await Jmap.authorization(credentialsKey: credentialsKey, allowRefresh: allowRefresh) + guard let current = try await Jmap.pushSubscription(apiUrl: apiUrl, id: subscriptionId, authorization: authorization), + isOwn(current, deviceClientId: deviceClientId) else { + // Lapsed or revoked: the app's resync tells the two apart. + pushLog.info("subscription \(subscriptionId, privacy: .public) is not on the server") + return false + } + // The recorded expiry was stale: the server's copy has time left. + if let expires = current.expires, !needsExtension(expires) { + record(key: key, account: account, expires: expires) + return false + } + let wanted = formatDate(Date().addingTimeInterval(wantedSeconds)) + let extended = try await Jmap.extendPushSubscription( + apiUrl: apiUrl, id: subscriptionId, expires: wanted, authorization: authorization) + if let expires = extended?.expires { record(key: key, account: account, expires: expires) } + pushLog.info("extended subscription \(subscriptionId, privacy: .public)") + return true + } catch { + pushLog.error("renewal failed: \(String(describing: error), privacy: .public)") + return false + } + } + + private static func record(key: String, account: [String: Any], expires: String) { + var json = account + json["subscriptionExpires"] = expires + SharedKeychain.update(key, json: json) + } +} diff --git a/plugins/ios-push/PushRenewalTask.swift b/plugins/ios-push/PushRenewalTask.swift new file mode 100644 index 00000000..0c9cbd9f --- /dev/null +++ b/plugins/ios-push/PushRenewalTask.swift @@ -0,0 +1,62 @@ +import BackgroundTasks +import Foundation +import UIKit + +/// The app's side of PushRenewal: every shared account's subscription is +/// renewed when the app comes back to the foreground (the JS refresh runs +/// only on a cold start), and from a background app refresh task that iOS +/// runs now and then while the app is not used at all. +/// +/// The config plugin (plugins/ios-push/index.js) compiles this into the app, +/// calls register() from AppDelegate's didFinishLaunching (BGTaskScheduler +/// wants its handlers registered before launch ends), and lists the task id +/// in Info.plist (BGTaskSchedulerPermittedIdentifiers, UIBackgroundModes +/// fetch). +enum PushRenewalTask { + static let identifier = "org.bulwarkmail.mobile.push-renewal" + /// A subscription lasts seven days; asking twice a day leaves iOS room to + /// skip runs (it decides when, and whether, the task runs). + static let interval: TimeInterval = 12 * 60 * 60 + private static var observers: [NSObjectProtocol] = [] + + static func register() { + let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in + guard let refresh = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + handle(refresh) + } + if !registered { pushLog.error("push renewal task not registered") } + let center = NotificationCenter.default + observers = [ + center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in + // No token refresh here: the app's own code refreshes on foreground. + Task { await PushRenewal.renewAll(allowRefresh: false) } + }, + center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in + schedule() + }, + ] + } + + static func schedule() { + let request = BGAppRefreshTaskRequest(identifier: identifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: interval) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + // The simulator has no background refresh. + pushLog.error("push renewal task not scheduled: \(String(describing: error), privacy: .public)") + } + } + + private static func handle(_ task: BGAppRefreshTask) { + schedule() + let work = Task { + await PushRenewal.renewAll(allowRefresh: true) + task.setTaskCompleted(success: !Task.isCancelled) + } + task.expirationHandler = { work.cancel() } + } +} diff --git a/plugins/ios-push/SharedKeychain.swift b/plugins/ios-push/SharedKeychain.swift index 77668029..bc68fd30 100644 --- a/plugins/ios-push/SharedKeychain.swift +++ b/plugins/ios-push/SharedKeychain.swift @@ -1,6 +1,11 @@ import Foundation +import os import Security +/// Ids and steps only, never credentials or message content. Shared by the +/// notification extension and the app's push renewal (PushRenewal.swift). +let pushLog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.bulwarkmail.mobile", category: "push") + /// Items the app shares with this extension through a keychain access group /// (BulwarkKeychainGroup in Info.plist). The app writes them with /// expo-secure-store: generic passwords whose account and generic attributes @@ -31,6 +36,38 @@ enum SharedKeychain { return item as? Data } + /// SecureStore keys: letters, digits, ".", "-", "_" only. The app builds + /// the same key part (src/lib/ios-push-shared.ts keyPart). + static func keyPart(_ id: String) -> String { + String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" }) + } + + /// The keys of the shared items whose key starts with `prefix`. + static func keys(withPrefix prefix: String) -> [String] { + guard let group else { return [] } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: group, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + var items: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &items) + guard status == errSecSuccess, let list = items as? [[String: Any]] else { + if status != errSecItemNotFound { + pushLog.error("keychain listing failed: \(status, privacy: .public)") + } + return [] + } + return list.compactMap { item in + let account = item[kSecAttrAccount as String] + let key = (account as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? (account as? String) + guard let key, key.hasPrefix(prefix) else { return nil } + return key + } + } + static func readJSON(_ key: String) -> [String: Any]? { guard let data = read(key) else { return nil } return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] diff --git a/plugins/ios-push/index.js b/plugins/ios-push/index.js index 2ce065de..4dcfb573 100644 --- a/plugins/ios-push/index.js +++ b/plugins/ios-push/index.js @@ -8,6 +8,11 @@ // only ids; the extension fetches sender and subject over JMAP on the device // before iOS shows the notification. Its sources live next to this file and // are copied into ios/ at prebuild, like everything else in ios/. +// - Push renewal (PushRenewal.swift): Stalwart drops a subscription seven days +// after its last refresh. The extension extends the pushed account's, and +// the app (PushRenewalTask.swift, compiled into the app target) every +// account's on foreground and from a background app refresh task, which +// AppDelegate registers at launch and Info.plist permits. // // Options (app.config.js): // apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins) @@ -20,7 +25,10 @@ const fs = require('node:fs'); const path = require('node:path'); const { + IOSConfig, + withAppDelegate, withEntitlementsPlist, + withInfoPlist, withXcodeProject, withDangerousMod, createRunOncePlugin, @@ -28,7 +36,17 @@ const { const TARGET = 'NotificationService'; const DEPLOYMENT_TARGET = '15.1'; -const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift']; +const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift']; +// Compiled into the app as well: the renewal on foreground and in the +// background refresh task. +const APP_SOURCE_FILES = ['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift']; +// PushRenewalTask.identifier +const RENEWAL_TASK_ID = 'org.bulwarkmail.mobile.push-renewal'; +const RENEWAL_REGISTER_CALL = 'PushRenewalTask.register()'; +// Where Expo's AppDelegate.swift hands launch to its superclass; the +// registration has to run before that returns (Expo's Maps plugin uses the +// same anchor). +const APP_DELEGATE_LAUNCH = /^([ \t]*)return super\.application\(\w+, didFinishLaunchingWithOptions: \w+\)/m; function sharedGroupSuffix() { return 'org.bulwarkmail.shared'; @@ -111,11 +129,70 @@ function withExtensionFiles(config) { infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }), ); fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist()); + const appDir = path.join(cfg.modRequest.platformProjectRoot, cfg.modRequest.projectName); + for (const file of APP_SOURCE_FILES) { + fs.copyFileSync(path.join(__dirname, file), path.join(appDir, file)); + } return cfg; }, ]); } +/** Compile the renewal sources into the app target. */ +function addAppSources(cfg) { + const projectName = cfg.modRequest.projectName; + for (const file of APP_SOURCE_FILES) { + IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ + filepath: `${projectName}/${file}`, + groupName: projectName, + project: cfg.modResults, + }); + } +} + +/** + * The app's Info.plist entries for the renewal: the refresh task id, the + * background mode it needs, and the keychain group SharedKeychain reads + * (the extension's Info.plist names the same group). + */ +function setRenewalInfoPlist(plist, { teamId }) { + const ids = new Set(plist.BGTaskSchedulerPermittedIdentifiers ?? []); + ids.add(RENEWAL_TASK_ID); + plist.BGTaskSchedulerPermittedIdentifiers = [...ids]; + const modes = new Set(plist.UIBackgroundModes ?? []); + modes.add('fetch'); + plist.UIBackgroundModes = [...modes]; + plist.BulwarkKeychainGroup = `${teamId || '$(DEVELOPMENT_TEAM)'}.${sharedGroupSuffix()}`; + return plist; +} + +/** AppDelegate.swift with the renewal task registered during launch. */ +function addRenewalToAppDelegate(contents) { + if (contents.includes(RENEWAL_REGISTER_CALL)) return contents; + if (!APP_DELEGATE_LAUNCH.test(contents)) { + throw new Error('plugins/ios-push: AppDelegate.swift has no didFinishLaunching return to register push renewal before'); + } + return contents.replace( + APP_DELEGATE_LAUNCH, + (line, indent) => `${indent}// plugins/ios-push: BGTaskScheduler handlers must exist before launch ends.\n${indent}${RENEWAL_REGISTER_CALL}\n${line}`, + ); +} + +function withRenewal(config, { teamId }) { + config = withInfoPlist(config, (cfg) => { + setRenewalInfoPlist(cfg.modResults, { teamId }); + return cfg; + }); + config = withAppDelegate(config, (cfg) => { + if (cfg.modResults.language !== 'swift') { + throw new Error('plugins/ios-push: push renewal expects a Swift AppDelegate'); + } + cfg.modResults.contents = addRenewalToAppDelegate(cfg.modResults.contents); + return cfg; + }); + return config; +} + function setReleaseProfiles(cfg, { appProfile, extensionProfile }) { if (!appProfile && !extensionProfile) return; const project = cfg.modResults; @@ -137,6 +214,7 @@ function setReleaseProfiles(cfg, { appProfile, extensionProfile }) { function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) { return withXcodeProject(config, (cfg) => { addExtensionTarget(cfg, { teamId }); + addAppSources(cfg); setReleaseProfiles(cfg, { appProfile, extensionProfile }); return cfg; }); @@ -189,7 +267,10 @@ function withIosPush(config, options = {}) { config = withAppEntitlements(config, { apsEnvironment }); config = withExtensionFiles(config); config = withExtensionTarget(config, { teamId, appProfile, extensionProfile }); + config = withRenewal(config, { teamId }); return config; } module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0'); +// For the tests (src/lib/__tests__/ios-push-renewal.test.ts). +module.exports.internals = { addRenewalToAppDelegate, setRenewalInfoPlist, SOURCE_FILES, APP_SOURCE_FILES, RENEWAL_TASK_ID }; diff --git a/src/lib/__tests__/ios-push-renewal.test.ts b/src/lib/__tests__/ios-push-renewal.test.ts new file mode 100644 index 00000000..8b04a8b4 --- /dev/null +++ b/src/lib/__tests__/ios-push-renewal.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; + +vi.mock('../ios-push-shared', () => ({ + apnsEnvironment: () => 'production', + writeIosPushAccount: vi.fn(async () => undefined), + removeIosPushAccount: vi.fn(async () => undefined), +})); + +import { shareAccountWithExtension } from '../push-notifications'; +import { writeIosPushAccount } from '../ios-push-shared'; + +// iOS push lapsed a week after the app was last opened: Stalwart drops a +// subscription seven days after its last refresh, and only the app's JS +// refreshed it. The native side now extends it (plugins/ios-push/ +// PushRenewal.swift) from the notification extension, on foreground and from +// a background refresh task, with what the app shares about the subscription. + +const ROOT = path.resolve(__dirname, '../../..'); +const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +describe('the subscription the app shares with the native renewal', () => { + const API = 'https://mail.example.org/jmap/'; + const LOCAL = 'ada@mail.example.org'; + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + (Platform as { OS: string }).OS = 'ios'; + await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'c' })); + }); + + afterEach(() => { + (Platform as { OS: string }).OS = 'android'; + }); + + it('names the subscription id, this device\'s client id and the recorded expiry', async () => { + await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b'); + await AsyncStorage.setItem(`push:deviceClientId:v2:${LOCAL}`, 'dev-1'); + await AsyncStorage.setItem(`push:subscriptionExpires:v1:${LOCAL}`, '2026-11-07T10:00:00Z'); + await shareAccountWithExtension(LOCAL, 'c', API, 'ada'); + expect(writeIosPushAccount).toHaveBeenCalledWith('c', { + credentialsKey: expect.any(String), + apiUrl: API, + label: 'ada', + subscriptionId: 'b', + deviceClientId: 'dev-1', + subscriptionExpires: '2026-11-07T10:00:00Z', + }); + }); + + it('shares no subscription for an account the user turned push off for', async () => { + await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b'); + await AsyncStorage.setItem(`push:optedOut:v1:${LOCAL}`, '1'); + await shareAccountWithExtension(LOCAL, 'c', API); + expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API }); + }); +}); + +describe('the config plugin wires the renewal into the app', () => { + const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-push/index.js')) as { + internals: { + addRenewalToAppDelegate(contents: string): string; + setRenewalInfoPlist(plist: Record, opts: { teamId: string }): Record; + SOURCE_FILES: string[]; + APP_SOURCE_FILES: string[]; + RENEWAL_TASK_ID: string; + }; + }; + const { internals } = plugin; + + // Expo SDK 54's AppDelegate.swift, shortened. + const APP_DELEGATE = [ + 'public class AppDelegate: ExpoAppDelegate {', + ' public override func application(', + ' _ application: UIApplication,', + ' didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil', + ' ) -> Bool {', + ' bindReactNativeFactory(factory)', + '', + ' return super.application(application, didFinishLaunchingWithOptions: launchOptions)', + ' }', + '}', + ].join('\n'); + + it('registers the refresh task in didFinishLaunching, before it returns', () => { + const out = internals.addRenewalToAppDelegate(APP_DELEGATE); + const call = out.indexOf(' PushRenewalTask.register()\n'); + expect(call).toBeGreaterThan(out.indexOf('bindReactNativeFactory')); + expect(call).toBeLessThan(out.indexOf('return super.application(')); + // Once, however often prebuild runs. + expect(internals.addRenewalToAppDelegate(out)).toBe(out); + }); + + it('fails the prebuild rather than skipping the registration', () => { + expect(() => internals.addRenewalToAppDelegate('class AppDelegate {}')).toThrow(/didFinishLaunching/); + }); + + it('permits the task, adds the fetch background mode and names the shared keychain group', () => { + const plist = internals.setRenewalInfoPlist({ UIBackgroundModes: ['remote-notification'] }, { teamId: 'TEAM' }); + expect(plist.BGTaskSchedulerPermittedIdentifiers).toEqual(['org.bulwarkmail.mobile.push-renewal']); + expect(plist.UIBackgroundModes).toEqual(['remote-notification', 'fetch']); + expect(plist.BulwarkKeychainGroup).toBe('TEAM.org.bulwarkmail.shared'); + }); + + it('compiles the renewal into the extension and the app', () => { + expect(internals.SOURCE_FILES).toContain('PushRenewal.swift'); + expect(internals.APP_SOURCE_FILES).toEqual( + expect.arrayContaining(['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift']), + ); + expect(internals.APP_SOURCE_FILES).not.toContain('NotificationService.swift'); + for (const file of [...internals.SOURCE_FILES, ...internals.APP_SOURCE_FILES]) { + expect(fs.existsSync(path.join(ROOT, 'plugins/ios-push', file))).toBe(true); + } + expect(read('plugins/ios-push/PushRenewalTask.swift')).toContain(`static let identifier = "${internals.RENEWAL_TASK_ID}"`); + }); +}); + +describe('the native renewal', () => { + const renewal = read('plugins/ios-push/PushRenewal.swift'); + const task = read('plugins/ios-push/PushRenewalTask.swift'); + const nse = read('plugins/ios-push/NotificationService.swift'); + const js = read('src/lib/push-notifications.ts'); + + it('extends at the same point and asks for the same lifetime as the Android push task', () => { + expect(js).toMatch(/const BACKGROUND_EXTEND_BELOW_MS = 6 \* 24 \* 60 \* 60 \* 1000;/); + expect(renewal).toMatch(/static let extendBelowSeconds: TimeInterval = 6 \* 24 \* 60 \* 60\r?\n/); + expect(js).toMatch(/const SUBSCRIPTION_EXPIRES_DAYS = 90;/); + expect(renewal).toMatch(/static let wantedSeconds: TimeInterval = 90 \* 24 \* 60 \* 60\r?\n/); + }); + + it('reads the server\'s copy first and extends only this device\'s own subscription', () => { + const body = renewal.slice(renewal.indexOf('static func renew(key:')); + const get = body.indexOf('Jmap.pushSubscription('); + const own = body.indexOf('isOwn(current, deviceClientId: deviceClientId)'); + const set = body.indexOf('Jmap.extendPushSubscription('); + expect(get).toBeGreaterThan(-1); + expect(own).toBeGreaterThan(get); + expect(set).toBeGreaterThan(own); + }); + + it('runs from the notification extension after the fetch for the pushed account', () => { + const renew = nse.indexOf('await PushRenewal.renewIfDue(jmapAccountId: accountId)'); + expect(renew).toBeGreaterThan(nse.indexOf('await NotificationService.enrich(')); + expect(renew).toBeLessThan(nse.indexOf('self?.finish(enriched)')); + }); + + it('runs from the app on foreground without refreshing tokens, and from a background refresh task', () => { + expect(task).toMatch(/willEnterForegroundNotification[\s\S]*?PushRenewal\.renewAll\(allowRefresh: false\)/); + expect(task).toContain('BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier'); + expect(task).toMatch(/BGAppRefreshTaskRequest\(identifier: identifier\)/); + expect(task).toMatch(/task\.expirationHandler = \{ work\.cancel\(\) \}/); + }); +}); diff --git a/src/lib/ios-push-shared.ts b/src/lib/ios-push-shared.ts index 2f28e57e..d33c97f1 100644 --- a/src/lib/ios-push-shared.ts +++ b/src/lib/ios-push-shared.ts @@ -69,6 +69,18 @@ export interface IosPushAccount { * group account that has this account's id. */ label?: string; + /** + * This device's PushSubscription on the account's server, so the native + * side keeps it alive (plugins/ios-push/PushRenewal.swift): Stalwart drops + * a subscription seven days after its last refresh, and on iOS the app's JS + * runs only when the app is opened. The native side extends it only while + * the server still lists it with this device's client id, and writes the + * expiry it settled on back here. + */ + subscriptionId?: string; + deviceClientId?: string; + /** When the subscription expires, as last recorded (UTCDate). */ + subscriptionExpires?: string; } export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise { diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 1993c44e..d83b4807 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1073,9 +1073,32 @@ export async function shareAccountWithExtension( credentialsKey: credentialsKey(accountId), apiUrl, ...(username ? { label: username } : {}), + ...(await sharedSubscription(accountId)), }).catch(() => undefined); } +/** + * The account's subscription as the iOS native renewal needs it + * (IosPushAccount.subscriptionId). Nothing for an account the user turned + * push off for, or without a recorded subscription: nothing to keep alive. + */ +async function sharedSubscription( + accountId: string, +): Promise<{ subscriptionId?: string; deviceClientId?: string; subscriptionExpires?: string }> { + const [subscriptionId, deviceClientId, expires, optedOut] = await Promise.all([ + AsyncStorage.getItem(subscriptionIdKey(accountId)), + AsyncStorage.getItem(deviceClientIdKey(accountId)), + AsyncStorage.getItem(subscriptionExpiresKey(accountId)), + AsyncStorage.getItem(optedOutKey(accountId)), + ]); + if (!subscriptionId || optedOut) return {}; + return { + subscriptionId, + ...(deviceClientId ? { deviceClientId } : {}), + ...(expires ? { subscriptionExpires: expires } : {}), + }; +} + /** * Whether a local account (username@host) is the login a relay label names. * The label is the username the account registered with the relay.