diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index ac2ae6c5..d703a292 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -113,7 +113,11 @@ Future openPendingExtensionVerification( onAuthUri?.call(uri); final launched = await _awaitVerificationStepOrCancellation( - _launchVerificationUrl(uri, browserMode), + _launchVerificationUrl( + uri, + browserMode, + cancellationSignal: cancellationSignal, + ), cancellationSignal, ); @@ -130,6 +134,7 @@ Future openPendingExtensionVerification( uri, browserMode: browserMode, immediateFailure: true, + cancellationSignal: cancellationSignal, ); } return launched; @@ -158,6 +163,7 @@ Timer? scheduleExtensionVerificationHelpDialog( Uri? authUri, { String browserMode = 'in_app_first', Duration delay = const Duration(seconds: 20), + Future? cancellationSignal, }) { final normalizedExtensionId = extensionId.trim(); if (normalizedExtensionId.isEmpty || authUri == null) return null; @@ -168,13 +174,15 @@ Timer? scheduleExtensionVerificationHelpDialog( normalizedExtensionId, authUri, browserMode: browserMode, + cancellationSignal: cancellationSignal, ), ); }); } /// Opens a pending extension verification challenge and waits (up to 3 -/// minutes) for its grant result, returning whether it succeeded. When +/// minutes, including pending lookup and browser launch) for its grant result, +/// returning whether it succeeded. When /// [cancellationSignal] completes, all local waiting resources are released /// and the method returns false without waiting for the timeout. /// @@ -211,6 +219,20 @@ Future openVerificationAndAwaitGrant( Uri? authUri; Timer? helpDialogTimer; + final stopped = Completer(); + void stop() { + if (!stopped.isCompleted) stopped.complete(); + } + + if (cancellationSignal != null) { + unawaited(cancellationSignal.then((_) => stop())); + } + // The browser or manual-help dialog may never return. Bound the complete + // foreground flow, not only the grant wait reached after opening the page. + final deadline = Timer(extensionVerificationGrantTimeout, () { + _log.w('Timed out waiting for verification grant: $normalizedExtensionId'); + stop(); + }); try { final opened = await _awaitVerificationStepOrCancellation( @@ -218,9 +240,9 @@ Future openVerificationAndAwaitGrant( normalizedExtensionId, browserMode: browserMode, onAuthUri: (uri) => authUri = uri, - cancellationSignal: cancellationSignal, + cancellationSignal: stopped.future, ), - cancellationSignal, + stopped.future, ); if (opened != true) return false; @@ -228,12 +250,13 @@ Future openVerificationAndAwaitGrant( normalizedExtensionId, authUri, browserMode: browserMode, + cancellationSignal: stopped.future, ); final event = await _awaitVerificationStepOrCancellation( grantCompleter.future, - cancellationSignal, - ).timeout(extensionVerificationGrantTimeout); + stopped.future, + ); if (event == null) { _log.i('Stopped waiting for verification grant: $normalizedExtensionId'); return false; @@ -243,7 +266,9 @@ Future openVerificationAndAwaitGrant( _log.w('Timed out waiting for verification grant: $normalizedExtensionId'); return false; } finally { + deadline.cancel(); helpDialogTimer?.cancel(); + stop(); await grantSub.cancel(); } } @@ -266,6 +291,7 @@ Future showExtensionVerificationHelpDialog( Uri authUri, { String browserMode = 'in_app_first', bool immediateFailure = false, + Future? cancellationSignal, }) async { final context = AppNavigationService.rootNavigatorKey.currentContext; if (context == null) { @@ -282,6 +308,25 @@ Future showExtensionVerificationHelpDialog( : l10n.extensionVerificationHelpMessageWaiting; final normalizedExtensionId = extensionId.trim(); BuildContext? activeDialogContext; + var closeRequested = false; + void closeDialog() { + closeRequested = true; + final dialogContext = activeDialogContext; + if (dialogContext == null || !dialogContext.mounted) return; + final route = ModalRoute.of(dialogContext); + if (route == null || !route.isActive) return; + final navigator = Navigator.of(dialogContext, rootNavigator: true); + if (route.isCurrent) { + navigator.pop(); + } else { + // A newer dialog may be above this one; dismiss only our own route. + navigator.removeRoute(route); + } + } + + if (cancellationSignal != null) { + unawaited(cancellationSignal.then((_) => closeDialog())); + } var clipboardGrantInFlight = false; late final StreamSubscription grantSub; grantSub = PlatformBridge.extensionSessionGrantEvents() @@ -290,12 +335,10 @@ Future showExtensionVerificationHelpDialog( event.success && event.extensionId.trim() == normalizedExtensionId, ) .listen((_) { - final dialogContext = activeDialogContext; - if (dialogContext == null || !dialogContext.mounted) return; _log.i( 'Closing verification help dialog after $normalizedExtensionId grant', ); - Navigator.of(dialogContext, rootNavigator: true).pop(); + closeDialog(); }); try { @@ -304,6 +347,9 @@ Future showExtensionVerificationHelpDialog( barrierDismissible: false, builder: (dialogContext) { activeDialogContext = dialogContext; + if (closeRequested) { + WidgetsBinding.instance.addPostFrameCallback((_) => closeDialog()); + } final dialogL10n = dialogContext.l10n; return StatefulBuilder( builder: (dialogContext, setDialogState) => AppAlertDialog( @@ -384,7 +430,13 @@ Future showExtensionVerificationHelpDialog( onPressed: clipboardGrantInFlight ? null : () { - unawaited(_launchVerificationUrl(authUri, browserMode)); + unawaited( + _launchVerificationUrl( + authUri, + browserMode, + cancellationSignal: cancellationSignal, + ), + ); }, child: Text(dialogL10n.extensionVerificationOpenBrowser), ), @@ -462,7 +514,15 @@ Future launchExtensionAuthUrl(Uri uri, {required String browserMode}) { return _launchVerificationUrl(uri, browserMode); } -Future _launchVerificationUrl(Uri uri, String browserMode) async { +Future _launchVerificationUrl( + Uri uri, + String browserMode, { + Future? cancellationSignal, +}) async { + var cancelled = false; + if (cancellationSignal != null) { + unawaited(cancellationSignal.then((_) => cancelled = true)); + } if (Platform.isIOS && uri.scheme == 'https') { try { if (await PlatformBridge.startIosWebAuthSession(uri.toString())) { @@ -483,6 +543,7 @@ Future _launchVerificationUrl(Uri uri, String browserMode) async { : LaunchMode.inAppBrowserView; for (final mode in [firstMode, fallbackMode]) { + if (cancelled) return false; try { if (await launchUrl(uri, mode: mode)) return true; } on PlatformException catch (error) { diff --git a/test/extension_verification_feedback_test.dart b/test/extension_verification_feedback_test.dart index 3073125f..5c381aeb 100644 --- a/test/extension_verification_feedback_test.dart +++ b/test/extension_verification_feedback_test.dart @@ -5,14 +5,19 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/services/app_navigation_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; void main() { const channel = MethodChannel('com.zarz.spotiflac/backend'); + const browser = MethodChannel('plugins.flutter.io/url_launcher'); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(browser, null); }); Future showApp(WidgetTester tester) => tester.pumpWidget( @@ -64,4 +69,127 @@ void main() { expect(find.byType(SnackBar), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); }); + + for (final blockedStep in [ + 'pending lookup', + 'browser launch', + 'manual help', + 'grant wait', + ]) { + testWidgets('verification timeout also bounds $blockedStep', ( + tester, + ) async { + final messenger = tester.binding.defaultBinaryMessenger; + final blocked = Completer(); + var launches = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'completeExtensionSessionGrant') return true; + expect(call.method, 'getExtensionPendingAuth'); + if (blockedStep == 'pending lookup') return await blocked.future; + return {'auth_url': 'https://example.invalid/verify?state=sample'}; + }); + messenger.setMockMethodCallHandler(browser, (_) async { + launches++; + if (blockedStep == 'browser launch') return await blocked.future; + return blockedStep == 'grant wait'; + }); + await showApp(tester); + bool? granted; + final waiting = openVerificationAndAwaitGrant( + 'provider-a', + browserMode: 'in_app_first', + ).then((value) => granted = value); + await tester.pumpAndSettle(); + expect(granted, isNull); + expect( + launches, + blockedStep == 'pending lookup' + ? 0 + : blockedStep == 'manual help' + ? 2 + : 1, + ); + if (blockedStep == 'manual help') { + expect(find.byType(AppAlertDialog), findsOneWidget); + } + await tester.pump(extensionVerificationGrantTimeout); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pumpAndSettle(); + expect(granted, isFalse, reason: 'Every browser step has a deadline'); + await waiting; + expect(find.byType(AppAlertDialog), findsNothing); + + // A late native/browser response must not reopen the expired flow. + blocked.complete( + blockedStep == 'pending lookup' + ? {'auth_url': 'https://example.invalid/late'} + : false, + ); + await tester.pumpAndSettle(); + expect( + launches, + blockedStep == 'pending lookup' + ? 0 + : blockedStep == 'manual help' + ? 2 + : 1, + reason: 'Late failure must not launch the fallback browser', + ); + expect(find.byType(AppAlertDialog), findsNothing); + expect(find.byType(SnackBar), findsNothing); + + // A fresh attempt can still receive a grant after the timed-out flow. + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'completeExtensionSessionGrant') return true; + return {'auth_url': 'https://example.invalid/verify?state=retry'}; + }); + messenger.setMockMethodCallHandler(browser, (_) async { + unawaited( + PlatformBridge.completeExtensionSessionGrant('provider-a', 'grant'), + ); + return true; + }); + expect( + await tester.runAsync( + () => openVerificationAndAwaitGrant( + 'provider-a', + browserMode: 'in_app_first', + ), + ), + isTrue, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets('cancelling a wait dismisses its already-visible help dialog', ( + tester, + ) async { + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + channel, + (_) async => {'auth_url': 'https://example.invalid/verify?state=sample'}, + ); + messenger.setMockMethodCallHandler(browser, (_) async => true); + await showApp(tester); + final cancellation = Completer(); + final waiting = openVerificationAndAwaitGrant( + 'provider-a', + browserMode: 'in_app_first', + cancellationSignal: cancellation.future, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 20)); + await tester.pumpAndSettle(); + expect(find.byType(AppAlertDialog), findsOneWidget); + cancellation.complete(); + await tester.pump(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + expect(await waiting, isFalse); + await tester.pumpAndSettle(); + expect(find.byType(AppAlertDialog), findsNothing); + expect(find.text('Library'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); }