From 8be4e926a1cd03c68e23ec19486659c7f2dfd158 Mon Sep 17 00:00:00 2001 From: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 20:57:18 -0700 Subject: [PATCH 1/3] feat(mobile): bridge Android age signals Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- mobile/android/app/build.gradle.kts | 2 + .../xyz/block/buzz/mobile/MainActivity.kt | 77 +++++++++++++++++++ .../block/buzz/mobile/AgeSignalPayloadTest.kt | 35 +++++++++ 3 files changed, 114 insertions(+) create mode 100644 mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 501a31c46f..ec9ea9a942 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -125,6 +125,8 @@ android { } dependencies { + implementation("com.google.android.play:age-signals:0.0.4") + testImplementation(kotlin("test")) androidTestImplementation(kotlin("test")) diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index 8718ffd8e2..bc2989b192 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -9,6 +9,11 @@ import android.media.MediaExtractor import android.media.MediaMuxer import android.os.Build import androidx.annotation.RequiresApi +import com.google.android.play.agesignals.AgeSignalsAccessRequest +import com.google.android.play.agesignals.AgeSignalsManager +import com.google.android.play.agesignals.AgeSignalsManagerFactory +import com.google.android.play.agesignals.AgeSignalsRequest +import com.google.android.play.agesignals.model.AgeSignalsStatus import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -17,6 +22,20 @@ import java.io.File import java.nio.ByteBuffer import java.util.UUID +internal fun ageSignalPayload(ageUpper: Int?): Map { + return mapOf( + "status" to "signal", + "ageUpper" to ageUpper, + ) +} + +internal fun noAgeSignalPayload(): Map { + return mapOf( + "status" to "noSignal", + "ageUpper" to null, + ) +} + internal object AndroidImageProcessor { fun decodeSrgbBitmap(bytes: ByteArray): Bitmap? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -78,6 +97,7 @@ internal object AndroidImageProcessor { class MainActivity : FlutterActivity() { private var mediaUploadChannel: MethodChannel? = null + private var ageSignalChannel: MethodChannel? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -104,6 +124,61 @@ class MainActivity : FlutterActivity() { } } } + + ageSignalChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + AGE_SIGNAL_CHANNEL, + ).also { channel -> + channel.setMethodCallHandler { call, result -> + when (call.method) { + REQUEST_AGE_SIGNAL_METHOD -> { + handleRequestAgeSignal( + AgeSignalsManagerFactory.create(applicationContext), + result, + ) + } + else -> result.notImplemented() + } + } + } + } + + private fun handleRequestAgeSignal( + ageSignalsManager: AgeSignalsManager, + result: MethodChannel.Result, + ) { + val accessRequest = AgeSignalsAccessRequest.builder() + .setActivity(this) + .build() + ageSignalsManager.requestAgeSignalsAccess(accessRequest) + .addOnSuccessListener { accessResult -> + if (accessResult.ageSignalsStatus() != AgeSignalsStatus.SHARED) { + replyWithNoAgeSignal(result) + return@addOnSuccessListener + } + + ageSignalsManager.checkAgeSignals(AgeSignalsRequest.builder().build()) + .addOnSuccessListener { ageSignalsResult -> + replyWithAgeSignal(result, ageSignalsResult.ageUpper()) + } + .addOnFailureListener { + replyWithNoAgeSignal(result) + } + } + .addOnFailureListener { + replyWithNoAgeSignal(result) + } + } + + private fun replyWithAgeSignal( + result: MethodChannel.Result, + ageUpper: Int?, + ) { + result.success(ageSignalPayload(ageUpper)) + } + + private fun replyWithNoAgeSignal(result: MethodChannel.Result) { + result.success(noAgeSignalPayload()) } private fun handleSanitizeImageForUpload( @@ -284,6 +359,8 @@ class MainActivity : FlutterActivity() { companion object { private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload" + private const val AGE_SIGNAL_CHANNEL = "buzz/age_signal" + private const val REQUEST_AGE_SIGNAL_METHOD = "requestAgeSignal" private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4" diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt new file mode 100644 index 0000000000..c9e6745ce6 --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt @@ -0,0 +1,35 @@ +package xyz.block.buzz.mobile + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AgeSignalPayloadTest { + @Test + fun `signal payload contains only status and upper age bound`() { + assertEquals( + mapOf( + "status" to "signal", + "ageUpper" to 17, + ), + ageSignalPayload(17), + ) + assertEquals( + mapOf( + "status" to "signal", + "ageUpper" to null, + ), + ageSignalPayload(null), + ) + } + + @Test + fun `no-signal payload contains only status and null upper age bound`() { + assertEquals( + mapOf( + "status" to "noSignal", + "ageUpper" to null, + ), + noAgeSignalPayload(), + ) + } +} From 0d86e46b7c207377329f5f0afc1c1bc4b4df5c60 Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 21:14:05 -0700 Subject: [PATCH 2/3] feat(mobile): gate app with store age signals Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- mobile/ios/Runner/AppDelegate.swift | 57 ++++++++++ mobile/lib/app.dart | 22 +++- .../age_gate/age_restriction_page.dart | 49 ++++++++ .../age_gate/age_signal_provider.dart | 58 ++++++++++ .../features/age_gate/age_gate_app_test.dart | 71 ++++++++++++ .../age_gate/age_restriction_page_test.dart | 18 +++ .../age_gate/age_signal_provider_test.dart | 105 ++++++++++++++++++ 7 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 mobile/lib/features/age_gate/age_restriction_page.dart create mode 100644 mobile/lib/features/age_gate/age_signal_provider.dart create mode 100644 mobile/test/features/age_gate/age_gate_app_test.dart create mode 100644 mobile/test/features/age_gate/age_restriction_page_test.dart create mode 100644 mobile/test/features/age_gate/age_signal_provider_test.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f6fe3aaf48..565ed22659 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,4 +1,5 @@ import AVFoundation +import DeclaredAgeRange import Flutter import UIKit import UserNotifications @@ -8,6 +9,7 @@ import UserNotifications private var mediaUploadChannel: FlutterMethodChannel? private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? + private var ageSignalChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? override func application( @@ -51,6 +53,21 @@ import UserNotifications } } + ageSignalChannel = FlutterMethodChannel( + name: "buzz/age_signal", + binaryMessenger: messenger + ) + let ageSignalViewController = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzAgeSignal" + )?.viewController + ageSignalChannel?.setMethodCallHandler { [weak ageSignalViewController] call, result in + Self.handleAgeSignalMethodCall( + call, + viewController: ageSignalViewController, + result: result + ) + } + if let inlinePhotoPickerRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzInlinePhotoPicker" ) { @@ -72,6 +89,46 @@ import UserNotifications ) } + private static func handleAgeSignalMethodCall( + _ call: FlutterMethodCall, + viewController: UIViewController?, + result: @escaping FlutterResult + ) { + guard call.method == "requestAgeSignal" else { + result(FlutterMethodNotImplemented) + return + } + guard #available(iOS 26.0, *), let viewController else { + result(Self.noAgeSignalResponse) + return + } + + Task { @MainActor in + do { + let response = try await AgeRangeService.shared.requestAgeRange( + ageGates: 18, + in: viewController + ) + switch response { + case .declinedSharing: + result(Self.noAgeSignalResponse) + case .sharing(let range): + let ageUpper = range.upperBound.map { $0 as Any } ?? NSNull() + result(["status": "signal", "ageUpper": ageUpper]) + @unknown default: + result(Self.noAgeSignalResponse) + } + } catch { + result(Self.noAgeSignalResponse) + } + } + } + + private static let noAgeSignalResponse: [String: Any] = [ + "status": "noSignal", + "ageUpper": NSNull(), + ] + private static func handleQrScannerMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 0538215109..76fce33fa8 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:app_badge_plus/app_badge_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'features/age_gate/age_restriction_page.dart'; +import 'features/age_gate/age_signal_provider.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/pairing/pairing_page.dart'; @@ -28,6 +32,14 @@ class App extends HookConsumerWidget { final accentIndex = ref.watch(accentProvider); final schemeName = ref.watch(schemeProvider); final authState = ref.watch(authProvider); + final ageRestricted = ref.watch(ageSignalProvider); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(ref.read(ageSignalProvider.notifier).request()); + }); + return null; + }, const []); final resolved = resolveSchemes(schemeName, themeMode); final lightScheme = applyAccent(resolved.light, accentIndex); @@ -90,11 +102,11 @@ class App extends HookConsumerWidget { topSectionGradient: buzzDarkGradient, ), themeMode: effectiveMode, - // Above the navigator, so a burst keeps playing over a pushed thread page - // or a modal sheet — the same reason desktop pins its canvas to the - // viewport rather than to the message row. - builder: (context, child) => - EmojiBurstOverlay(child: child ?? const SizedBox.shrink()), + // Above the navigator, so an age restriction cannot be bypassed by a + // route that was pushed while the store signal request was in flight. + builder: (context, child) => ageRestricted + ? const AgeRestrictionPage() + : EmojiBurstOverlay(child: child ?? const SizedBox.shrink()), home: authState.when( loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), diff --git a/mobile/lib/features/age_gate/age_restriction_page.dart b/mobile/lib/features/age_gate/age_restriction_page.dart new file mode 100644 index 0000000000..10426c5447 --- /dev/null +++ b/mobile/lib/features/age_gate/age_restriction_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../../shared/theme/theme.dart'; + +class AgeRestrictionPage extends StatelessWidget { + const AgeRestrictionPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(Grid.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + size: 48, + color: context.colors.primary, + ), + const SizedBox(height: Grid.lg), + Text( + 'Buzz is for people 18 and older', + textAlign: TextAlign.center, + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.sm), + Text( + "You must be 18 or older to use Buzz under Buzz's Terms.", + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/age_gate/age_signal_provider.dart b/mobile/lib/features/age_gate/age_signal_provider.dart new file mode 100644 index 0000000000..723b08e01f --- /dev/null +++ b/mobile/lib/features/age_gate/age_signal_provider.dart @@ -0,0 +1,58 @@ +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const ageSignalChannel = MethodChannel('buzz/age_signal'); + +bool shouldBlockForAgeSignal(Map response) { + if (response.length != 2 || + !response.containsKey('status') || + !response.containsKey('ageUpper')) { + throw StateError('Unexpected age signal response.'); + } + + final status = response['status']; + if (status == 'noSignal') { + return false; + } + if (status != 'signal') { + throw StateError('Unexpected age signal status.'); + } + + final ageUpper = response['ageUpper']; + if (ageUpper == null) { + return false; + } + if (ageUpper is! int) { + throw StateError('Unexpected age signal upper bound.'); + } + return ageUpper < 18; +} + +class AgeSignalNotifier extends Notifier { + bool _requested = false; + + @override + bool build() => false; + + Future request() async { + if (_requested) { + return; + } + _requested = true; + + try { + final response = await ageSignalChannel.invokeMapMethod( + 'requestAgeSignal', + ); + if (response != null) { + state = shouldBlockForAgeSignal(response); + } + } on Object { + state = false; + } + } +} + +final ageSignalProvider = NotifierProvider( + AgeSignalNotifier.new, +); diff --git a/mobile/test/features/age_gate/age_gate_app_test.dart b/mobile/test/features/age_gate/age_gate_app_test.dart new file mode 100644 index 0000000000..bbbb9567ca --- /dev/null +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:buzz/app.dart'; +import 'package:buzz/features/age_gate/age_restriction_page.dart'; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, null); + }); + + testWidgets('requests after the first frame and gates pushed routes', ( + tester, + ) async { + final response = Completer(); + var requests = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (call) { + requests += 1; + return response.future; + }); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => _FakeAuthNotifier()), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: const App(), + ), + ); + + expect(requests, 1); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Pushed route')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Pushed route'), findsOneWidget); + + response.complete({'status': 'signal', 'ageUpper': 17}); + await tester.pump(); + await tester.pump(); + + expect(find.byType(AgeRestrictionPage), findsOneWidget); + expect(find.text('Pushed route'), findsNothing); + expect(requests, 1); + }); +} + +class _FakeAuthNotifier extends AuthNotifier { + @override + Future build() async { + return const AuthState(status: AuthStatus.unauthenticated); + } +} diff --git a/mobile/test/features/age_gate/age_restriction_page_test.dart b/mobile/test/features/age_gate/age_restriction_page_test.dart new file mode 100644 index 0000000000..6ac2fa1a4a --- /dev/null +++ b/mobile/test/features/age_gate/age_restriction_page_test.dart @@ -0,0 +1,18 @@ +import 'package:buzz/features/age_gate/age_restriction_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows the 18+ restriction without a bypass', (tester) async { + await tester.pumpWidget(const MaterialApp(home: AgeRestrictionPage())); + + expect(find.text('Buzz is for people 18 and older'), findsOneWidget); + expect( + find.text("You must be 18 or older to use Buzz under Buzz's Terms."), + findsOneWidget, + ); + expect(find.byType(TextButton), findsNothing); + expect(find.byType(FilledButton), findsNothing); + expect(find.byType(ElevatedButton), findsNothing); + }); +} diff --git a/mobile/test/features/age_gate/age_signal_provider_test.dart b/mobile/test/features/age_gate/age_signal_provider_test.dart new file mode 100644 index 0000000000..c6eee85863 --- /dev/null +++ b/mobile/test/features/age_gate/age_signal_provider_test.dart @@ -0,0 +1,105 @@ +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, null); + }); + + Future requestWithResponse(Object? response) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (call) async { + expect(call.method, 'requestAgeSignal'); + expect(call.arguments, isNull); + return response; + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(ageSignalProvider.notifier).request(); + return container.read(ageSignalProvider); + } + + test('blocks when the signal upper bound is 17', () async { + expect( + await requestWithResponse({'status': 'signal', 'ageUpper': 17}), + isTrue, + ); + }); + + test('allows when the signal upper bound is 18', () async { + expect( + await requestWithResponse({'status': 'signal', 'ageUpper': 18}), + isFalse, + ); + }); + + test('allows when a signal has an open-ended upper bound', () async { + expect( + await requestWithResponse({'status': 'signal', 'ageUpper': null}), + isFalse, + ); + }); + + test('allows when no signal is available', () async { + expect( + await requestWithResponse({'status': 'noSignal', 'ageUpper': null}), + isFalse, + ); + }); + + test('allows when the platform request throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) async { + throw PlatformException(code: 'unavailable'); + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(ageSignalProvider.notifier).request(); + + expect(container.read(ageSignalProvider), isFalse); + }); + + test('allows malformed and unexpected platform responses', () async { + expect( + await requestWithResponse({'status': 'unknown', 'ageUpper': null}), + isFalse, + ); + expect( + await requestWithResponse({'status': 'signal', 'ageUpper': '17'}), + isFalse, + ); + expect( + await requestWithResponse({ + 'status': 'signal', + 'ageUpper': 17, + 'ageLower': 13, + }), + isFalse, + ); + expect(await requestWithResponse(null), isFalse); + }); + + test('requests the signal at most once', () async { + var requests = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) async { + requests += 1; + return {'status': 'noSignal', 'ageUpper': null}; + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(ageSignalProvider.notifier); + + await notifier.request(); + await notifier.request(); + + expect(requests, 1); + }); +} From 4ded04c4a7e43831f2a9ef0b30f343a556396ece Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 21:15:16 -0700 Subject: [PATCH 3/3] test(mobile): cover authenticated age gate Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- .../features/age_gate/age_gate_app_test.dart | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/mobile/test/features/age_gate/age_gate_app_test.dart b/mobile/test/features/age_gate/age_gate_app_test.dart index bbbb9567ca..401bb6266f 100644 --- a/mobile/test/features/age_gate/age_gate_app_test.dart +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:buzz/app.dart'; import 'package:buzz/features/age_gate/age_restriction_page.dart'; import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; import 'package:flutter/material.dart'; @@ -18,6 +19,26 @@ void main() { .setMockMethodCallHandler(ageSignalChannel, null); }); + testWidgets('blocks authenticated app content', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: const App(), + ), + ); + await tester.pump(); + + expect(find.byType(AgeRestrictionPage), findsOneWidget); + expect(find.byType(HomePage), findsNothing); + }); + testWidgets('requests after the first frame and gates pushed routes', ( tester, ) async { @@ -63,6 +84,18 @@ void main() { }); } +class _AuthenticatedAuthNotifier extends AuthNotifier { + @override + Future build() async { + return const AuthState(status: AuthStatus.authenticated); + } +} + +class _BlockingAgeSignalNotifier extends AgeSignalNotifier { + @override + bool build() => true; +} + class _FakeAuthNotifier extends AuthNotifier { @override Future build() async {