mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(ios): route notification taps to messages
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user