fix(extensions): serialize verification callbacks

This commit is contained in:
zarzet
2026-07-28 14:09:06 +07:00
parent 6368d1feee
commit 7bf6f32802
3 changed files with 162 additions and 61 deletions
+28
View File
@@ -1409,9 +1409,37 @@ class PlatformBridge {
});
}
static final Map<String, Future<bool>> _extensionSessionGrantCompletions =
<String, Future<bool>>{};
static Future<bool> 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<bool> completion;
completion = _completeExtensionSessionGrant(normalizedExtensionId, grant)
.whenComplete(() {
if (identical(_extensionSessionGrantCompletions[key], completion)) {
_extensionSessionGrantCompletions.remove(key);
}
});
_extensionSessionGrantCompletions[key] = completion;
return completion;
}
static Future<bool> _completeExtensionSessionGrant(
String extensionId,
String grant,
) async {
_log.d('completeExtensionSessionGrant: $extensionId');
final result = await _channel.invokeMethod(
+80 -61
View File
@@ -177,6 +177,7 @@ Future<bool> showExtensionVerificationHelpDialog(
: l10n.extensionVerificationHelpMessageWaiting;
final normalizedExtensionId = extensionId.trim();
BuildContext? activeDialogContext;
var clipboardGrantInFlight = false;
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
.where(
@@ -200,69 +201,90 @@ Future<bool> 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<void> _completeSessionGrantFromClipboard(
),
),
);
if (success) {
Navigator.of(context, rootNavigator: true).pop();
}
} catch (e) {
if (!context.mounted) return;
messenger?.showSnackBar(
@@ -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<bool>();
var callCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
if (call.method != 'completeExtensionSessionGrant') return null;
callCount++;
return nativeResult.future;
});
final events = <ExtensionSessionGrantEvent>[];
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<void>.delayed(Duration.zero);
expect(callCount, 1);
expect(events, hasLength(1));
expect(events.single.extensionId, 'qobuz-web');
expect(events.single.success, isTrue);
});
}