From cd112de7b79d0d36239e90d9fd5da652ed43018c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 17 Aug 2026 12:43:49 -0700 Subject: [PATCH] feat(ios): route notification taps to messages Signed-off-by: Tom Brow --- .../BuzzPushNavigationTarget.swift | 84 +++++++++++++++++++ .../BuzzPushNotificationResolver.swift | 27 ++++-- .../BuzzPushNavigationTargetTests.swift | 52 ++++++++++++ .../BuzzPushNotificationResolverTests.swift | 8 ++ .../NotificationService.swift | 8 ++ mobile/ios/Runner/AppDelegate.swift | 47 ++++++++++- .../channels/deep_link_dispatcher.dart | 63 ++++++++++++++ mobile/lib/main.dart | 1 + mobile/lib/shared/deeplink/deep_link.dart | 11 ++- .../deeplink/pending_deep_link_provider.dart | 20 ++++- mobile/lib/shared/push/push_bridge.dart | 47 +++++++++++ .../channels/deep_link_dispatcher_test.dart | 62 ++++++++++++++ .../pending_deep_link_provider_test.dart | 49 +++++++++++ mobile/test/shared/push/push_bridge_test.dart | 56 +++++++++++++ 14 files changed, 525 insertions(+), 10 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift create mode 100644 mobile/test/shared/deeplink/pending_deep_link_provider_test.dart diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift new file mode 100644 index 000000000..0dcd380db --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -0,0 +1,84 @@ +import Foundation + +/// A stable destination attached by the notification service extension after +/// it resolves and verifies the event that produced a push wake. +public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { + public static let userInfoKey = "buzz_push_navigation" + + public let eventID: String + public let communityID: String + public let channelID: String + + public init(eventID: String, communityID: String, channelID: String) { + self.eventID = eventID.lowercased() + self.communityID = communityID + self.channelID = channelID + } + + public var userInfoValue: [String: String] { + [ + "event_id": eventID, + "community_id": communityID, + "channel_id": channelID, + ] + } + + /// Decodes a target without trusting other fields from the APNs payload. + public static func decodeIfPresent( + from userInfo: [AnyHashable: Any] + ) -> BuzzPushNavigationTarget? { + guard let raw = userInfo[userInfoKey] as? [String: Any], + raw.count == 3, + let eventID = raw["event_id"] as? String, + let communityID = raw["community_id"] as? String, + let channelID = raw["channel_id"] as? String, + !eventID.isEmpty, + !communityID.isEmpty, + !channelID.isEmpty + else { + return nil + } + return BuzzPushNavigationTarget( + eventID: eventID, + communityID: communityID, + channelID: channelID + ) + } +} + +/// Thread-safe one-item buffer spanning notification delivery and Flutter +/// engine startup during a cold notification launch. +public final class BuzzPushNavigationBuffer: @unchecked Sendable { + private let lock = NSLock() + private var target: BuzzPushNavigationTarget? + + public init() {} + + public func record(_ target: BuzzPushNavigationTarget) { + lock.lock() + self.target = target + lock.unlock() + } + + public func peek() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + return target + } + + public func take() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + let current = target + target = nil + return current + } + + public func remove(ifMatching expected: BuzzPushNavigationTarget) { + lock.lock() + defer { lock.unlock() } + if target == expected { + target = nil + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index a80b5988d..e6cff569b 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -10,12 +10,20 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable { public let body: String public let subtitle: String? public let threadIdentifier: String? + public let navigationTarget: BuzzPushNavigationTarget? - public init(title: String, body: String, subtitle: String?, threadIdentifier: String?) { + public init( + title: String, + body: String, + subtitle: String?, + threadIdentifier: String?, + navigationTarget: BuzzPushNavigationTarget? = nil + ) { self.title = title self.body = body self.subtitle = subtitle self.threadIdentifier = threadIdentifier + self.navigationTarget = navigationTarget } } @@ -130,10 +138,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let body = previewBody(event.content) guard !body.isEmpty else { return nil } let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] - return (BuzzPushResolution( - title: shortPubkey(event.pubkey), body: body, subtitle: community.name, - threadIdentifier: channel ?? community.id - ), event) + return ( + BuzzPushResolution( + title: shortPubkey(event.pubkey), body: body, subtitle: community.name, + threadIdentifier: channel ?? community.id, + navigationTarget: channel.map { + BuzzPushNavigationTarget( + eventID: event.id, + communityID: community.id, + channelID: $0 + ) + } + ), event + ) } static func previewBody(_ content: String) -> String { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift new file mode 100644 index 000000000..9b743d594 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +@Test func `Round-trip navigation target through notification user info`() { + let target = BuzzPushNavigationTarget( + eventID: "ABC123", + communityID: "community-id", + channelID: "channel-id" + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target + ) + #expect(target.eventID == "abc123") +} + +@Test func `Reject incomplete navigation target`() { + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "event-id", + "community_id": "community-id", + ] + ] + ) == nil + ) +} + +@Test func `Buffer preserves cold-start target until consumed`() { + let first = BuzzPushNavigationTarget( + eventID: "first", + communityID: "community-id", + channelID: "channel-id" + ) + let second = BuzzPushNavigationTarget( + eventID: "second", + communityID: "community-id", + channelID: "channel-id" + ) + let buffer = BuzzPushNavigationBuffer() + + buffer.record(first) + buffer.remove(ifMatching: second) + #expect(buffer.peek() == first) + #expect(buffer.take() == first) + #expect(buffer.take() == nil) +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 8e5bdecb8..d788f83e6 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -141,6 +141,14 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.body, "Hello Buzz") XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual(result.threadIdentifier, "channel-id") + XCTAssertEqual( + result.navigationTarget, + BuzzPushNavigationTarget( + eventID: event.id, + communityID: "community-id", + channelID: "channel-id" + ) + ) } private func makeResolver( diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index b77dc9044..649638d7f 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -38,6 +38,9 @@ final class NotificationService: UNNotificationServiceExtension { return } bestAttemptContent = content + var cleanUserInfo = content.userInfo + cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey) + content.userInfo = cleanUserInfo resolver.resolve { [weak self] resolution in guard let self else { return } @@ -50,6 +53,11 @@ final class NotificationService: UNNotificationServiceExtension { if let threadIdentifier = resolution.threadIdentifier { content.threadIdentifier = threadIdentifier } + if let navigationTarget = resolution.navigationTarget { + var userInfo = content.userInfo + userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue + content.userInfo = userInfo + } } self.finish(content) } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 53e001d18..351916d08 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -9,6 +9,7 @@ import UserNotifications private var mediaUploadChannel: FlutterMethodChannel? private var pushChannel: FlutterMethodChannel? private let apnsRegistrationBuffer = APNsRegistrationBuffer() + private let pushNavigationBuffer = BuzzPushNavigationBuffer() private var apnsDeviceToken: Data? private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore( accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String @@ -27,7 +28,9 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.delegate = self + notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in if granted { DispatchQueue.main.async { @@ -169,11 +172,43 @@ import UserNotifications apnsRegistrationBuffer.recordError(error.localizedDescription) } + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier, + let target = BuzzPushNavigationTarget.decodeIfPresent( + from: response.notification.request.content.userInfo + ) + { + pushNavigationBuffer.record(target) + deliverPushNavigationTarget(target) + } + super.userNotificationCenter( + center, + didReceive: response, + withCompletionHandler: completionHandler + ) + } + + private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) { + pushChannel?.invokeMethod( + "notificationOpened", + arguments: target.flutterArguments + ) { [weak self] result in + guard result as? String == "handled" else { return } + self?.pushNavigationBuffer.remove(ifMatching: target) + } + } + private func handlePushMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult ) { switch call.method { + case "takePendingNotificationResponse": + result(pushNavigationBuffer.take()?.flutterArguments) case "saveCommunitySnapshot": guard let arguments = call.arguments as? [String: Any], let communities = arguments["communities"] as? [[String: Any]], @@ -551,3 +586,13 @@ import UserNotifications } } } + +extension BuzzPushNavigationTarget { + fileprivate var flutterArguments: [String: String] { + [ + "eventId": eventID, + "communityId": communityID, + "channelId": channelID, + ] + } +} diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index b264b31b6..43eb40e8d 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; +import '../../shared/community/community.dart'; +import '../../shared/community/community_provider.dart'; import '../invites/invite_join_provider.dart'; import '../invites/invite_join_sheet.dart'; import 'channel.dart'; @@ -37,6 +39,7 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { class _DeepLinkDispatcherState extends ConsumerState { bool _preparingInvite = false; + String? _switchingCommunityId; @override void initState() { @@ -57,6 +60,12 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.listen>>(channelsProvider, (_, _) { _maybeDispatch(ref.read(pendingDeepLinkProvider)); }); + ref.listen>(activeCommunityProvider, (_, _) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + }); + ref.listen>>(communityListProvider, (_, _) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + }); } return widget.child; @@ -69,6 +78,7 @@ class _DeepLinkDispatcherState extends ConsumerState { return; } if (link is! MessageDeepLink || !widget.dispatchMessageLinks) return; + if (!_prepareNotificationCommunity(link)) return; final channels = ref.read(channelsProvider).asData?.value; // Channels not loaded yet — keep the link parked; the channelsProvider @@ -106,6 +116,59 @@ class _DeepLinkDispatcherState extends ConsumerState { ); } + bool _prepareNotificationCommunity(MessageDeepLink link) { + final communityId = link.communityId; + if (communityId == null) return true; + + final communities = ref.read(communityListProvider).asData?.value; + if (communities == null) return false; + if (!communities.any((community) => community.id == communityId)) { + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + return false; + } + + final activeCommunity = ref.read(activeCommunityProvider).asData?.value; + if (activeCommunity?.id == communityId) return true; + _switchCommunity(communityId); + return false; + } + + void _switchCommunity(String communityId) { + if (_switchingCommunityId != null) return; + _switchingCommunityId = communityId; + Future.microtask(() async { + var switched = false; + try { + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + switched = true; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } finally { + _switchingCommunityId = null; + if (mounted && switched) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + } + } + }); + } + void _maybeDispatchInvite(InviteDeepLink link) { if (_preparingInvite) return; _preparingInvite = true; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 53bb5c0d6..ce70cbf26 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -11,6 +11,7 @@ void main() => runBuzzApp(const App()); Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); installBuzzPushMethodHandler(); + await syncPendingBuzzPushNotificationResponse(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index 0ef7b8e59..bf74edf5c 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -52,6 +52,10 @@ class InviteDeepLink extends BuzzDeepLink { /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { + /// Local community identifier for notification-originated links. + /// Canonical shared links omit this because community IDs are device-local. + final String? communityId; + /// Channel UUID from the `channel` query param. final String channelId; @@ -62,6 +66,7 @@ class MessageDeepLink extends BuzzDeepLink { final String? threadRootId; const MessageDeepLink({ + this.communityId, required this.channelId, required this.messageId, this.threadRootId, @@ -70,16 +75,18 @@ class MessageDeepLink extends BuzzDeepLink { @override bool operator ==(Object other) => other is MessageDeepLink && + other.communityId == communityId && other.channelId == channelId && other.messageId == messageId && other.threadRootId == threadRootId; @override - int get hashCode => Object.hash(channelId, messageId, threadRootId); + int get hashCode => + Object.hash(communityId, channelId, messageId, threadRootId); @override String toString() => - 'MessageDeepLink(channel: $channelId, id: $messageId, ' + 'MessageDeepLink(community: $communityId, channel: $channelId, id: $messageId, ' 'thread: $threadRootId)'; } diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 8dc46d9f1..3fb622184 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../push/push_bridge.dart'; /// Holds the most recent supported deep link that has not been /// dispatched yet. @@ -19,16 +20,26 @@ class PendingDeepLinkNotifier extends Notifier { static Stream? debugUriStreamOverride; StreamSubscription? _subscription; + VoidCallback? _pushNotificationListener; @override BuzzDeepLink? build() { final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; _subscription = stream.listen(handleUri); + _pushNotificationListener = () { + final link = pendingPushNotificationLink.value; + if (link != null) state = link; + }; + pendingPushNotificationLink.addListener(_pushNotificationListener!); ref.onDispose(() { _subscription?.cancel(); _subscription = null; + if (_pushNotificationListener case final listener?) { + pendingPushNotificationLink.removeListener(listener); + } + _pushNotificationListener = null; }); - return null; + return pendingPushNotificationLink.value; } /// Parse and park an incoming URI. Unsupported links are ignored loudly. @@ -43,7 +54,12 @@ class PendingDeepLinkNotifier extends Notifier { } /// Clear the pending link after it has been dispatched (or dropped). - void consume() => state = null; + void consume() { + if (pendingPushNotificationLink.value == state) { + pendingPushNotificationLink.value = null; + } + state = null; + } } final pendingDeepLinkProvider = diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 2cdd5015b..7c6237407 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../community/community.dart'; +import '../deeplink/deep_link.dart'; import '../relay/nostr_models.dart'; import '../relay/relay_provider.dart'; import 'push_models.dart'; @@ -17,6 +18,47 @@ final apnsRegistrationError = ValueNotifier(null); final pushEndpointGrants = ValueNotifier>([]); final pushEndpointGrantError = ValueNotifier(null); +/// The most recent notification response waiting for app navigation. +/// +/// Native iOS buffers cold-start responses until Dart asks for them. This +/// notifier also carries warm responses into the existing deep-link pipeline. +final pendingPushNotificationLink = ValueNotifier(null); + +MessageDeepLink? _pushNotificationLink(Object? arguments) { + if (arguments is! Map) return null; + final eventId = arguments['eventId']; + final communityId = arguments['communityId']; + final channelId = arguments['channelId']; + if (eventId is! String || + eventId.isEmpty || + communityId is! String || + communityId.isEmpty || + channelId is! String || + channelId.isEmpty) { + return null; + } + return MessageDeepLink( + communityId: communityId, + channelId: channelId, + messageId: eventId, + ); +} + +/// Pulls a notification response that arrived before the Flutter method +/// handler was installed. +Future syncPendingBuzzPushNotificationResponse() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final arguments = await _channel.invokeMapMethod( + 'takePendingNotificationResponse', + ); + final link = _pushNotificationLink(arguments); + if (link != null) pendingPushNotificationLink.value = link; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + class BuzzPushEndpointGrant { final String relayOrigin; final String relayPubkey; @@ -231,6 +273,11 @@ void installBuzzPushMethodHandler() { : 'APNs registration failed'; debugPrint('APNs registration failed: ${apnsRegistrationError.value}'); return null; + case 'notificationOpened': + final link = _pushNotificationLink(call.arguments); + if (link == null) return 'ignored'; + pendingPushNotificationLink.value = link; + return 'handled'; case 'resolveNotification': final args = call.arguments; if (args is! Map) return null; diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 0771a7bb3..cc2001087 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -51,6 +51,54 @@ void main() { expect(destination.link.threadRootId, 'message-1'); }); + testWidgets('switches to the notification community before dispatch', ( + tester, + ) async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.save(_firstCommunity); + await storage.save(_notificationCommunity); + await storage.saveActiveId(_firstCommunity.id); + const link = MessageDeepLink( + communityId: 'community-2', + channelId: 'channel-1', + messageId: 'message-2', + ); + + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(await storage.loadActiveId(), _notificationCommunity.id); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, link); + }); + testWidgets('retains invite and surfaces prepare failure', (tester) async { const link = InviteDeepLink( relayUrl: 'wss://relay.example.com', @@ -204,6 +252,20 @@ final _channel = Channel( isMember: true, ); +final _firstCommunity = Community( + id: 'community-1', + name: 'First', + relayUrl: 'wss://first.example', + addedAt: DateTime(2026), +); + +final _notificationCommunity = Community( + id: 'community-2', + name: 'Notification', + relayUrl: 'wss://notification.example', + addedAt: DateTime(2026), +); + class _CountingCommunityStorage extends CommunityStorage { int loadCalls = 0; diff --git a/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart new file mode 100644 index 000000000..b13f42df6 --- /dev/null +++ b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + setUp(() { + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + pendingPushNotificationLink.value = null; + }); + + tearDown(() { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + pendingPushNotificationLink.value = null; + }); + + test('parks and consumes a native notification message link', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(pendingDeepLinkProvider), isNull); + + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + await pumpEventQueue(); + + expect(container.read(pendingDeepLinkProvider), link); + container.read(pendingDeepLinkProvider.notifier).consume(); + expect(container.read(pendingDeepLinkProvider), isNull); + expect(pendingPushNotificationLink.value, isNull); + }); + + test('preserves a cold-start target present before provider build', () { + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(pendingDeepLinkProvider), link); + }); +} diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 360a1cb3c..0208ce60a 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/relay/relay_provider.dart'; import 'package:flutter/foundation.dart'; @@ -14,6 +17,7 @@ void main() { apnsRegistrationError.value = null; pushEndpointGrants.value = const []; pushEndpointGrantError.value = null; + pendingPushNotificationLink.value = null; installBuzzPushMethodHandler(); }); @@ -170,6 +174,58 @@ void main() { ); expect(apnsRegistrationError.value, 'denied'); }); + + test('turns a warm notification response into a message link', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'eventId': 'event-id', + 'communityId': 'community-id', + 'channelId': 'channel-id', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'handled'); + expect( + pendingPushNotificationLink.value, + const MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ), + ); + }); + + test('pulls a cold-start notification response from native iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'takePendingNotificationResponse'); + return { + 'eventId': 'cold-event', + 'communityId': 'community-id', + 'channelId': 'channel-id', + }; + }); + + await syncPendingBuzzPushNotificationResponse(); + + expect( + pendingPushNotificationLink.value, + const MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'cold-event', + ), + ); + }); } Map _grantMap(