twonly-app/ios/NotificationService/NotificationService.swift
otsmr 6f597a08a1
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
improve video, remove deps, fix notifications
2026-08-31 23:00:09 +02:00

298 lines
9.3 KiB
Swift

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_kind"] = addition.kind
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)
}
}