feat(ios): route notification taps to messages

Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-08-17 12:43:49 -07:00
parent b3875f6ade
commit cd112de7b7
14 changed files with 525 additions and 10 deletions
@@ -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
}
}
}
@@ -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 {
@@ -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)
}
@@ -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(
@@ -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)
}
+46 -1
View File
@@ -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,
]
}
}
@@ -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<DeepLinkDispatcher> {
bool _preparingInvite = false;
String? _switchingCommunityId;
@override
void initState() {
@@ -57,6 +60,12 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
ref.listen<AsyncValue<List<Channel>>>(channelsProvider, (_, _) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
ref.listen<AsyncValue<Community?>>(activeCommunityProvider, (_, _) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
ref.listen<AsyncValue<List<Community>>>(communityListProvider, (_, _) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
return widget.child;
@@ -69,6 +78,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
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<DeepLinkDispatcher> {
);
}
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;
+1
View File
@@ -11,6 +11,7 @@ void main() => runBuzzApp(const App());
Future<void> 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();
+9 -2
View File
@@ -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)';
}
@@ -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<BuzzDeepLink?> {
static Stream<Uri>? debugUriStreamOverride;
StreamSubscription<Uri>? _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<BuzzDeepLink?> {
}
/// 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 =
+47
View File
@@ -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<String?>(null);
final pushEndpointGrants = ValueNotifier<List<BuzzPushEndpointGrant>>([]);
final pushEndpointGrantError = ValueNotifier<String?>(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<MessageDeepLink?>(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<void> syncPendingBuzzPushNotificationResponse() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
final arguments = await _channel.invokeMapMethod<dynamic, dynamic>(
'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;
@@ -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;
@@ -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);
});
}
@@ -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<ByteData?>();
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<String, Object> _grantMap(