mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Age-check errors previously blocked Buzz startup. Normal app startup now proceeds immediately; only a valid under-18 response from the current launch request can restrict access. Age check enforcement remains disabled at compile time by default. Builds can opt in with `--dart-define=BUZZ_AGE_GATING_ENABLED=true`. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
368 lines
12 KiB
Dart
368 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../community/community.dart';
|
|
import '../deeplink/deep_link.dart';
|
|
import '../relay/relay_provider.dart';
|
|
import '../relay/app_lifecycle_provider.dart';
|
|
import 'push_snapshot.dart';
|
|
|
|
const _channel = MethodChannel('buzz/push');
|
|
|
|
enum BuzzPushAuthorizationStatus {
|
|
notDetermined,
|
|
denied,
|
|
authorized,
|
|
provisional,
|
|
ephemeral,
|
|
}
|
|
|
|
typedef BuzzPushAuthorizationStatusReader =
|
|
Future<BuzzPushAuthorizationStatus> Function();
|
|
typedef BuzzPushNotificationSettingsOpener = Future<bool> Function();
|
|
|
|
final buzzPushAuthorizationStatusReaderProvider =
|
|
Provider<BuzzPushAuthorizationStatusReader>((ref) {
|
|
return readBuzzPushAuthorizationStatus;
|
|
});
|
|
|
|
final buzzPushNotificationSettingsOpenerProvider =
|
|
Provider<BuzzPushNotificationSettingsOpener>((ref) {
|
|
return openBuzzPushNotificationSettings;
|
|
});
|
|
|
|
final buzzPushAuthorizationStatusProvider =
|
|
AsyncNotifierProvider<
|
|
BuzzPushAuthorizationStatusNotifier,
|
|
BuzzPushAuthorizationStatus
|
|
>(BuzzPushAuthorizationStatusNotifier.new);
|
|
|
|
class BuzzPushAuthorizationStatusNotifier
|
|
extends AsyncNotifier<BuzzPushAuthorizationStatus> {
|
|
@override
|
|
Future<BuzzPushAuthorizationStatus> build() async {
|
|
ref.listen(appLifecycleProvider, (previous, next) {
|
|
if (previous != AppLifecycleState.resumed &&
|
|
next == AppLifecycleState.resumed) {
|
|
unawaited(refresh());
|
|
}
|
|
});
|
|
return ref.read(buzzPushAuthorizationStatusReaderProvider)();
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
state = const AsyncLoading<BuzzPushAuthorizationStatus>();
|
|
state = await AsyncValue.guard(
|
|
ref.read(buzzPushAuthorizationStatusReaderProvider),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<BuzzPushAuthorizationStatus> readBuzzPushAuthorizationStatus() async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) {
|
|
return BuzzPushAuthorizationStatus.authorized;
|
|
}
|
|
final raw = await _channel.invokeMethod<String>(
|
|
'notificationAuthorizationStatus',
|
|
);
|
|
return switch (raw) {
|
|
'notDetermined' => BuzzPushAuthorizationStatus.notDetermined,
|
|
'denied' => BuzzPushAuthorizationStatus.denied,
|
|
'authorized' => BuzzPushAuthorizationStatus.authorized,
|
|
'provisional' => BuzzPushAuthorizationStatus.provisional,
|
|
'ephemeral' => BuzzPushAuthorizationStatus.ephemeral,
|
|
_ => throw FormatException(
|
|
'Native push bridge returned unknown authorization status: $raw',
|
|
),
|
|
};
|
|
}
|
|
|
|
Future<bool> openBuzzPushNotificationSettings() async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
|
|
return await _channel.invokeMethod<bool>('openNotificationSettings') ?? false;
|
|
}
|
|
|
|
/// 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);
|
|
|
|
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.
|
|
}
|
|
}
|
|
|
|
/// 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 (!Env.pushGatewayConfigured ||
|
|
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 (!Env.pushGatewayConfigured ||
|
|
defaultTargetPlatform != TargetPlatform.iOS) {
|
|
return const [];
|
|
}
|
|
try {
|
|
final raw = await _channel.invokeListMethod<dynamic>('endpointGrants', {
|
|
'gatewayUrl': Env.pushGatewayUrl,
|
|
});
|
|
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: $error');
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
}
|
|
|
|
Future<void> registerBuzzPushCommunitySnapshot(List<Community> communities) =>
|
|
_registerBuzzPushCommunitySnapshot(communities, strict: false);
|
|
|
|
/// Writes the age-gate snapshot through a native path that must acknowledge
|
|
/// both the app-group store and signing-key update.
|
|
Future<void> registerBuzzPushCommunitySnapshotStrict(
|
|
List<Community> communities, {
|
|
required bool settleFence,
|
|
}) => _registerBuzzPushCommunitySnapshot(
|
|
communities,
|
|
strict: true,
|
|
settleFence: settleFence,
|
|
);
|
|
|
|
/// Restores notification presentation without reading community storage.
|
|
Future<void> restoreAgeRestrictedBuzzNotifications() async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
|
await _channel.invokeMethod<void>('restoreAgeRestrictedNotifications');
|
|
}
|
|
|
|
/// Removes notifications rendered before a confirmed age restriction.
|
|
Future<void> purgeAgeRestrictedBuzzNotifications() async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
|
await _channel.invokeMethod<void>('purgeAgeRestrictedNotifications');
|
|
}
|
|
|
|
Future<void> _registerBuzzPushCommunitySnapshot(
|
|
List<Community> communities, {
|
|
required bool strict,
|
|
bool settleFence = false,
|
|
}) async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
|
try {
|
|
final snapshots = [
|
|
for (final community in communities)
|
|
if (community.pushNotificationsEnabled)
|
|
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) {
|
|
if (!community.pushNotificationsEnabled) continue;
|
|
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>(
|
|
strict ? 'syncAgeGatePushSnapshot' : 'syncPushSnapshot',
|
|
{
|
|
'section': 'communities',
|
|
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
|
|
'signingKeys': signingKeys,
|
|
if (strict) 'settleFence': settleFence,
|
|
},
|
|
);
|
|
} on MissingPluginException {
|
|
if (strict) rethrow;
|
|
// 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}');
|
|
}
|
|
});
|
|
}
|