mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
fix(download): unblock queue after verification cancel
This commit is contained in:
@@ -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 = {};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user