From 8b231be19a4bc7266d83c2648154e85a83849c4b Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 18 Aug 2026 20:50:48 +0700 Subject: [PATCH] fix(download): unblock queue after verification cancel --- lib/providers/download_queue_provider.dart | 5 +- .../download_queue_provider_verification.dart | 75 +++++++------ .../download_verification_retry_guard.dart | 106 ++++++++++++++++++ lib/utils/extension_auth_launcher.dart | 65 ++++++++--- ...ownload_verification_retry_guard_test.dart | 96 ++++++++++++++++ test/extension_auth_launcher_test.dart | 17 +++ 6 files changed, 317 insertions(+), 47 deletions(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5c3b3d88..5da7e795 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -358,7 +358,8 @@ class DownloadQueueNotifier extends Notifier { final Set _pausePendingItemIds = {}; final DownloadVerificationRetryGuard _verificationRetryGuard = DownloadVerificationRetryGuard(); - final Map> _verificationFlowsByExtension = {}; + final DownloadVerificationWaitCoordinator _verificationWaitCoordinator = + DownloadVerificationWaitCoordinator(); final Set _rateLimitRetriedItemIds = {}; String? _activeNativeWorkerRunId; bool get _hasActiveAndroidNativeWorker => @@ -379,6 +380,7 @@ class DownloadQueueNotifier extends Notifier { }); ref.onDispose(() { + _verificationWaitCoordinator.cancelAll(); _progressPoller.stop(); _connectivitySub?.cancel(); _connectivitySub = null; @@ -1015,6 +1017,7 @@ class DownloadQueueNotifier extends Notifier { } void _requestNativeCancel(String id) { + _verificationWaitCoordinator.cancelItem(id); PlatformBridge.cancelDownload(id).catchError((_) {}); PlatformBridge.clearItemProgress(id).catchError((_) {}); } diff --git a/lib/providers/download_queue_provider_verification.dart b/lib/providers/download_queue_provider_verification.dart index 6307d89e..0afbdb0f 100644 --- a/lib/providers/download_queue_provider_verification.dart +++ b/lib/providers/download_queue_provider_verification.dart @@ -5,9 +5,9 @@ extension _DownloadQueueVerificationGate on DownloadQueueNotifier { /// Completes when the app is in the foreground. Verification challenges /// can only be handled there: launching a browser from the background is /// blocked by the OS and the challenge would expire unseen. - Future _waitForForeground() async { + Future _waitForForeground(Future cancellationSignal) async { if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { - return; + return true; } final completer = Completer(); final listener = AppLifecycleListener( @@ -16,48 +16,53 @@ extension _DownloadQueueVerificationGate on DownloadQueueNotifier { }, ); try { - await completer.future; + return await Future.any([ + completer.future.then((_) => true), + cancellationSignal.then((_) => false), + ]); } finally { listener.dispose(); } } - Future _openVerificationAndWait(String extensionId) { - final key = extensionId.trim().toLowerCase(); - final activeFlow = _verificationFlowsByExtension[key]; - if (activeFlow != null) { + Future _openVerificationAndWait(String itemId, String extensionId) { + if (_verificationWaitCoordinator.hasActiveFlow(extensionId)) { _log.i( 'Joining active verification flow for $extensionId instead of opening another challenge', ); - return activeFlow; } - final flow = _runVerificationFlow(extensionId, key); - _verificationFlowsByExtension[key] = flow; - return flow; + return _verificationWaitCoordinator.waitForGrant( + itemId: itemId, + service: extensionId, + startFlow: (cancellationSignal) => + _runVerificationFlow(extensionId, cancellationSignal), + ); } - Future _runVerificationFlow(String extensionId, String key) async { - try { - return await openVerificationAndAwaitGrant( - extensionId, - browserMode: ref - .read(settingsProvider) - .extensionVerificationBrowserMode, - awaitForeground: (normalizedExtensionId) async { - if (WidgetsBinding.instance.lifecycleState != - AppLifecycleState.resumed) { - _log.i( - 'Verification required for $normalizedExtensionId while app is in ' - 'background; deferring challenge until the app is foregrounded', - ); - await _waitForForeground(); - } - }, + Future _runVerificationFlow( + String extensionId, + Future cancellationSignal, + ) async { + if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) { + _log.i( + 'Verification required for $extensionId while app is in background; ' + 'deferring challenge until the app is foregrounded', ); - } finally { - _verificationFlowsByExtension.remove(key); + final reachedForeground = await _waitForForeground(cancellationSignal); + if (!reachedForeground) { + _log.i( + 'Verification wait for $extensionId was cancelled before the app returned to the foreground', + ); + return false; + } } + + return openVerificationAndAwaitGrant( + extensionId, + browserMode: ref.read(settingsProvider).extensionVerificationBrowserMode, + cancellationSignal: cancellationSignal, + ); } Future _handleVerificationRequiredDownload( @@ -99,7 +104,7 @@ extension _DownloadQueueVerificationGate on DownloadQueueNotifier { late final bool verified; try { - verified = await _openVerificationAndWait(targetService); + verified = await _openVerificationAndWait(item.id, targetService); } finally { try { await _notificationService.cancelVerificationRequired(); @@ -111,7 +116,13 @@ extension _DownloadQueueVerificationGate on DownloadQueueNotifier { } final current = _findItemById(item.id); if (current == null || _isLocallyCancelled(item.id, item: current)) { - _log.i('Verification completed after item was removed or cancelled'); + _log.i('Verification wait stopped after item was removed or cancelled'); + return true; + } + if (_isPausePending(item.id)) { + _requeueItemForPause(item.id); + _pausePendingItemIds.remove(item.id); + _log.i('Verification wait stopped because the queue was paused'); return true; } diff --git a/lib/providers/download_verification_retry_guard.dart b/lib/providers/download_verification_retry_guard.dart index 80a0ea40..983615db 100644 --- a/lib/providers/download_verification_retry_guard.dart +++ b/lib/providers/download_verification_retry_guard.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + class DownloadVerificationRetryGuard { final Set _grantedRetryKeys = {}; @@ -30,3 +32,107 @@ class DownloadVerificationRetryGuard { String _key(String itemId, String service) => '$itemId::${service.trim().toLowerCase()}'; } + +/// Shares one verification challenge between downloads for the same service +/// while still allowing each queue item to stop waiting independently. +/// +/// When the last waiter leaves, [startFlow]'s cancellation signal completes so +/// browser timers and grant subscriptions can be released immediately. +class DownloadVerificationWaitCoordinator { + final Map _flowsByService = {}; + final Map> _cancellationsByItem = {}; + + bool hasActiveFlow(String service) => + _flowsByService.containsKey(_serviceKey(service)); + + Future waitForGrant({ + required String itemId, + required String service, + required Future Function(Future cancellationSignal) startFlow, + }) { + final previousCancellation = _cancellationsByItem[itemId]; + if (previousCancellation != null && !previousCancellation.isCompleted) { + previousCancellation.complete(); + } + + final itemCancellation = Completer(); + _cancellationsByItem[itemId] = itemCancellation; + + final serviceKey = _serviceKey(service); + var activeFlow = _flowsByService[serviceKey]; + if (activeFlow == null) { + final flowCancellation = Completer(); + final result = Future.sync( + () => startFlow(flowCancellation.future), + ); + activeFlow = _ActiveDownloadVerificationFlow( + result: result, + cancellation: flowCancellation, + ); + _flowsByService[serviceKey] = activeFlow; + } + + final waiter = Object(); + activeFlow.waiters.add(waiter); + return _waitForGrant( + itemId: itemId, + serviceKey: serviceKey, + activeFlow: activeFlow, + waiter: waiter, + itemCancellation: itemCancellation, + ); + } + + Future _waitForGrant({ + required String itemId, + required String serviceKey, + required _ActiveDownloadVerificationFlow activeFlow, + required Object waiter, + required Completer itemCancellation, + }) async { + try { + return await Future.any([ + activeFlow.result, + itemCancellation.future.then((_) => false), + ]); + } finally { + if (identical(_cancellationsByItem[itemId], itemCancellation)) { + _cancellationsByItem.remove(itemId); + } + activeFlow.waiters.remove(waiter); + if (activeFlow.waiters.isEmpty && + identical(_flowsByService[serviceKey], activeFlow)) { + _flowsByService.remove(serviceKey); + if (!activeFlow.cancellation.isCompleted) { + activeFlow.cancellation.complete(); + } + } + } + } + + void cancelItem(String itemId) { + final cancellation = _cancellationsByItem[itemId]; + if (cancellation != null && !cancellation.isCompleted) { + cancellation.complete(); + } + } + + void cancelAll() { + for (final cancellation in _cancellationsByItem.values.toList()) { + if (!cancellation.isCompleted) cancellation.complete(); + } + } + + String _serviceKey(String service) => service.trim().toLowerCase(); +} + +class _ActiveDownloadVerificationFlow { + _ActiveDownloadVerificationFlow({ + required this.result, + required this.cancellation, + }); + + final Future result; + final Completer cancellation; + final Set waiters = {}; +} diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 8d07503f..77e0f50e 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -69,22 +69,30 @@ Future openPendingExtensionVerification( String extensionId, { String browserMode = 'in_app_first', void Function(Uri authUri)? onAuthUri, + Future? cancellationSignal, }) async { final normalizedExtensionId = extensionId.trim(); if (normalizedExtensionId.isEmpty) return false; try { - final pending = await PlatformBridge.getExtensionPendingAuth( - normalizedExtensionId, + final pending = await _awaitVerificationStepOrCancellation( + PlatformBridge.getExtensionPendingAuth(normalizedExtensionId), + cancellationSignal, ); - final authUrl = pending?['auth_url']?.toString().trim() ?? ''; + if (pending == null) return false; + final authUrl = pending['auth_url']?.toString().trim() ?? ''; if (authUrl.isEmpty) return false; final uri = Uri.tryParse(authUrl); if (uri == null) return false; onAuthUri?.call(uri); - final launched = await _launchVerificationUrl(uri, browserMode); + final launched = await _awaitVerificationStepOrCancellation( + _launchVerificationUrl(uri, browserMode), + cancellationSignal, + ); + + if (launched == null) return false; if (launched) { _log.i('Opened verification challenge for $normalizedExtensionId'); @@ -129,7 +137,9 @@ Timer? scheduleExtensionVerificationHelpDialog( } /// Opens a pending extension verification challenge and waits (up to 5 -/// minutes) for its grant result, returning whether it succeeded. +/// minutes) 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. /// /// [awaitForeground], if given, is awaited first — passed the trimmed /// [extensionId] — before opening the challenge; used by callers that must @@ -139,12 +149,17 @@ Future openVerificationAndAwaitGrant( String extensionId, { required String browserMode, Future Function(String extensionId)? awaitForeground, + Future? cancellationSignal, }) async { final normalizedExtensionId = extensionId.trim(); if (normalizedExtensionId.isEmpty) return false; if (awaitForeground != null) { - await awaitForeground(normalizedExtensionId); + final reachedForeground = await _awaitVerificationStepOrCancellation( + awaitForeground(normalizedExtensionId).then((_) => true), + cancellationSignal, + ); + if (reachedForeground != true) return false; } final grantCompleter = Completer(); @@ -161,12 +176,16 @@ Future openVerificationAndAwaitGrant( Timer? helpDialogTimer; try { - final opened = await openPendingExtensionVerification( - normalizedExtensionId, - browserMode: browserMode, - onAuthUri: (uri) => authUri = uri, + final opened = await _awaitVerificationStepOrCancellation( + openPendingExtensionVerification( + normalizedExtensionId, + browserMode: browserMode, + onAuthUri: (uri) => authUri = uri, + cancellationSignal: cancellationSignal, + ), + cancellationSignal, ); - if (!opened) return false; + if (opened != true) return false; helpDialogTimer = scheduleExtensionVerificationHelpDialog( normalizedExtensionId, @@ -174,9 +193,14 @@ Future openVerificationAndAwaitGrant( browserMode: browserMode, ); - final event = await grantCompleter.future.timeout( - const Duration(minutes: 5), - ); + final event = await _awaitVerificationStepOrCancellation( + grantCompleter.future, + cancellationSignal, + ).timeout(const Duration(minutes: 5)); + if (event == null) { + _log.i('Stopped waiting for verification grant: $normalizedExtensionId'); + return false; + } return event.success; } on TimeoutException { _log.w('Timed out waiting for verification grant: $normalizedExtensionId'); @@ -187,6 +211,19 @@ Future openVerificationAndAwaitGrant( } } +Future _awaitVerificationStepOrCancellation( + Future operation, + Future? cancellationSignal, +) { + if (cancellationSignal == null) { + return operation.then((value) => value); + } + return Future.any([ + operation.then((value) => value), + cancellationSignal.then((_) => null), + ]); +} + Future showExtensionVerificationHelpDialog( String extensionId, Uri authUri, { diff --git a/test/download_verification_retry_guard_test.dart b/test/download_verification_retry_guard_test.dart index 70ade08f..8d1d253a 100644 --- a/test/download_verification_retry_guard_test.dart +++ b/test/download_verification_retry_guard_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/providers/download_verification_retry_guard.dart'; @@ -45,4 +47,98 @@ void main() { expect(guard.hasRetriedAfterGrant('remaining', 'tidal-web'), isTrue); }); }); + + group('DownloadVerificationWaitCoordinator', () { + test( + 'cancelling an item releases its verification wait immediately', + () async { + final coordinator = DownloadVerificationWaitCoordinator(); + final flowStarted = Completer(); + final flowCancelled = Completer(); + + final result = coordinator.waitForGrant( + itemId: 'item-1', + service: 'tidal-web', + startFlow: (cancellationSignal) async { + flowStarted.complete(); + await cancellationSignal; + flowCancelled.complete(); + return false; + }, + ); + await flowStarted.future; + + coordinator.cancelItem('item-1'); + + expect(await result.timeout(const Duration(seconds: 1)), isFalse); + await flowCancelled.future.timeout(const Duration(seconds: 1)); + }, + ); + + test('a new item does not join an abandoned verification flow', () async { + final coordinator = DownloadVerificationWaitCoordinator(); + var starts = 0; + + final first = coordinator.waitForGrant( + itemId: 'item-1', + service: 'tidal-web', + startFlow: (cancellationSignal) async { + starts++; + await cancellationSignal; + return false; + }, + ); + coordinator.cancelItem('item-1'); + expect(await first, isFalse); + + final second = coordinator.waitForGrant( + itemId: 'item-2', + service: 'tidal-web', + startFlow: (_) async { + starts++; + return true; + }, + ); + + expect(await second, isTrue); + expect(starts, 2); + }); + + test( + 'cancelling one waiter keeps a shared flow alive for another', + () async { + final coordinator = DownloadVerificationWaitCoordinator(); + final grant = Completer(); + final flowCancelled = Completer(); + var starts = 0; + + Future startFlow(Future cancellationSignal) async { + starts++; + cancellationSignal.then((_) { + if (!flowCancelled.isCompleted) flowCancelled.complete(); + }); + return grant.future; + } + + final first = coordinator.waitForGrant( + itemId: 'item-1', + service: ' TIDAL-WEB ', + startFlow: startFlow, + ); + final second = coordinator.waitForGrant( + itemId: 'item-2', + service: 'tidal-web', + startFlow: startFlow, + ); + + coordinator.cancelItem('item-1'); + expect(await first, isFalse); + expect(flowCancelled.isCompleted, isFalse); + + grant.complete(true); + expect(await second, isTrue); + expect(starts, 1); + }, + ); + }); } diff --git a/test/extension_auth_launcher_test.dart b/test/extension_auth_launcher_test.dart index b16e9ca8..6ab632ce 100644 --- a/test/extension_auth_launcher_test.dart +++ b/test/extension_auth_launcher_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; @@ -21,4 +23,19 @@ void main() { 'qobuz-web', ); }); + + test('verification wait can be cancelled before foreground resume', () async { + final foreground = Completer(); + final cancellation = Completer(); + final result = openVerificationAndAwaitGrant( + 'tidal-web', + browserMode: 'in_app_first', + awaitForeground: (_) => foreground.future, + cancellationSignal: cancellation.future, + ); + + cancellation.complete(); + + expect(await result.timeout(const Duration(seconds: 1)), isFalse); + }); }