import Foundation import Intents import UserNotifications import rust_lib_twonly private let runtimeAppGroup = "group.eu.twonly.runtime" private struct NativeNotificationResponse: Decodable { let ok: Bool let batch: NativeNotificationBatch? let fallback: NativeNotificationPresentation? let error: String? } private struct NativeNotificationPresentation: Decodable { let title: String let body: String } private struct NativeNotificationBatch: Decodable { let additions: [NativeNotificationAddition] let removals: [String] let badgeCount: Int64 let completed: Bool enum CodingKeys: String, CodingKey { case additions, removals, completed case badgeCount = "badge_count" } } private struct NativeNotificationAddition: Decodable { let eventId: String let notificationId: String let conversationId: String? let senderId: Int64 let senderName: String let title: String let body: String let conversationName: String? let isGroup: Bool let messageId: String? let kind: String let content: String? let createdAt: Int64 let avatarPath: String? enum CodingKeys: String, CodingKey { case kind, content, title, body case eventId = "event_id" case notificationId = "notification_id" case conversationId = "conversation_id" case senderId = "sender_id" case senderName = "sender_name" case conversationName = "conversation_name" case isGroup = "is_group" case messageId = "message_id" case createdAt = "created_at" case avatarPath = "avatar_path" } } final class NotificationService: UNNotificationServiceExtension { private let finishLock = NSLock() private var hasFinished = false private var contentHandler: ((UNNotificationContent) -> Void)? private var fallbackContent: UNNotificationContent? override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { self.contentHandler = contentHandler fallbackContent = request.content guard let runtimeDirectory = Self.runtimeDirectory() else { deliverFallback(reason: "shared runtime directory is unavailable") return } DispatchQueue.global(qos: .userInitiated).async { guard let response = Self.processWakeup(runtimeDirectory: runtimeDirectory) else { self.deliverFallback(reason: "notification worker returned no response") return } guard response.ok else { self.deliverFallback( reason: response.error ?? "notification worker failed", presentation: response.fallback ) return } guard let batch = response.batch, !batch.additions.isEmpty else { self.deliverFallback(reason: "notification worker returned no messages") return } self.render(batch: batch, original: request.content) } } override func serviceExtensionTimeWillExpire() { deliverFallback(reason: "notification service extension timed out") } private func render(batch: NativeNotificationBatch, original: UNNotificationContent) { let center = UNUserNotificationCenter.current() let group = DispatchGroup() let stateLock = NSLock() var deliveredEventIds: [String] = [] var finalContent: UNNotificationContent = original for (index, addition) in batch.additions.enumerated() { group.enter() communicationContent(for: addition, badgeCount: batch.badgeCount, original: original) { content in let isFinal = index == batch.additions.count - 1 if isFinal { stateLock.lock() finalContent = content deliveredEventIds.append(addition.eventId) stateLock.unlock() group.leave() return } let request = UNNotificationRequest( identifier: addition.notificationId, content: content, trigger: nil ) center.add(request) { error in if let error { NSLog("Could not schedule Twonly notification: \(error)") } else { stateLock.lock() deliveredEventIds.append(addition.eventId) stateLock.unlock() } group.leave() } } } group.notify(queue: .global(qos: .userInitiated)) { [weak self] in stateLock.lock() let eventIds = deliveredEventIds let content = finalContent stateLock.unlock() Self.acknowledge(eventIds: eventIds) self?.finish(with: content) } } private func communicationContent( for addition: NativeNotificationAddition, badgeCount: Int64, original: UNNotificationContent, completion: @escaping (UNNotificationContent) -> Void ) { let mutable = (original.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() mutable.title = addition.title mutable.body = addition.body mutable.threadIdentifier = addition.conversationId ?? String(addition.senderId) mutable.badge = NSNumber(value: badgeCount) mutable.sound = .default var userInfo = mutable.userInfo if let conversationId = addition.conversationId { userInfo["conversation_id"] = conversationId } userInfo["notification_id"] = addition.notificationId mutable.userInfo = userInfo let avatar = addition.avatarPath .flatMap { try? Data(contentsOf: URL(fileURLWithPath: $0)) } .map(INImage.init(imageData:)) let sender = INPerson( personHandle: INPersonHandle(value: String(addition.senderId), type: .unknown), nameComponents: nil, displayName: addition.senderName, image: avatar, contactIdentifier: nil, customIdentifier: String(addition.senderId) ) let groupName = addition.isGroup ? addition.conversationName.map(INSpeakableString.init(spokenPhrase:)) : nil let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: addition.body, speakableGroupName: groupName, conversationIdentifier: addition.conversationId ?? String(addition.senderId), serviceName: "Twonly", sender: sender, attachments: nil ) let interaction = INInteraction(intent: intent, response: nil) interaction.direction = .incoming interaction.donate { error in if let error { NSLog("Could not donate Twonly communication intent: \(error)") completion(mutable) return } do { completion(try mutable.updating(from: intent)) } catch { NSLog("Could not create Twonly communication notification: \(error)") completion(mutable) } } } private func finish(with content: UNNotificationContent) { finishLock.lock() guard !hasFinished else { finishLock.unlock() return } hasFinished = true let handler = contentHandler fallbackContent = nil contentHandler = nil finishLock.unlock() handler?(content) } private func deliverFallback( reason: String, presentation: NativeNotificationPresentation? = nil ) { NSLog("Delivering Twonly wake-up fallback notification: \(reason)") guard let presentation else { finish(with: fallbackContent ?? UNMutableNotificationContent()) return } let content = (fallbackContent?.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() content.title = presentation.title content.body = presentation.body finish(with: content) } private static func runtimeDirectory() -> String? { guard let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: runtimeAppGroup ) else { return nil } let directory = container.appendingPathComponent("runtime", isDirectory: true) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try (directory as NSURL).setResourceValue( URLFileProtection.completeUntilFirstUserAuthentication, forKey: .fileProtectionKey ) return directory.path } catch { NSLog("Could not open Twonly runtime directory: \(error)") return nil } } private static func processWakeup(runtimeDirectory: String) -> NativeNotificationResponse? { let locale = Locale.current.identifier let pointer = runtimeDirectory.withCString { databaseDirectory in runtimeDirectory.withCString { dataDirectory in locale.withCString { locale in twonly_notification_process(databaseDirectory, dataDirectory, locale, 24_000) } } } guard let pointer else { return nil } defer { twonly_notification_string_free(pointer) } let json = String(cString: pointer) do { return try JSONDecoder().decode( NativeNotificationResponse.self, from: Data(json.utf8) ) } catch { NSLog("Could not decode Twonly notification worker response: \(error)") return nil } } private static func acknowledge(eventIds: [String]) { guard !eventIds.isEmpty, let data = try? JSONEncoder().encode(eventIds), let json = String(data: data, encoding: .utf8) else { return } let pointer = json.withCString { twonly_notification_acknowledge($0) } guard let pointer else { return } twonly_notification_string_free(pointer) } }