diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index b43af9aab0..766df24554 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -515,6 +515,7 @@ open class Push: Service { Push.apnsPrepare(self) } + Push.scheduleReconcile(self) let generation = sync.sync { closeGeneration } let mqtt: MQTTClient do { @@ -1326,6 +1327,10 @@ extension Push { private static var apnsProxyInstalled = false private static var apnsRegistrationRequested = false static var apnsDeliveries = Deliveries(defaults: Shared.defaults) + static var apnsLinkedTopics = LinkedTopics(defaults: Shared.defaults) + private static var apnsReconciled: Set = [] + /// How long a launch has to subscribe again before the topics it doesn't claim are unlinked. + static var reconcileDelay: TimeInterval = 30 private final class WeakPush { weak var push: Push? @@ -1417,6 +1422,41 @@ extension Push { // Gone already, or the session was ended first. } catch { push.surface(error) + return + } + apnsLinkedTopics.removeTarget(targetId) + } + + /// Once per launch, after the app has had time to subscribe again, unlink the topics an earlier + /// launch linked that no background subscription claims now, so the server stops waking the + /// device for them. A topic subscribed later is linked again. + static func scheduleReconcile(_ push: Push) { + guard !Shared.inServiceExtension, let userId = push.remoteUserId() else { + return + } + let targetId = Ids.target(installation: Installation.id, userId: userId) + guard apnsLocked({ apnsReconciled.insert(targetId).inserted }) else { + return + } + let scheduler = WeakPush(push) + Task { + try? await Task.sleep(nanoseconds: UInt64(reconcileDelay * 1_000_000_000)) + await reconcile(userId: userId, targetId: targetId, fallback: scheduler.push) + } + } + + static func reconcile(userId: String, targetId: String, fallback: Push?) async { + let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } }.filter { $0.remoteUserId() == userId } + guard let push = pushes.first(where: { !$0.backgroundTopics().isEmpty }) else { + // No background subscription this launch: the target and its subscribers go, as on close(). + if apnsLinkedTopics.isRegistered(targetId), let push = fallback ?? pushes.first { + await deleteTarget(push, targetId: targetId) + } + return + } + let claimed = Set(pushes.flatMap { $0.backgroundTopics() }) + for topic in LinkedTopics.stale(saved: apnsLinkedTopics.topics(targetId), claimed: claimed) { + await unlink(push, targetId: targetId, topic: topic) } } @@ -1467,6 +1507,8 @@ extension Push { return } apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } + apnsLinkedTopics.register(targetId) + scheduleReconcile(push) // A topic unsubscribed while the target request was in flight is not linked. let wanted = Set(push.backgroundTopics()) for topic in topics where Ids.isMessagingTopic(topic) && wanted.contains(topic) { @@ -1531,6 +1573,7 @@ extension Push { return } apnsLocked { _ = apnsLinked[targetId, default: []].insert(topic) } + apnsLinkedTopics.add(topic, to: targetId) // Unsubscribed while the subscriber was being created: `apnsDisable` found nothing to // unlink then, so undo it here. if !push.backgroundTopics().contains(topic) { @@ -1539,6 +1582,7 @@ extension Push { } private static func unlink(_ push: Push, targetId: String, topic: String) async { + var unlinked = true do { let _: Any = try await push.client.call( method: "DELETE", @@ -1550,12 +1594,19 @@ extension Push { // Gone already. } catch { push.surface(error) + unlinked = false } apnsLocked { _ = apnsLinked[targetId]?.remove(topic) } - // Back in a background subscription while the delete was in flight: `link` saw the topic - // still linked then and skipped it, so link it again now. - if push.backgroundTopics().contains(topic) { - await link(push, targetId: targetId, topic: topic) + if unlinked { + apnsLinkedTopics.remove(topic, from: targetId) + } + // Back in a background subscription of any Push of the user while the delete was in flight: + // `link` saw the topic still linked then and skipped it, so link it again now. + let userId = push.remoteUserId() + let claimer = apnsLocked { apnsPushes.values.compactMap { $0.push } } + .first { $0.remoteUserId() == userId && $0.backgroundTopics().contains(topic) } + if let claimer { + await link(claimer, targetId: targetId, topic: topic) } } @@ -1755,6 +1806,64 @@ extension Push { /// Message ids already delivered, by MQTT or APNs, so each message reaches the app once. /// Bounded, and kept across launches. + /// The targets this install registered and the topics linked to each, kept across launches and + /// shared with the extension. + final class LinkedTopics { + static let key = "{{ spec.info.title | caseLower }}.push.linked" + + private let defaults: UserDefaults + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + func topics(_ targetId: String) -> Set { + Shared.locked { Set(read()[targetId] ?? []) } + } + + func add(_ topic: String, to targetId: String) { + change { $0[targetId] = Array(Set($0[targetId] ?? []).union([topic])).sorted() } + } + + func remove(_ topic: String, from targetId: String) { + change { $0[targetId] = $0[targetId]?.filter { $0 != topic } } + } + + func removeTarget(_ targetId: String) { + change { $0[targetId] = nil } + } + + /// Record a target this install registered, linked topics or not. + func register(_ targetId: String) { + change { $0[targetId] = $0[targetId] ?? [] } + } + + func isRegistered(_ targetId: String) -> Bool { + Shared.locked { read()[targetId] != nil } + } + + /// Saved topics no background subscription claims. + static func stale(saved: Set, claimed: Set) -> [String] { + saved.subtracting(claimed).sorted() + } + + private func read() -> [String: [String]] { + defaults.dictionary(forKey: LinkedTopics.key) as? [String: [String]] ?? [:] + } + + private func change(_ body: (inout [String: [String]]) -> Void) { + Shared.locked { + var links = read() + body(&links) + if links.isEmpty { + defaults.removeObject(forKey: LinkedTopics.key) + } else { + defaults.set(links, forKey: LinkedTopics.key) + } + } + } + } + final class Deliveries { static let capacity = 500 static let key = "{{ spec.info.title | caseLower }}.push.delivered" diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index f938c62c82..e149c74b0a 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -171,6 +171,37 @@ final class PushTests { PushMessage(topic: topic, payload: Array(text.utf8), qos: 1) } + @Test func linkedTopicsAreKeptAcrossLaunches() { + let shared = defaults() + let launch = Push.LinkedTopics(defaults: shared) + launch.add("news", to: "t1") + launch.add("sales", to: "t1") + launch.add("news", to: "t2") + launch.remove("sales", from: "t1") + + let next = Push.LinkedTopics(defaults: shared) + #expect(next.topics("t1") == ["news"]) + #expect(next.topics("t2") == ["news"]) + next.removeTarget("t1") + #expect(Push.LinkedTopics(defaults: shared).topics("t1").isEmpty) + } + + @Test func aRegisteredTargetIsKeptUntilItIsDeleted() { + let links = Push.LinkedTopics(defaults: defaults()) + #expect(!links.isRegistered("t1")) + links.register("t1") + links.add("news", to: "t1") + links.remove("news", from: "t1") + #expect(links.isRegistered("t1")) + links.removeTarget("t1") + #expect(!links.isRegistered("t1")) + } + + @Test func topicsNoSubscriptionClaimsAreStale() { + #expect(Push.LinkedTopics.stale(saved: ["news", "sales", "users-1"], claimed: ["news"]) == ["sales", "users-1"]) + #expect(Push.LinkedTopics.stale(saved: ["news"], claimed: ["news", "deals"]).isEmpty) + } + @Test func onlyRawPayloadsAndEnvelopesWithTextAreShown() { let shown: [(String, Bool, Bool)] = [ ("plain text", true, true),