168 lines
6.6 KiB
Swift
168 lines
6.6 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.
|
|
private static let senderKeyNames = [
|
|
"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
|
|
}
|
|
|
|
// 1. Add sound for vibration — server APNs payload has no sound field.
|
|
content.sound = .default
|
|
|
|
// 2. Increment badge count from shared App Group storage.
|
|
if let shared = UserDefaults(suiteName: Self.appGroupID) {
|
|
let current = shared.integer(forKey: Self.badgeKey)
|
|
let newBadge = current + 1
|
|
shared.set(newBadge, forKey: Self.badgeKey)
|
|
content.badge = NSNumber(value: newBadge)
|
|
|
|
// 4. Filter muted chats.
|
|
let senderKey = Self.extractSenderKey(from: content.userInfo)
|
|
let mutedKeys = shared.stringArray(forKey: "muted_chats_keys") ?? []
|
|
if !senderKey.isEmpty, mutedKeys.contains(senderKey) {
|
|
// Muted: deliver silently (no sound, no alert)
|
|
content.sound = nil
|
|
content.title = ""
|
|
content.body = ""
|
|
contentHandler(content)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 3. Normalize sender_public_key in userInfo for tap navigation.
|
|
// Server may send under different key names — normalize to "sender_public_key".
|
|
let senderKey = Self.extractSenderKey(from: content.userInfo)
|
|
if !senderKey.isEmpty {
|
|
var updatedInfo = content.userInfo
|
|
updatedInfo["sender_public_key"] = senderKey
|
|
// Also normalize sender_name
|
|
if let name = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) {
|
|
updatedInfo["sender_name"] = name
|
|
}
|
|
content.userInfo = updatedInfo
|
|
}
|
|
|
|
// 5. Ensure notification category for CarPlay parity.
|
|
if content.categoryIdentifier.isEmpty {
|
|
content.categoryIdentifier = "message"
|
|
}
|
|
|
|
// 6. Create Communication Notification via INSendMessageIntent.
|
|
// This makes the notification appear on CarPlay and work with Focus filters.
|
|
// Apple requires INSendMessageIntent for messaging notifications on CarPlay (iOS 15+).
|
|
let senderName = 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
|
|
}
|
|
}
|