mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
TEMPORARY DEMO-ONLY: add a foreground Remind me notification action that opens the exact message and presents only the five fixed reminder presets. This stretch commit is isolated for quick reversion if it interferes with the demo. Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com> Ai-assisted: true
298 lines
10 KiB
Dart
298 lines
10 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 '../reminders/reminder_service.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 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);
|
|
|
|
class BuzzPushReminderRequest {
|
|
final MessageDeepLink link;
|
|
final ReminderTarget target;
|
|
|
|
const BuzzPushReminderRequest({required this.link, required this.target});
|
|
}
|
|
|
|
final pendingPushReminderRequest = ValueNotifier<BuzzPushReminderRequest?>(
|
|
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,
|
|
);
|
|
}
|
|
|
|
BuzzPushReminderRequest? _pushReminderRequest(Object? arguments) {
|
|
final link = _pushNotificationLink(arguments);
|
|
if (link == null || arguments is! Map) return null;
|
|
final authorPubkey = arguments['authorPubkey'];
|
|
final preview = arguments['preview'];
|
|
if (authorPubkey is! String ||
|
|
!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(authorPubkey) ||
|
|
preview is! String) {
|
|
return null;
|
|
}
|
|
return BuzzPushReminderRequest(
|
|
link: link,
|
|
target: ReminderTarget(
|
|
eventId: link.messageId,
|
|
channelId: link.channelId,
|
|
preview: preview,
|
|
authorPubkey: authorPubkey.toLowerCase(),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _recordPushReminder(Object? arguments) {
|
|
final request = _pushReminderRequest(arguments);
|
|
if (request == null) return;
|
|
pendingPushReminderRequest.value = request;
|
|
pendingPushNotificationLink.value = request.link;
|
|
}
|
|
|
|
/// 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;
|
|
final reminderArguments = await _channel.invokeMapMethod<dynamic, dynamic>(
|
|
'takePendingReminderRequest',
|
|
);
|
|
_recordPushReminder(reminderArguments);
|
|
} on MissingPluginException {
|
|
// Flutter tests and non-Runner embeddings do not install the native bridge.
|
|
}
|
|
}
|
|
|
|
/// Starts the independent iOS notification-authorization and APNs-registration
|
|
/// requests. Display authorization is intentionally not returned or persisted:
|
|
/// APNs registration and enrollment remain valid while display is denied.
|
|
Future<void> startBuzzPushRegistration() async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
|
try {
|
|
await _channel.invokeMethod<void>('startRegistration');
|
|
} on MissingPluginException {
|
|
// Flutter tests and non-Runner embeddings do not install the native bridge.
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Enrolls the endpoint and optionally rewrites the NSE snapshot afterward.
|
|
///
|
|
/// The rewrite propagates NIP-11 `self` rotations even when the opaque grant
|
|
/// and accepted relay lease remain reusable and their generations do not move.
|
|
Future<BuzzPushEndpointGrant> enrollBuzzPush(
|
|
String relayUrl,
|
|
String gatewayUrl, {
|
|
List<Community>? communitiesForSnapshotRefresh,
|
|
}) 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();
|
|
if (communitiesForSnapshotRefresh != null) {
|
|
try {
|
|
await registerBuzzPushCommunitySnapshot(communitiesForSnapshotRefresh);
|
|
pushCommunitySnapshotError.value = null;
|
|
} catch (error, stackTrace) {
|
|
reportPushCommunitySnapshotError(error, stackTrace);
|
|
}
|
|
}
|
|
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),
|
|
subscriptions: community.pushSubscriptionState.authoritative,
|
|
),
|
|
];
|
|
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>('syncPushSnapshot', {
|
|
'section': 'communities',
|
|
'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';
|
|
case 'notificationReminderRequested':
|
|
final request = _pushReminderRequest(call.arguments);
|
|
if (request == null) return 'ignored';
|
|
_recordPushReminder(call.arguments);
|
|
return 'handled';
|
|
default:
|
|
throw MissingPluginException('Unknown buzz/push method ${call.method}');
|
|
}
|
|
});
|
|
}
|