fix(verification): bound browser handoffs and release expired dialogs

Apply the three-minute deadline to pending-auth lookup, browser launch, manual help and grant waiting. Dismiss owned help dialogs on timeout or cancellation, and prevent late launch failures from opening a fallback browser.

Add regression coverage for stalled steps, cancellation, late responses and successful retry after expiry. Related queue and verification validation: 155 tests passed; scoped analyzer and formatter clean.

Refs #675
This commit is contained in:
zarzet committed 2026-10-17 11:45:40 +07:00
1 parent 56a526caf4
commit 2bf50ecb30
2 files changed
+200 -11

No files matched your search

+72 -11
View File
@@ -113,7 +113,11 @@ Future<bool> openPendingExtensionVerification(
onAuthUri?.call(uri);
final launched = await _awaitVerificationStepOrCancellation(
_launchVerificationUrl(uri, browserMode),
_launchVerificationUrl(
uri,
browserMode,
cancellationSignal: cancellationSignal,
),
cancellationSignal,
);
@@ -130,6 +134,7 @@ Future<bool> 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<void>? 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<bool> openVerificationAndAwaitGrant(
Uri? authUri;
Timer? helpDialogTimer;
final stopped = Completer<void>();
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<bool> 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<bool> 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<bool> 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<bool> showExtensionVerificationHelpDialog(
Uri authUri, {
String browserMode = 'in_app_first',
bool immediateFailure = false,
Future<void>? cancellationSignal,
}) async {
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null) {
@@ -282,6 +308,25 @@ Future<bool> 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<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
@@ -290,12 +335,10 @@ Future<bool> 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<bool> 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<bool> showExtensionVerificationHelpDialog(
onPressed: clipboardGrantInFlight
? null
: () {
unawaited(_launchVerificationUrl(authUri, browserMode));
unawaited(
_launchVerificationUrl(
authUri,
browserMode,
cancellationSignal: cancellationSignal,
),
);
},
child: Text(dialogL10n.extensionVerificationOpenBrowser),
),
@@ -462,7 +514,15 @@ Future<bool> launchExtensionAuthUrl(Uri uri, {required String browserMode}) {
return _launchVerificationUrl(uri, browserMode);
}
Future<bool> _launchVerificationUrl(Uri uri, String browserMode) async {
Future<bool> _launchVerificationUrl(
Uri uri,
String browserMode, {
Future<void>? 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<bool> _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) {
@@ -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<void> 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<Object?>();
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<void>.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<void>();
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<void>.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());
});
}