Files
buzz/mobile/lib/shared/push/push_bridge.dart
2026-08-20 13:48:18 -07:00

246 lines
8.3 KiB
Dart

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<String?>(null);
final apnsRegistrationError = ValueNotifier<String?>(null);
final pushAuthorizationGranted = ValueNotifier<bool?>(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);
final _pushEventIdPattern = RegExp(r'^[0-9a-f]{64}$');
final _pushChannelIdPattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
MessageDeepLink? _pushNotificationLink(Object? arguments) {
if (arguments is! Map) return null;
final eventId = arguments['eventId'];
final communityId = arguments['communityId'];
final channelId = arguments['channelId'];
final normalizedEventId = eventId is String ? eventId.toLowerCase() : '';
final normalizedChannelId = channelId is String
? channelId.toLowerCase()
: '';
if (eventId is! String ||
!_pushEventIdPattern.hasMatch(normalizedEventId) ||
communityId is! String ||
communityId.isEmpty ||
channelId is! String ||
!_pushChannelIdPattern.hasMatch(normalizedChannelId)) {
return null;
}
return MessageDeepLink(
communityId: communityId,
channelId: normalizedChannelId,
messageId: normalizedEventId,
);
}
/// 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.
}
}
Future<bool> requestBuzzPushAuthorization() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
try {
final granted =
await _channel.invokeMethod<bool>('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<dynamic, dynamic> 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<List<BuzzPushEndpointGrant>> readBuzzPushEndpointGrants() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return const [];
try {
final raw = await _channel.invokeListMethod<dynamic>('endpointGrants');
final grants = [
for (final value in raw ?? const [])
BuzzPushEndpointGrant.fromMap(value as Map<dynamic, dynamic>),
];
pushEndpointGrants.value = grants;
pushEndpointGrantError.value = null;
return grants;
} catch (error) {
pushEndpointGrantError.value = error.toString();
rethrow;
}
}
Future<BuzzPushEndpointGrant> enrollBuzzPush(
String relayUrl,
String gatewayUrl,
) async {
final raw = await _channel.invokeMapMethod<dynamic, dynamic>('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<String?>(null);
final pushLeaseCleanupError = ValueNotifier<String?>(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<void> registerBuzzPushCommunitySnapshot(
List<Community> 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 = <String, String>{};
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<void>('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}');
}
});
}