From 16801e881b3ff279af04bee5b7d4b852b07c2b44 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 11:35:30 -0700 Subject: [PATCH] fix(ios): supply missing push notification sound at handoff Signed-off-by: Tom Brow --- .../BuzzPushKit/BuzzNotificationSound.swift | 15 +++ .../BuzzNotificationSoundTests.swift | 110 ++++++++++++++++++ .../NotificationService.swift | 5 +- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSound.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSoundTests.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSound.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSound.swift new file mode 100644 index 0000000000..a326965e29 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSound.swift @@ -0,0 +1,15 @@ +import UserNotifications + +/// Applies the ordinary alert sound policy at the final service-extension handoff. +public enum BuzzNotificationSound { + /// Preserves the incoming APNs sound, or adds the default when no sound exists. + public static func preparing( + _ content: UNNotificationContent, + originalSound: UNNotificationSound? + ) -> UNNotificationContent { + // UNNotificationContent's NSMutableCopying contract returns its mutable counterpart. + let prepared = content.mutableCopy() as! UNMutableNotificationContent + prepared.sound = originalSound ?? content.sound ?? .default + return prepared + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSoundTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSoundTests.swift new file mode 100644 index 0000000000..8551208840 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSoundTests.swift @@ -0,0 +1,110 @@ +import Testing +import UserNotifications + +@testable import BuzzPushKit + +struct BuzzNotificationSoundTests { + @Test func `Missing sound receives default without changing presentation`() { + let content = UNMutableNotificationContent() + content.title = "Sender" + content.body = "Message" + content.userInfo = ["navigation": "target"] + let result = BuzzNotificationSound.preparing(content, originalSound: nil) + #expect(result.sound != nil) + #expect(content.sound == nil) + #expect(result.title == content.title) + #expect(result.body == content.body) + #expect(result.userInfo["navigation"] as? String == "target") + } + + @Test func `Gateway sound survives enrichment that omits or replaces it`() { + let gatewaySound = UNNotificationSound(named: UNNotificationSoundName("gateway.caf")) + for enrichedSound in [nil, UNNotificationSound.default] { + let enriched = UNMutableNotificationContent() + enriched.sound = enrichedSound + let result = BuzzNotificationSound.preparing(enriched, originalSound: gatewaySound) + #expect(result.sound?.isEqual(gatewaySound) == true) + } + } + + @Test func `Existing enriched sound survives when gateway supplied none`() { + let enriched = UNMutableNotificationContent() + let custom = UNNotificationSound(named: UNNotificationSoundName("enriched.caf")) + enriched.sound = custom + let result = BuzzNotificationSound.preparing(enriched, originalSound: nil) + #expect(result.sound?.isEqual(custom) == true) + } + + @Test(arguments: [true, false]) + func `Final handoff applies sound to resolved and restricted content once`(allowed: Bool) { + var delivered: [UNNotificationContent] = [] + let original = UNMutableNotificationContent() + let handoff = BuzzNotificationHandoff { content in + delivered.append(BuzzNotificationSound.preparing(content, originalSound: original.sound)) + } + let resolved = UNMutableNotificationContent() + resolved.title = "Sender" + let fallback = UNMutableNotificationContent() + fallback.title = "Buzz" + for _ in 0..<2 { + handoff.finish( + resolved, + restrictedFallback: fallback, + handoffIfAllowed: { deliver in + if allowed { deliver() } + return allowed + }, + cleanup: { #expect(delivered.first?.sound != nil) } + ) + } + #expect(delivered.count == 1) + #expect(delivered.first?.title == (allowed ? "Sender" : "Buzz")) + #expect(delivered.first?.sound != nil) + } + + @Test func `Expiration receives sound before a late resolution is ignored`() { + var delivered: [UNNotificationContent] = [] + let handoff = BuzzNotificationHandoff { content in + delivered.append(BuzzNotificationSound.preparing(content, originalSound: nil)) + } + for body in ["Reconnect to your relay now", "Resolved message"] { + let content = UNMutableNotificationContent() + content.body = body + handoff.finish( + content, restrictedFallback: content, + handoffIfAllowed: { deliver in deliver(); return true }, cleanup: {} + ) + } + #expect(delivered.count == 1) + #expect(delivered.first?.body == "Reconnect to your relay now") + #expect(delivered.first?.sound != nil) + } +} + +#if os(iOS) + import Intents + + extension BuzzNotificationSoundTests { + @Test func `Real communication intent update retains the original sound at handoff`() throws { + let descriptor = BuzzCommunicationNotificationDescriptor( + senderDisplayName: "Alice", senderIdentifier: "sender", + senderAvatarPNG: nil, messageBody: "Hello", conversationIdentifier: "conversation", + conversationDisplayName: "General", recipientCount: 1 + ) + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + for incomingSound in [nil, UNNotificationSound(named: UNNotificationSoundName("gateway.caf"))] { + let original = UNMutableNotificationContent() + original.sound = incomingSound + let specialized = try original.updating(from: intent) + let result = BuzzNotificationSound.preparing(specialized, originalSound: original.sound) + if let incomingSound { + #expect(result.sound?.isEqual(incomingSound) == true) + } else { + #expect(result.sound != nil) + } + #expect(result.title == specialized.title) + #expect(result.body == specialized.body) + } + } + } +#endif diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 0fffa19540..5e83469fee 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -100,7 +100,10 @@ final class NotificationService: UNNotificationServiceExtension { withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { resolvedScope = nil - handoff = BuzzNotificationHandoff(handler: contentHandler) + let originalSound = request.content.sound + handoff = BuzzNotificationHandoff { content in + contentHandler(BuzzNotificationSound.preparing(content, originalSound: originalSound)) + } restrictedFallbackContent = Self.restrictedFallback(from: request.content) guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { finish(request.content)