import 'package:nostr/nostr.dart' as nostr; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../community/community.dart'; import '../deeplink/deep_link.dart'; import '../relay/relay_provider.dart'; import 'push_snapshot.dart'; const _channel = MethodChannel('buzz/push'); /// Latest APNs registration state, including callbacks replayed by iOS after /// the Flutter method channel attaches. final apnsDeviceToken = ValueNotifier(null); final apnsRegistrationError = ValueNotifier(null); final pushAuthorizationGranted = 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. } } Future requestBuzzPushAuthorization() async { if (defaultTargetPlatform != TargetPlatform.iOS) return false; try { final granted = await _channel.invokeMethod('requestAuthorization') ?? false; pushAuthorizationGranted.value = granted; return granted; } on MissingPluginException { pushAuthorizationGranted.value = false; return false; } } class BuzzPushEndpointGrant { final String relayOrigin; final String relayPubkey; final String installationId; final String endpointGrant; final String endpointHash; final String appProfile; final int endpointEpoch; final int generation; final int expiresAt; const BuzzPushEndpointGrant({ required this.relayOrigin, required this.relayPubkey, required this.installationId, required this.endpointGrant, required this.endpointHash, required this.appProfile, required this.endpointEpoch, required this.generation, required this.expiresAt, }); factory BuzzPushEndpointGrant.fromMap(Map map) { final generation = map['generation'] as int; return BuzzPushEndpointGrant( relayOrigin: map['relayOrigin'] as String, relayPubkey: map['relayPubkey'] as String, installationId: map['installationId'] as String, endpointGrant: map['endpointGrant'] as String, endpointHash: map['endpointHash'] as String, appProfile: map['appProfile'] as String, endpointEpoch: map['endpointEpoch'] as int, generation: generation, expiresAt: map['expiresAt'] as int, ); } } Future> readBuzzPushEndpointGrants() async { if (defaultTargetPlatform != TargetPlatform.iOS) return const []; try { final raw = await _channel.invokeListMethod('endpointGrants'); final grants = [ for (final value in raw ?? const []) BuzzPushEndpointGrant.fromMap(value as Map), ]; pushEndpointGrants.value = grants; pushEndpointGrantError.value = null; return grants; } catch (error) { pushEndpointGrantError.value = error.toString(); rethrow; } } Future enrollBuzzPush( String relayUrl, String gatewayUrl, ) async { final raw = await _channel.invokeMapMethod('enrollPush', { 'relayUrl': relayUrl, 'gatewayUrl': gatewayUrl, }); if (raw == null) { throw StateError('Native push enrollment returned no grant.'); } final grant = BuzzPushEndpointGrant.fromMap(raw); await readBuzzPushEndpointGrants(); return grant; } /// Latest failure to export the community snapshot used by the iOS /// notification service extension. Snapshot export is push enrichment and must /// never gate authentication or community persistence. final pushCommunitySnapshotError = ValueNotifier(null); final pushLeaseCleanupError = ValueNotifier(null); void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) { pushCommunitySnapshotError.value = error.toString(); debugPrint('Push community snapshot export failed: $error'); debugPrintStack(stackTrace: stackTrace); } void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) { pushLeaseCleanupError.value = error.toString(); debugPrint( 'Push lease cleanup failed; relay expiry remains the fallback: $error', ); debugPrintStack(stackTrace: stackTrace); } Future registerBuzzPushCommunitySnapshot( List communities, ) async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { final snapshots = [ for (final community in communities) BuzzPushCommunitySnapshot( id: community.id, name: community.name, relayUrl: community.relayUrl, pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), pushSubscriptionState: community.pushSubscriptionState, ), ]; final signingKeys = {}; for (final community in communities) { final nsec = community.nsec; if (nsec == null || nsec.isEmpty) continue; try { final decoded = nostr.Nip19.decode(payload: nsec); if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { continue; } signingKeys[community.id] = decoded.data; } catch (_) { // Native storage is fail-closed; malformed keys are never exported. } } await _channel.invokeMethod('saveCommunitySnapshot', { 'communities': [for (final snapshot in snapshots) snapshot.toJson()], 'signingKeys': signingKeys, }); } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } } void installBuzzPushMethodHandler() { _channel.setMethodCallHandler((call) async { switch (call.method) { case 'apnsTokenChanged': final args = call.arguments; if (args is Map) { final token = args['token']; if (token is String && token.isNotEmpty) { apnsDeviceToken.value = token; apnsRegistrationError.value = null; } } return null; case 'apnsRegistrationFailed': final args = call.arguments; final message = args is Map ? args['message'] : null; apnsRegistrationError.value = message is String && message.isNotEmpty ? message : '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'; default: throw MissingPluginException('Unknown buzz/push method ${call.method}'); } }); }