Files
JeffGandGitHub d57a883e53 Implement iOS NSE notifications and Android push token plumbing (#673)
* Add remote push notification plumbing

* Checkpoint notification reliability cleanup

* Prepare notification reliability PR

* Fix notification PR review findings

* Address notification review follow-ups

* Clean up failed iOS data migration copies

* Address app resume and migration cleanup review

* Restore coverage for notification review fixes

* Fix App Group migration guard

* Address notification PR review feedback

* Cover notification review edge cases

* Address notification test review feedback

* Address NSE migration and startup retry review

* Use foreground catch-up for notification refresh

* Update whitenoise-rs foreground catch-up dependency

* Cover notification catch-up error paths

* Fix notification review edge cases
2026-05-20 14:47:09 +02:00

365 lines
11 KiB
Swift

import Foundation
import UserNotifications
private typealias WnJsonCallback = @convention(c) (
UnsafePointer<UInt8>?,
Int,
UnsafeMutableRawPointer?
) -> Void
@_silgen_name("wn_collect_notifications_after_push")
private func wn_collect_notifications_after_push(
_ dataDirPtr: UnsafePointer<UInt8>?,
_ dataDirLen: Int,
_ logsDirPtr: UnsafePointer<UInt8>?,
_ logsDirLen: Int,
_ keyringServiceIdPtr: UnsafePointer<UInt8>?,
_ keyringServiceIdLen: Int,
_ maxWaitMs: UInt32,
_ callback: WnJsonCallback,
_ userData: UnsafeMutableRawPointer?
)
@_silgen_name("wn_install_ios_background_keyring_store_with_access_group")
private func wn_install_ios_background_keyring_store_with_access_group(
_ accessGroupPtr: UnsafePointer<UInt8>?,
_ accessGroupLen: Int
) -> Bool
private final class BackgroundPushResultBox {
var json: String?
}
private final class NotificationDelivery {
private let lock = NSLock()
private var contentHandler: ((UNNotificationContent) -> Void)?
init(_ contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
}
func deliver(_ content: UNNotificationContent) {
lock.lock()
guard let contentHandler else {
lock.unlock()
return
}
self.contentHandler = nil
lock.unlock()
contentHandler(content)
}
}
private func backgroundPushJsonCallback(
jsonPtr: UnsafePointer<UInt8>?,
jsonLen: Int,
userData: UnsafeMutableRawPointer?
) {
guard let userData else { return }
let box = Unmanaged<BackgroundPushResultBox>.fromOpaque(userData).takeUnretainedValue()
guard let jsonPtr else {
box.json = ""
return
}
box.json = String(data: Data(bytes: jsonPtr, count: jsonLen), encoding: .utf8)
}
private func localized(_ key: String, fallback: String) -> String {
NSLocalizedString(key, tableName: nil, bundle: .main, value: fallback, comment: "")
}
private func localizedFormat(_ key: String, fallback: String, _ arguments: CVarArg...) -> String {
String(format: localized(key, fallback: fallback), locale: Locale.current, arguments: arguments)
}
final class NotificationService: UNNotificationServiceExtension {
private static let collectionQueue = DispatchQueue(
label: "org.parres.whitenoise.notification-service.collection",
qos: .userInitiated
)
private let dataVersionFileName = "data_version"
private let currentDataVersion = "1"
private let keyringServiceId = "com.whitenoise.app"
// Keep fallback delivery quick; slow relay collection should not hold the system UI for 30 seconds.
private let maxNotificationServiceWaitMs: UInt32 = 8_000
private var bestAttemptDelivery: NotificationDelivery?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
let delivery = NotificationDelivery(contentHandler)
bestAttemptDelivery = delivery
bestAttemptContent = nil
NSLog("White Noise NSE received mutable notification")
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
NSLog("White Noise NSE failed to copy notification content")
delivery.deliver(request.content)
return
}
bestAttemptContent = content
Self.collectionQueue.async {
let shouldDisplayNotification = self.collectAndApplyNotificationText(content)
delivery.deliver(shouldDisplayNotification ? content : UNMutableNotificationContent())
}
}
override func serviceExtensionTimeWillExpire() {
guard let bestAttemptDelivery else {
return
}
NSLog("White Noise NSE timed out before rendering notification")
let content = bestAttemptContent ?? UNMutableNotificationContent()
applyFallbackNotificationText(content)
bestAttemptDelivery.deliver(content)
}
private func collectAndApplyNotificationText(_ content: UNMutableNotificationContent) -> Bool {
let collectionResult = collectNotificationsAfterPush()
let result: BackgroundPushResult
switch collectionResult {
case .success(let collectedResult):
result = collectedResult
case .failure(let reason):
NSLog("White Noise NSE collection failed: %@", reason)
applyFallbackNotificationText(content)
return true
}
NSLog(
"White Noise NSE collection status=%@ count=%d error=%@",
result.status,
result.notifications.count,
result.error ?? ""
)
guard result.status == "new_data" else {
return false
}
guard let notification = result.notifications.last else {
return false
}
content.title = notification.title
content.subtitle = ""
content.body = notification.body
content.userInfo = content.userInfo.merging(notification.tapUserInfo) { _, new in new }
return true
}
private func applyFallbackNotificationText(_ content: UNMutableNotificationContent) {
if content.title.isEmpty {
content.title = localized("notification_title", fallback: "White Noise")
}
content.subtitle = ""
content.body = localized("notification_new_encrypted_message", fallback: "New encrypted message")
}
private func collectNotificationsAfterPush() -> BackgroundPushCollectionResult {
let installed = withUtf8Buffer(keychainAccessGroup() ?? "") { accessGroupPtr, accessGroupLen in
wn_install_ios_background_keyring_store_with_access_group(accessGroupPtr, accessGroupLen)
}
guard installed else {
NSLog("White Noise NSE failed to install iOS background keyring store")
return .failure("keyring store")
}
guard let container = appGroupContainerURL() else {
NSLog("White Noise NSE App Group container unavailable")
return .failure("app group")
}
let baseDir = container.appendingPathComponent("whitenoise", isDirectory: true)
let dataDirURL = baseDir.appendingPathComponent("data", isDirectory: true)
let logsDirURL = baseDir.appendingPathComponent("logs", isDirectory: true)
let dataDir = dataDirURL.path
let logsDir = logsDirURL.path
try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: logsDirURL, withIntermediateDirectories: true)
guard let json = collectNotificationsJson(
dataDir: dataDir,
logsDir: logsDir,
keyringServiceId: keyringServiceId
) else {
return .failure("empty result")
}
do {
let result = try JSONDecoder().decode(BackgroundPushResult.self, from: Data(json.utf8))
if result.status == "new_data" {
writeDataVersionMarker(in: dataDirURL)
}
return .success(result)
} catch {
NSLog("White Noise NSE notification JSON decode failed: %@", error.localizedDescription)
return .failure("decode \(error.localizedDescription)")
}
}
private func writeDataVersionMarker(in dataDir: URL) {
let markerURL = dataDir.appendingPathComponent(dataVersionFileName, isDirectory: false)
guard !FileManager.default.fileExists(atPath: markerURL.path) else {
return
}
do {
try currentDataVersion.write(to: markerURL, atomically: true, encoding: .utf8)
} catch {
NSLog("White Noise NSE failed to write data version marker: %@", error.localizedDescription)
}
}
private func appGroupContainerURL() -> URL? {
guard
let identifier = Bundle.main.object(forInfoDictionaryKey: "WNAppGroupIdentifier") as? String,
!identifier.isEmpty
else {
return nil
}
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
}
private func collectNotificationsJson(
dataDir: String,
logsDir: String,
keyringServiceId: String
) -> String? {
let box = BackgroundPushResultBox()
let opaque = Unmanaged.passRetained(box).toOpaque()
defer {
_ = Unmanaged<BackgroundPushResultBox>.fromOpaque(opaque).takeRetainedValue()
}
withUtf8Buffer(dataDir) { dataPtr, dataLen in
withUtf8Buffer(logsDir) { logsPtr, logsLen in
withUtf8Buffer(keyringServiceId) { keyringPtr, keyringLen in
wn_collect_notifications_after_push(
dataPtr,
dataLen,
logsPtr,
logsLen,
keyringPtr,
keyringLen,
maxNotificationServiceWaitMs,
backgroundPushJsonCallback,
opaque
)
}
}
}
return box.json
}
private func keychainAccessGroup() -> String? {
Bundle.main.object(forInfoDictionaryKey: "WNKeychainAccessGroup") as? String
}
private func withUtf8Buffer<T>(
_ string: String,
_ body: (UnsafePointer<UInt8>?, Int) -> T
) -> T {
let bytes = Array(string.utf8)
return bytes.withUnsafeBufferPointer { buffer in
body(buffer.baseAddress, buffer.count)
}
}
}
private enum BackgroundPushCollectionResult {
case success(BackgroundPushResult)
case failure(String)
}
private struct BackgroundPushResult: Decodable {
let status: String
let notifications: [BackgroundNotification]
let error: String?
}
private struct BackgroundNotification: Decodable {
let trigger: String
let mlsGroupId: String
let groupName: String?
let isDm: Bool
let receiver: BackgroundNotificationUser
let sender: BackgroundNotificationUser
let content: String
var title: String {
if trigger == "invite" {
if isDm {
return sender.displayName
?? localized("notification_secure_chat_invite_title", fallback: "Secure chat invite")
}
return groupName
?? localized("notification_secure_group_invite_title", fallback: "Secure group invite")
}
if isDm {
return sender.displayName ?? "White Noise"
}
return groupName ?? "White Noise"
}
var body: String {
let senderName = sender.displayName ?? localized("notification_unknown_sender", fallback: "Someone")
if trigger == "invite" {
if isDm {
return localizedFormat(
"notification_dm_invite_body",
fallback: "%@ invited you to a secure chat",
senderName
)
}
return localizedFormat(
"notification_group_invite_body",
fallback: "%@ invited you to %@",
senderName,
groupName ?? localized("notification_secure_group", fallback: "a secure group")
)
}
if isDm {
return content
}
return localizedFormat(
"notification_group_message_body",
fallback: "%@: %@",
senderName,
content
)
}
var tapUserInfo: [AnyHashable: Any] {
[
"groupId": mlsGroupId,
"trigger": trigger,
"receiverPubkey": receiver.pubkey,
"mlsGroupId": mlsGroupId,
"isInvite": trigger == "invite",
]
}
enum CodingKeys: String, CodingKey {
case trigger
case mlsGroupId = "mls_group_id"
case groupName = "group_name"
case isDm = "is_dm"
case receiver
case sender
case content
}
}
private struct BackgroundNotificationUser: Decodable {
let pubkey: String
let displayName: String?
enum CodingKeys: String, CodingKey {
case pubkey
case displayName = "display_name"
}
}