fix(download): unblock queue after verification cancel

This commit is contained in:
zarzet
2026-08-18 20:50:48 +07:00
parent 958e0db4e8
commit 8b231be19a
6 changed files with 317 additions and 47 deletions
+4 -1
View File
@@ -358,7 +358,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final Set<String> _pausePendingItemIds = {};
final DownloadVerificationRetryGuard _verificationRetryGuard =
DownloadVerificationRetryGuard();
final Map<String, Future<bool>> _verificationFlowsByExtension = {};
final DownloadVerificationWaitCoordinator _verificationWaitCoordinator =
DownloadVerificationWaitCoordinator();
final Set<String> _rateLimitRetriedItemIds = {};
String? _activeNativeWorkerRunId;
bool get _hasActiveAndroidNativeWorker =>
@@ -379,6 +380,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
ref.onDispose(() {
_verificationWaitCoordinator.cancelAll();
_progressPoller.stop();
_connectivitySub?.cancel();
_connectivitySub = null;
@@ -1015,6 +1017,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void _requestNativeCancel(String id) {
_verificationWaitCoordinator.cancelItem(id);
PlatformBridge.cancelDownload(id).catchError((_) {});
PlatformBridge.clearItemProgress(id).catchError((_) {});
}
@@ -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<void> _waitForForeground() async {
Future<bool> _waitForForeground(Future<void> cancellationSignal) async {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
return;
return true;
}
final completer = Completer<void>();
final listener = AppLifecycleListener(
@@ -16,48 +16,53 @@ extension _DownloadQueueVerificationGate on DownloadQueueNotifier {
},
);
try {
await completer.future;
return await Future.any<bool>([
completer.future.then((_) => true),
cancellationSignal.then((_) => false),
]);
} finally {
listener.dispose();
}
}
Future<bool> _openVerificationAndWait(String extensionId) {
final key = extensionId.trim().toLowerCase();
final activeFlow = _verificationFlowsByExtension[key];
if (activeFlow != null) {
Future<bool> _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<bool> _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<bool> _runVerificationFlow(
String extensionId,
Future<void> 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<bool> _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;
}
@@ -1,3 +1,5 @@
import 'dart:async';
class DownloadVerificationRetryGuard {
final Set<String> _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<String, _ActiveDownloadVerificationFlow> _flowsByService = {};
final Map<String, Completer<void>> _cancellationsByItem = {};
bool hasActiveFlow(String service) =>
_flowsByService.containsKey(_serviceKey(service));
Future<bool> waitForGrant({
required String itemId,
required String service,
required Future<bool> Function(Future<void> cancellationSignal) startFlow,
}) {
final previousCancellation = _cancellationsByItem[itemId];
if (previousCancellation != null && !previousCancellation.isCompleted) {
previousCancellation.complete();
}
final itemCancellation = Completer<void>();
_cancellationsByItem[itemId] = itemCancellation;
final serviceKey = _serviceKey(service);
var activeFlow = _flowsByService[serviceKey];
if (activeFlow == null) {
final flowCancellation = Completer<void>();
final result = Future<bool>.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<bool> _waitForGrant({
required String itemId,
required String serviceKey,
required _ActiveDownloadVerificationFlow activeFlow,
required Object waiter,
required Completer<void> itemCancellation,
}) async {
try {
return await Future.any<bool>([
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<bool> result;
final Completer<void> cancellation;
final Set<Object> waiters = {};
}
+51 -14
View File
@@ -69,22 +69,30 @@ Future<bool> openPendingExtensionVerification(
String extensionId, {
String browserMode = 'in_app_first',
void Function(Uri authUri)? onAuthUri,
Future<void>? 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<bool> openVerificationAndAwaitGrant(
String extensionId, {
required String browserMode,
Future<void> Function(String extensionId)? awaitForeground,
Future<void>? 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<ExtensionSessionGrantEvent>();
@@ -161,12 +176,16 @@ Future<bool> 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<bool> 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<bool> openVerificationAndAwaitGrant(
}
}
Future<T?> _awaitVerificationStepOrCancellation<T>(
Future<T> operation,
Future<void>? cancellationSignal,
) {
if (cancellationSignal == null) {
return operation.then<T?>((value) => value);
}
return Future.any<T?>([
operation.then<T?>((value) => value),
cancellationSignal.then<T?>((_) => null),
]);
}
Future<bool> showExtensionVerificationHelpDialog(
String extensionId,
Uri authUri, {
@@ -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<void>();
final flowCancelled = Completer<void>();
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<bool>();
final flowCancelled = Completer<void>();
var starts = 0;
Future<bool> startFlow(Future<void> 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);
},
);
});
}
+17
View File
@@ -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<void>();
final cancellation = Completer<void>();
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);
});
}