From 7bf6f328024950cffbac5345a25c9dbd2ec3df03 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 28 Jul 2026 14:09:06 +0700 Subject: [PATCH] fix(extensions): serialize verification callbacks --- lib/services/platform_bridge.dart | 28 ++++ lib/utils/extension_auth_launcher.dart | 141 +++++++++++-------- test/platform_bridge_session_grant_test.dart | 54 +++++++ 3 files changed, 162 insertions(+), 61 deletions(-) create mode 100644 test/platform_bridge_session_grant_test.dart diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f0f17504..13d980a7 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1409,9 +1409,37 @@ class PlatformBridge { }); } + static final Map> _extensionSessionGrantCompletions = + >{}; + static Future completeExtensionSessionGrant( String extensionId, String grant, + ) { + final normalizedExtensionId = extensionId.trim(); + final key = normalizedExtensionId.toLowerCase(); + final activeCompletion = _extensionSessionGrantCompletions[key]; + if (activeCompletion != null) { + _log.d( + 'Joining active completeExtensionSessionGrant: $normalizedExtensionId', + ); + return activeCompletion; + } + + late final Future completion; + completion = _completeExtensionSessionGrant(normalizedExtensionId, grant) + .whenComplete(() { + if (identical(_extensionSessionGrantCompletions[key], completion)) { + _extensionSessionGrantCompletions.remove(key); + } + }); + _extensionSessionGrantCompletions[key] = completion; + return completion; + } + + static Future _completeExtensionSessionGrant( + String extensionId, + String grant, ) async { _log.d('completeExtensionSessionGrant: $extensionId'); final result = await _channel.invokeMethod( diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 94929ea7..06662fb3 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -177,6 +177,7 @@ Future showExtensionVerificationHelpDialog( : l10n.extensionVerificationHelpMessageWaiting; final normalizedExtensionId = extensionId.trim(); BuildContext? activeDialogContext; + var clipboardGrantInFlight = false; late final StreamSubscription grantSub; grantSub = PlatformBridge.extensionSessionGrantEvents() .where( @@ -200,69 +201,90 @@ Future showExtensionVerificationHelpDialog( builder: (dialogContext) { activeDialogContext = dialogContext; final dialogL10n = dialogContext.l10n; - return AlertDialog( - title: Text(title), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(message), - const SizedBox(height: 16), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of( - dialogContext, - ).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: SelectableText( - authUri.toString(), - maxLines: 4, - minLines: 1, + return StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message), + const SizedBox(height: 16), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + dialogContext, + ).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + authUri.toString(), + maxLines: 4, + minLines: 1, + ), ), ), + ], + ), + actions: [ + TextButton( + onPressed: clipboardGrantInFlight + ? null + : () => Navigator.of(dialogContext).pop(), + child: Text(dialogL10n.extensionVerificationClose), + ), + TextButton.icon( + icon: const Icon(Icons.copy), + label: Text(dialogL10n.extensionVerificationCopyLink), + onPressed: clipboardGrantInFlight + ? null + : () { + Clipboard.setData( + ClipboardData(text: authUri.toString()), + ); + ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar( + SnackBar( + content: Text( + dialogL10n.extensionVerificationLinkCopied, + ), + ), + ); + }, + ), + TextButton.icon( + icon: clipboardGrantInFlight + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.content_paste), + label: const Text('Paste callback'), + onPressed: clipboardGrantInFlight + ? null + : () async { + setDialogState(() => clipboardGrantInFlight = true); + await _completeSessionGrantFromClipboard( + dialogContext, + extensionId, + ); + if (dialogContext.mounted) { + setDialogState(() => clipboardGrantInFlight = false); + } + }, + ), + FilledButton.icon( + icon: const Icon(Icons.open_in_browser), + label: Text(dialogL10n.extensionVerificationOpenBrowser), + onPressed: clipboardGrantInFlight + ? null + : () { + unawaited(_launchVerificationUrl(authUri, browserMode)); + }, ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(dialogL10n.extensionVerificationClose), - ), - TextButton.icon( - icon: const Icon(Icons.copy), - label: Text(dialogL10n.extensionVerificationCopyLink), - onPressed: () { - Clipboard.setData(ClipboardData(text: authUri.toString())); - ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar( - SnackBar( - content: Text(dialogL10n.extensionVerificationLinkCopied), - ), - ); - }, - ), - TextButton.icon( - icon: const Icon(Icons.content_paste), - label: const Text('Paste callback'), - onPressed: () { - unawaited( - _completeSessionGrantFromClipboard( - dialogContext, - extensionId, - ), - ); - }, - ), - FilledButton.icon( - icon: const Icon(Icons.open_in_browser), - label: Text(dialogL10n.extensionVerificationOpenBrowser), - onPressed: () { - unawaited(_launchVerificationUrl(authUri, browserMode)); - }, - ), - ], ); }, ); @@ -304,9 +326,6 @@ Future _completeSessionGrantFromClipboard( ), ), ); - if (success) { - Navigator.of(context, rootNavigator: true).pop(); - } } catch (e) { if (!context.mounted) return; messenger?.showSnackBar( diff --git a/test/platform_bridge_session_grant_test.dart b/test/platform_bridge_session_grant_test.dart new file mode 100644 index 00000000..9023ee10 --- /dev/null +++ b/test/platform_bridge_session_grant_test.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const backendChannel = MethodChannel('com.zarz.spotiflac/backend'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, null); + }); + + test('duplicate session grant completions share one native call', () async { + final nativeResult = Completer(); + var callCount = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + if (call.method != 'completeExtensionSessionGrant') return null; + callCount++; + return nativeResult.future; + }); + + final events = []; + final subscription = PlatformBridge.extensionSessionGrantEvents().listen( + events.add, + ); + addTearDown(subscription.cancel); + + final first = PlatformBridge.completeExtensionSessionGrant( + ' qobuz-web ', + 'grant-value', + ); + final second = PlatformBridge.completeExtensionSessionGrant( + 'QOBUZ-WEB', + 'grant-value', + ); + + expect(identical(first, second), isTrue); + expect(callCount, 1); + + nativeResult.complete(true); + expect(await Future.wait([first, second]), [isTrue, isTrue]); + await Future.delayed(Duration.zero); + + expect(callCount, 1); + expect(events, hasLength(1)); + expect(events.single.extensionId, 'qobuz-web'); + expect(events.single.success, isTrue); + }); +}