mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
### What changed? Buzz mobile checks the app-store age signal at launch and hard-blocks the app behind a full-screen 18+ notice when the store says the signed-in person is under 18. No age signal (e.g. declined permission, indeterminate value, unsupported OS versions) results in no block. The age value is never persisted. It is read, used to compute one boolean, and discarded. Nothing is written to disk, and neither the age value nor the range is logged or sent to analytics. Platform notes: - **New dependency `com.google.android.play:age-signals:0.0.4`.** This is the only way to read the Play signal. Its AAR declares `minSdkVersion 23`, below the app's 24, so it forces **no minSdk bump**, and it self-declares its own consent activity, so **no AndroidManifest change** is needed. - **iOS entitlement and linking.** `DeclaredAgeRange` autolinks from the import. `Runner.entitlements` now declares `com.apple.developer.declared-age-range`, and the call remains behind `#available(iOS 26.0, *)`. ### Why? App stores now expose a declared-age range, and Buzz's Terms require users to be 18 or older. This wires the store signal to that rule with the smallest change that is defensible. Absent signals, declined or undeterminable results, and unsupported OS versions allow the app through. A transient native request failure does not become evidence that access is allowed: the app remains gated until a retry produces a result. Only an affirmative under-18 signal produces the permanent restriction screen. ### How is it tested? Added tests: - [`age_signal_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) covers the 17/18 boundary, open-ended ranges, no-signal, transient recovery, bounded retry exhaustion, malformed payloads, and request-once behavior - [`age_gate_app_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) proves authenticated content remains unmounted while the signal is pending or retryable, and verifies the retry path - [`age_restriction_page_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) asserts the block screen offers no bypass affordance - [`community_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/shared/community) verifies age restriction clears push state, persists inactive leases, and retries a failed tombstone - [`AgeSignalPayloadTest`](https://github.com/block/buzz/tree/main/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile) pins the Android success payload to exactly `{status, ageUpper}` and verifies failures return the distinct retryable platform error ### Deployment note Enable the Declared Age Range capability and regenerate signing profiles carrying the entitlement. This relops/App Store Connect task is not performed by this PR. *🤖 This PR was authored [with an agent](codex://threads/019e5128-536b-73b3-8348-88651123f6da).* | Age restriction screen | | --- | | <img src="https://github.com/user-attachments/assets/5b0f48b1-df9b-4a60-9800-80b33f128535" alt="Age restriction screen" width="320"> | --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: Codex <noreply@openai.com>
354 lines
12 KiB
Dart
354 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 (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: $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,
|
|
);
|
|
|
|
/// 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}');
|
|
}
|
|
});
|
|
}
|