diff --git a/src/SDK/Language/Apple.php b/src/SDK/Language/Apple.php index 36ac976ce1..f18e9ecf8c 100644 --- a/src/SDK/Language/Apple.php +++ b/src/SDK/Language/Apple.php @@ -261,6 +261,11 @@ public function getFiles(): array 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Services/Push.swift', 'template' => '/apple/Sources/Services/Push.swift.twig', ], + [ + 'scope' => 'default', + 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Services/PushServiceExtension.swift', + 'template' => '/apple/Sources/Services/PushServiceExtension.swift.twig', + ], [ 'scope' => 'default', 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/WebSockets/HTTPHandler.swift', diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 1043d77bd7..38047990a6 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -275,6 +275,8 @@ open class Push: Service { private var closedClients = Set() private var closeGeneration = 0 + private var heartbeat: DispatchSourceTimer? + private var onOpenCb: (() -> Void)? private var onCloseCb: (() -> Void)? private var onErrorCb: ((Swift.Error) -> Void)? @@ -585,6 +587,7 @@ open class Push: Service { if background { Push.apnsEnable(self, topics: topics) } + deliverHandedOff() let unsubscribe: () -> Void = { [weak self] in guard let self else { return } @@ -697,6 +700,7 @@ open class Push: Service { } return existing } + stopHeartbeat() guard let client else { return } DispatchQueue.global().async { [weak self] in do { @@ -788,7 +792,7 @@ open class Push: Service { /// The session set on the client (`Client.setSession`), else the one the app signed in with /// through it (e.g. `Account.createEmailPasswordSession`), read from the cookies it saved. - private func session() -> String { + func session() -> String { if let session = client.config["session"], !session.isEmpty { return session } @@ -931,9 +935,11 @@ open class Push: Service { return } if let self, let mqtt, self.sync.sync(execute: { self.closedClients.remove(ObjectIdentifier(mqtt)) != nil }) { + self.stopHeartbeat() self.onCloseCb?() return } + self?.stopHeartbeat() if case .failure(let error) = result { self?.surface(Push.describe(error)) } else if let self, !self.sync.sync(execute: { self.closing }) { @@ -966,6 +972,7 @@ open class Push: Service { throw described } + startHeartbeat() onOpenCb?() return mqtt } @@ -1024,6 +1031,7 @@ open class Push: Service { if !infos.isEmpty { try await brokerSubscribe(client, infos) } + startHeartbeat() onOpenCb?() return } catch { @@ -1155,16 +1163,17 @@ open class Push: Service { content.title = title content.body = notification.body ?? (notification.present ? "" : message.data) content.sound = .default - content.userInfo = ["topic": message.topic, "payload": message.data] + content.userInfo = ["topic": message.topic, "payload": message.data, Shared.bannerKey: true] + let identifier = RemoteMessage.id(payload: message.payload) ?? UUID().uuidString guard let image = notification.image.flatMap(URL.init(string:)) else { - post(content) + post(content, identifier: identifier) return } Push.downloadAttachment(image) { [weak self] attachment in if let attachment { content.attachments = [attachment] } - self?.post(content) + self?.post(content, identifier: identifier) } #endif } @@ -1180,8 +1189,8 @@ open class Push: Service { "image/heic": "heic", ] - private func post(_ content: UNNotificationContent) { - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + private func post(_ content: UNNotificationContent, identifier: String) { + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) { [weak self] error in if let error { self?.surface(error) @@ -1191,7 +1200,7 @@ open class Push: Service { /// Download a notification image into a file a notification can attach, or nil when it cannot be fetched or is /// not an image type notifications show. - private static func downloadAttachment(_ url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) { + static func downloadAttachment(_ url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) { URLSession.shared.downloadTask(with: URLRequest(url: url, timeoutInterval: 10)) { location, response, _ in guard let location, let fileExtension = attachmentExtension(url, mimeType: response?.mimeType) else { completion(nil) @@ -1269,7 +1278,7 @@ private func jsonString(base64: String, key: String) -> String? { } /// MQTT topic-filter match with `+` (single level) and `#` (multi level). -private func matches(filter: String, topic: String) -> Bool { +func matches(filter: String, topic: String) -> Bool { let filterParts = filter.split(separator: "/", omittingEmptySubsequences: false) let topicParts = topic.split(separator: "/", omittingEmptySubsequences: false) for (index, part) in filterParts.enumerated() { @@ -1308,7 +1317,7 @@ extension Push { private static var apnsLinked: [String: Set] = [:] private static var apnsProxyInstalled = false private static var apnsRegistrationRequested = false - static var apnsDeliveries = Deliveries(defaults: .standard) + static var apnsDeliveries = Deliveries(defaults: Shared.defaults) private final class WeakPush { weak var push: Push? @@ -1349,6 +1358,7 @@ extension Push { /// Start APNs for a push with background subscriptions on `topics`. static func apnsEnable(_ push: Push, topics: [String]) { + Shared.share(push) apnsPrepare(push) let token = apnsLocked { apnsToken } if autoRegistration { @@ -1361,6 +1371,7 @@ extension Push { /// Unlink `topics` from APNs once they have no background subscription left. static func apnsDisable(_ push: Push, topics: [String]) { + Shared.share(push) guard let userId = push.remoteUserId() else { return } @@ -1436,11 +1447,12 @@ extension Push { "environment": Environment.current.rawValue, "bundleId": Bundle.main.bundleIdentifier, ] + let headers = ["X-Appwrite-Project": push.client.config["project"] ?? "", "content-type": "application/json"] do { let _: Any = try await push.client.call( method: "POST", path: "/account/targets/push", - headers: ["content-type": "application/json"], + headers: headers, params: params, converter: { $0 } ) @@ -1451,7 +1463,7 @@ extension Push { let _: Any = try await push.client.call( method: "PUT", path: "/account/targets/\(targetId)/push", - headers: ["content-type": "application/json"], + headers: headers, params: params.filter { $0.key != "targetId" }, converter: { $0 } ) @@ -1540,6 +1552,56 @@ extension Push { sync.sync { Array(Set(subscriptions.values.filter { $0.background }.map { $0.filter })) } } + /// The notification title of each background topic. + func backgroundTitles() -> [String: String] { + sync.sync { + var titles: [String: String] = [:] + for entry in subscriptions.values where entry.background { + titles[entry.filter] = entry.title ?? titles[entry.filter] ?? entry.filter + } + return titles + } + } + + /// Deliver what the Notification Service Extension fetched, without banners: iOS already showed them. + func deliverHandedOff() { + guard !Shared.inServiceExtension else { + return + } + let filters = sync.sync { subscriptions.values.map { $0.filter } } + let messages = Shared.takeHandedOff { topic in + filters.contains { matches(filter: $0, topic: topic) } + } + for message in messages { + deliver(message, notifyLocally: false) + } + } + + private func startHeartbeat() { + guard Shared.appGroup != nil, !Shared.inServiceExtension else { + return + } + let timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now(), repeating: Shared.heartbeatInterval) + timer.setEventHandler { Shared.markLive() } + let previous = sync.sync { () -> DispatchSourceTimer? in + let previous = heartbeat + heartbeat = timer + return previous + } + previous?.cancel() + timer.resume() + } + + private func stopHeartbeat() { + let timer = sync.sync { () -> DispatchSourceTimer? in + let timer = heartbeat + heartbeat = nil + return timer + } + timer?.cancel() + } + /// The signed-in user's id, or nil without a credential. func remoteUserId() -> String? { try? currentUserId() @@ -1570,6 +1632,7 @@ extension Push { let connected: MQTTClient if let client { _ = try await client.v5.connect(cleanStart: false, properties: try connectProperties()) + startHeartbeat() connected = client } else { connected = try await connect() @@ -1634,7 +1697,6 @@ extension Push { static let capacity = 500 static let key = "{{ spec.info.title | caseLower }}.push.delivered" - private let lock = NSLock() private let defaults: UserDefaults private var ids: [String] private var lookup: Set @@ -1648,26 +1710,36 @@ extension Push { /// Whether `id` was delivered before. func contains(_ id: String) -> Bool { - lock.lock() - defer { lock.unlock() } - return lookup.contains(id) + Shared.locked { + reload() + return lookup.contains(id) + } } /// Record `id`: true when it is new, false when it was delivered before. @discardableResult func record(_ id: String) -> Bool { - lock.lock() - defer { lock.unlock() } - guard lookup.insert(id).inserted else { - return false + Shared.locked { + reload() + guard lookup.insert(id).inserted else { + return false + } + ids.append(id) + if ids.count > Deliveries.capacity { + ids.removeFirst(ids.count - Deliveries.capacity) + lookup = Set(ids) + } + defaults.set(ids, forKey: Deliveries.key) + return true } - ids.append(id) - if ids.count > Deliveries.capacity { - ids.removeFirst(ids.count - Deliveries.capacity) - lookup = Set(ids) + } + + private func reload() { + let saved = defaults.stringArray(forKey: Deliveries.key) ?? [] + if saved != ids { + ids = saved + lookup = Set(saved) } - defaults.set(ids, forKey: Deliveries.key) - return true } } diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig new file mode 100644 index 0000000000..257251923c --- /dev/null +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -0,0 +1,408 @@ +import Foundation + +#if canImport(Security) + import Security +#endif +#if canImport(UserNotifications) + import UserNotifications +#endif + +extension Push { + /// State the app and its Notification Service Extension share through their App Group. + enum Shared { + static let appGroupKey = "AppwritePushAppGroup" + static let bannerKey = "{{ spec.info.title | caseLower }}.push.banner" + static let heartbeatInterval = DispatchTimeInterval.seconds(3) + static let liveWindow: TimeInterval = 10 + static let pendingLifetime: TimeInterval = 30 + static var inServiceExtension = false + + private static let prefix = "{{ spec.info.title | caseLower }}.push.shared" + private static let lock = NSLock() + #if canImport(Darwin) + private static let lockFile: Int32 = { + guard let group = appGroup, let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { + return -1 + } + return open(url.appendingPathComponent("\(prefix).lock").path, O_RDWR | O_CREAT, 0o600) + }() + #endif + + /// Run `body` while holding the lock the app and its extension share, as both write this state. + static func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + #if canImport(Darwin) + if lockFile >= 0 { + flock(lockFile, LOCK_EX) + } + defer { + if lockFile >= 0 { + flock(lockFile, LOCK_UN) + } + } + #endif + return try body() + } + + static var appGroup: String? { + guard let group = Bundle.main.object(forInfoDictionaryKey: appGroupKey) as? String, !group.isEmpty else { + return nil + } + return group + } + + static var defaults: UserDefaults = appGroup.flatMap { UserDefaults(suiteName: $0) } ?? .standard + + // MARK: Session + + struct Session: Codable, Equatable { + var endpoint: String + var pushEndpoint: String? + var project: String + var clientId: String + /// Background topic filters and their notification titles. + var topics: [String: String] + var jwt: Bool + } + + /// What the extension needs to connect as this app, kept while `push` has background subscriptions. + static func share(_ push: Push) { + guard appGroup != nil, !inServiceExtension else { + return + } + let topics = push.backgroundTitles() + guard !topics.isEmpty else { + save(nil) + saveCredential(nil) + return + } + let jwt = push.client.config["jwt"] ?? "" + save( + Session( + endpoint: push.client.endPoint, + pushEndpoint: push.client.endPointPush, + project: push.client.config["project"] ?? "", + clientId: push.client.pushClientId ?? "", + topics: topics, + jwt: !jwt.isEmpty + )) + saveCredential(jwt.isEmpty ? push.session() : jwt) + } + + static func save(_ session: Session?) { + let key = "\(prefix).session" + if let session, let data = try? JSONEncoder().encode(session) { + defaults.set(data, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + static func loadSession() -> Session? { + defaults.data(forKey: "\(prefix).session").flatMap { try? JSONDecoder().decode(Session.self, from: $0) } + } + + // MARK: Heartbeat + + static func markLive(at date: Date = Date()) { + defaults.set(date.timeIntervalSince1970, forKey: "\(prefix).live") + } + + static func isAppLive(at date: Date = Date()) -> Bool { + let live = defaults.double(forKey: "\(prefix).live") + return live > 0 && date.timeIntervalSince1970 - live < liveWindow + } + + // MARK: Messages + + private struct Stored: Codable { + let topic: String + let payload: Data + let qos: Int + let at: Date + + init(_ message: PushMessage) { + topic = message.topic + payload = Data(message.payload) + qos = message.qos + at = Date() + } + + var message: PushMessage { + PushMessage(topic: topic, payload: [UInt8](payload), qos: qos) + } + } + + static func handOff(_ messages: [PushMessage]) { + append(messages, to: "\(prefix).handedOff") + } + + static func takeHandedOff(matching: (String) -> Bool) -> [PushMessage] { + locked { + let key = "\(prefix).handedOff" + let stored = read(key) + let taken = stored.filter { matching($0.topic) } + guard !taken.isEmpty else { + return [] + } + write(stored.filter { !matching($0.topic) }, to: key) + return taken.map(\.message) + } + } + + static func enqueue(_ messages: [PushMessage]) { + append(messages, to: "\(prefix).pending") + } + + static func dequeue(at date: Date = Date()) -> PushMessage? { + locked { + let key = "\(prefix).pending" + var stored = read(key).filter { date.timeIntervalSince($0.at) < pendingLifetime } + guard !stored.isEmpty else { + write([], to: key) + return nil + } + let first = stored.removeFirst() + write(stored, to: key) + return first.message + } + } + + private static func append(_ messages: [PushMessage], to key: String) { + guard !messages.isEmpty else { + return + } + locked { + write(read(key) + messages.map(Stored.init), to: key) + } + } + + private static func read(_ key: String) -> [Stored] { + defaults.data(forKey: key).flatMap { try? JSONDecoder().decode([Stored].self, from: $0) } ?? [] + } + + private static func write(_ stored: [Stored], to key: String) { + if stored.isEmpty { + defaults.removeObject(forKey: key) + } else if let data = try? JSONEncoder().encode(stored) { + defaults.set(data, forKey: key) + } + } + + // MARK: Credential + + static func loadCredential() -> String? { + #if canImport(Security) + var query = credentialQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + #else + return nil + #endif + } + + private static func saveCredential(_ credential: String?) { + #if canImport(Security) + let query = credentialQuery() + SecItemDelete(query as CFDictionary) + guard let credential, !credential.isEmpty else { + return + } + var item = query + item[kSecValueData as String] = Data(credential.utf8) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(item as CFDictionary, nil) + #endif + } + + #if canImport(Security) + private static func credentialQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: prefix, + kSecAttrAccount as String: "credential", + ] + if let appGroup { + query[kSecAttrAccessGroup as String] = appGroup + } + return query + } + #endif + } +} + +#if os(iOS) && canImport(UserNotifications) + /// The base class of an app's Notification Service Extension, which is all of its code: + /// + /// import {{ spec.info.title | caseUcfirst }} + /// + /// class NotificationService: {{ spec.info.title | caseUcfirst }}NotificationService {} + /// + /// iOS runs it for each visible push with `mutable-content`, even when the app was swiped away. + /// It fetches the messages waiting for the app over MQTT, with the app's session, and shows one in + /// place of the push's placeholder. The app and the extension must share an App Group, named by + /// `AppwritePushAppGroup` in both Info.plists. + open class {{ spec.info.title | caseUcfirst }}NotificationService: UNNotificationServiceExtension { + private let lock = NSLock() + private var contentHandler: ((UNNotificationContent) -> Void)? + private var content: UNMutableNotificationContent? + private var push: Push? + + open override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() + lock.lock() + self.contentHandler = contentHandler + self.content = content + lock.unlock() + Task { + await self.fill(content) + self.finish() + } + } + + open override func serviceExtensionTimeWillExpire() { + lock.lock() + let push = self.push + lock.unlock() + push?.close() + finish() + } + + private func finish() { + lock.lock() + let handler = contentHandler + contentHandler = nil + let content = self.content + lock.unlock() + if let handler, let content { + handler(content) + } + } + + private func fill(_ content: UNMutableNotificationContent) async { + Push.Shared.inServiceExtension = true + guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { + return + } + var message = Push.Shared.dequeue() + // The session allows one connection, and the live app already has the message. An app killed + // moments ago still looks live but posted no banner, so fetch then. + if message == nil, Push.Shared.isAppLive(), await replaceAppBanner(in: content) { + return + } + if message == nil { + let fetched = await fetch(session, credential: credential) + Push.Shared.handOff(fetched) + message = fetched.first + Push.Shared.enqueue(Array(fetched.dropFirst())) + } + if let message { + await show(message, titles: session.topics, in: content) + } + } + + private func fetch(_ session: Push.Shared.Session, credential: String) async -> [PushMessage] { + let client = Client().setEndpoint(session.endpoint).setProject(session.project) + if let pushEndpoint = session.pushEndpoint { + _ = client.setPushEndpoint(pushEndpoint) + } + if !session.clientId.isEmpty { + _ = client.setPushClientId(session.clientId) + } + _ = session.jwt ? client.setJWT(credential) : client.setSession(credential) + let push = Push(client) + keep(push) + + let received = Received() + do { + _ = try await push.subscribe(Array(session.topics.keys), retry: true) { received.add($0) } + } catch { + push.close() + return [] + } + let started = Date() + while Date().timeIntervalSince(started) < 20 { + try? await Task.sleep(nanoseconds: 300_000_000) + if let last = received.lastAt { + if Date().timeIntervalSince(last) > 1.5 { + break + } + } else if Date().timeIntervalSince(started) > 5 { + break + } + } + push.close() + return received.messages + } + + private func keep(_ push: Push) { + lock.lock() + self.push = push + lock.unlock() + } + + private func show(_ message: PushMessage, titles: [String: String], in content: UNMutableNotificationContent) async { + let notification = PushNotificationContent(message: message) + let title = titles.first { matches(filter: $0.key, topic: message.topic) }?.value + content.title = notification.title ?? title ?? message.topic + content.body = notification.body ?? (notification.present ? "" : message.data) + content.userInfo = ["topic": message.topic, "payload": message.data] + if let image = notification.image.flatMap(URL.init(string:)) { + let attachment = await withCheckedContinuation { continuation in + Push.downloadAttachment(image) { continuation.resume(returning: $0) } + } + if let attachment { + content.attachments = [attachment] + } + } + } + + private func replaceAppBanner(in content: UNMutableNotificationContent) async -> Bool { + let center = UNUserNotificationCenter.current() + let delivered = await center.deliveredNotifications() + guard + let latest = delivered.filter({ $0.request.content.userInfo[Push.Shared.bannerKey] as? Bool == true }) + .max(by: { $0.date < $1.date }), + Date().timeIntervalSince(latest.date) < Push.Shared.liveWindow + else { + return false + } + content.title = latest.request.content.title + content.body = latest.request.content.body + content.userInfo = latest.request.content.userInfo + center.removeDeliveredNotifications(withIdentifiers: [latest.request.identifier]) + return true + } + } + + private final class Received: @unchecked Sendable { + private let lock = NSLock() + private var stored: [PushMessage] = [] + private var last: Date? + + var messages: [PushMessage] { + lock.lock() + defer { lock.unlock() } + return stored + } + + var lastAt: Date? { + lock.lock() + defer { lock.unlock() } + return last + } + + func add(_ message: PushMessage) { + lock.lock() + stored.append(message) + last = Date() + lock.unlock() + } + } +#endif diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index b9a11d10f9..dde733653f 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -157,6 +157,77 @@ final class PushTests { #expect(results == [true, false], "delivered once") #expect(Push.apnsDeliveries.contains("m6")) } + + // MARK: Shared with the Notification Service Extension + + private func withSharedDefaults(_ body: () -> Void) { + let previous = Push.Shared.defaults + Push.Shared.defaults = defaults() + defer { Push.Shared.defaults = previous } + body() + } + + private func message(_ topic: String, _ text: String) -> PushMessage { + PushMessage(topic: topic, payload: Array(text.utf8), qos: 1) + } + + @Test func theSessionIsKeptForTheExtension() { + withSharedDefaults { + let session = Push.Shared.Session( + endpoint: "https://cloud.appwrite.io/v1", pushEndpoint: "mqtts://push.cloud.appwrite.io:8883", project: "p1", + clientId: "", topics: ["news": "News"], jwt: false) + Push.Shared.save(session) + #expect(Push.Shared.loadSession() == session) + Push.Shared.save(nil) + #expect(Push.Shared.loadSession() == nil) + } + } + + @Test func theAppIsLiveOnlyWhileItKeepsMarkingIt() { + withSharedDefaults { + let now = Date() + #expect(!Push.Shared.isAppLive(at: now)) + Push.Shared.markLive(at: now) + #expect(Push.Shared.isAppLive(at: now.addingTimeInterval(5))) + #expect(!Push.Shared.isAppLive(at: now.addingTimeInterval(Push.Shared.liveWindow + 1))) + } + } + + @Test func handedOffMessagesGoToTheMatchingTopicsOnce() { + withSharedDefaults { + Push.Shared.handOff([message("news", "a"), message("sales", "b"), message("news", "c")]) + let news = Push.Shared.takeHandedOff { $0 == "news" } + #expect(news.map(\.data) == ["a", "c"]) + #expect(Push.Shared.takeHandedOff { $0 == "news" }.isEmpty) + #expect(Push.Shared.takeHandedOff { _ in true }.map(\.data) == ["b"]) + } + } + + @Test func queuedMessagesComeOutInOrder() { + withSharedDefaults { + Push.Shared.enqueue([message("news", "1"), message("news", "2")]) + #expect(Push.Shared.dequeue()?.data == "1") + #expect(Push.Shared.dequeue()?.data == "2") + #expect(Push.Shared.dequeue() == nil) + } + } + + @Test func expiredQueuedMessagesAreDropped() { + withSharedDefaults { + Push.Shared.enqueue([message("news", "1")]) + #expect(Push.Shared.dequeue(at: Date().addingTimeInterval(Push.Shared.pendingLifetime + 1)) == nil) + #expect(Push.Shared.dequeue() == nil) + } + } + + @Test func theAppAndTheExtensionSeeEachOthersDeliveries() { + let shared = defaults() + let app = Push.Deliveries(defaults: shared) + let serviceExtension = Push.Deliveries(defaults: shared) + #expect(serviceExtension.record("m8")) + #expect(app.contains("m8")) + #expect(!app.record("m8")) + } } // The delegate proxy needs the Objective-C runtime.