Files
mobile-ios/RosettaNotificationService/NotificationService.swift

251 lines
10 KiB
Swift

import UserNotifications
import Intents
/// Notification Service Extension runs as a separate process even when the main app
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
/// 1. Adds `.default` sound for vibration (server payload has no sound)
/// 2. Increments the app icon badge from shared App Group storage
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
/// 4. Filters muted chats
/// 5. Creates Communication Notification via INSendMessageIntent (CarPlay + Focus parity)
final class NotificationService: UNNotificationServiceExtension {
private static let appGroupID = "group.com.rosetta.dev"
private static let badgeKey = "app_badge_count"
/// Android parity: multiple key names for sender public key extraction.
/// Server currently sends `from` field in data-only push.
private static let senderKeyNames = [
"from", "sender_public_key", "from_public_key", "fromPublicKey",
"public_key", "publicKey"
]
private static let senderNameKeyNames = [
"sender_name", "from_title", "sender", "title", "name"
]
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
let shared = UserDefaults(suiteName: Self.appGroupID)
let pushType = content.userInfo["type"] as? String ?? ""
// MARK: type=read clear notifications for dialog, deliver silently.
// Server sends read push when user reads a dialog on another device.
if pushType == "read" {
var dialogKey = content.userInfo["dialog"] as? String ?? ""
if dialogKey.hasPrefix("#group:") {
dialogKey = String(dialogKey.dropFirst("#group:".count))
}
// Deliver silently no sound, no alert.
content.sound = nil
content.title = ""
content.body = ""
guard !dialogKey.isEmpty else {
contentHandler(content)
return
}
// Clear notifications for the dialog BEFORE calling contentHandler.
// NSE process can be suspended immediately after contentHandler returns,
// so the async callback must complete first.
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { $0.request.content.userInfo["sender_public_key"] as? String == dialogKey }
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
let current = shared?.integer(forKey: Self.badgeKey) ?? 0
let newBadge = max(current - idsToRemove.count, 0)
shared?.set(newBadge, forKey: Self.badgeKey)
UNUserNotificationCenter.current().setBadgeCount(newBadge)
}
contentHandler(content)
}
return
}
// MARK: type=call incoming call notification (no badge increment).
// Server sends this when someone calls and the recipient's WebSocket is not connected.
// NSE adds sound for vibration and caller name; no badge (calls don't affect unread).
if pushType == "call" {
content.sound = .default
content.categoryIdentifier = "call"
let callerKey = content.userInfo["from"] as? String
?? Self.extractSenderKey(from: content.userInfo)
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
let callerName = contactNames[callerKey]
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
if let callerName, !callerName.isEmpty, content.title.isEmpty {
content.title = callerName
}
if content.body.isEmpty {
content.body = "Incoming call"
}
var updatedInfo = content.userInfo
if !callerKey.isEmpty {
updatedInfo["sender_public_key"] = callerKey
updatedInfo["type"] = "call"
}
content.userInfo = updatedInfo
content.interruptionLevel = .timeSensitive
contentHandler(content)
return
}
// MARK: Message types (personal_message / group_message)
// 1. Add sound for vibration server APNs payload has no sound field.
content.sound = .default
// 2. Extract sender key server sends `from` field.
let senderKey = content.userInfo["from"] as? String
?? Self.extractSenderKey(from: content.userInfo)
// 3. Filter muted chats BEFORE badge increment muted chats must not inflate badge.
if let shared {
let mutedKeys = shared.stringArray(forKey: "muted_chats_keys") ?? []
if !senderKey.isEmpty, mutedKeys.contains(senderKey) {
// Muted: deliver silently (no sound, no alert, no badge increment).
content.sound = nil
content.title = ""
content.body = ""
contentHandler(content)
return
}
// 4. Increment badge count only for non-muted chats.
let current = shared.integer(forKey: Self.badgeKey)
let newBadge = current + 1
shared.set(newBadge, forKey: Self.badgeKey)
content.badge = NSNumber(value: newBadge)
}
// 5. Normalize sender_public_key in userInfo for tap navigation.
var updatedInfo = content.userInfo
if !senderKey.isEmpty {
updatedInfo["sender_public_key"] = senderKey
}
// 6. Resolve sender name from App Group cache (synced by DialogRepository).
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
let resolvedName = contactNames[senderKey]
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
if let resolvedName, !resolvedName.isEmpty {
updatedInfo["sender_name"] = resolvedName
if content.title.isEmpty {
content.title = resolvedName
}
}
content.userInfo = updatedInfo
// 7. Ensure notification category for CarPlay parity.
if content.categoryIdentifier.isEmpty {
content.categoryIdentifier = "message"
}
// 8. Create Communication Notification via INSendMessageIntent.
let senderName = resolvedName
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
?? content.title
let finalContent = Self.makeCommunicationNotification(
content: content,
senderName: senderName,
senderKey: senderKey
)
contentHandler(finalContent)
}
override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent {
content.sound = .default
handler(content)
}
}
// MARK: - Communication Notification (CarPlay + Focus)
/// Wraps the notification content with an INSendMessageIntent so iOS treats it
/// as a Communication Notification. This enables:
/// - Display on CarPlay
/// - Proper grouping in Focus modes
/// - Sender name/avatar in notification UI
private static func makeCommunicationNotification(
content: UNMutableNotificationContent,
senderName: String,
senderKey: String
) -> UNNotificationContent {
let handle = INPersonHandle(value: senderKey, type: .unknown)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: senderName.isEmpty ? "Rosetta" : senderName,
image: nil,
contactIdentifier: nil,
customIdentifier: senderKey
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: senderKey,
serviceName: "Rosetta",
sender: sender,
attachments: nil
)
// Donate the intent so Siri can learn communication patterns.
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
// Update the notification content with the intent.
// This returns a new content object that iOS recognizes as a Communication Notification.
do {
let updatedContent = try content.updating(from: intent)
return updatedContent
} catch {
// If updating fails, return original content notification still works,
// just without CarPlay / Communication Notification features.
return content
}
}
// MARK: - Helpers
/// Android parity: extract sender key from multiple possible key names.
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
firstNonBlank(userInfo, keys: senderKeyNames) ?? ""
}
private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty {
return value
}
}
return nil
}
}