mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 21:30:23 +02:00
214 lines
6.6 KiB
Dart
214 lines
6.6 KiB
Dart
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
|
part of 'download_queue_provider.dart';
|
|
|
|
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<bool> _waitForForeground(Future<void> cancellationSignal) async {
|
|
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
|
|
return true;
|
|
}
|
|
final completer = Completer<void>();
|
|
final listener = AppLifecycleListener(
|
|
onResume: () {
|
|
if (!completer.isCompleted) completer.complete();
|
|
},
|
|
);
|
|
try {
|
|
return await Future.any<bool>([
|
|
completer.future.then((_) => true),
|
|
cancellationSignal.then((_) => false),
|
|
]);
|
|
} finally {
|
|
listener.dispose();
|
|
}
|
|
}
|
|
|
|
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 _verificationWaitCoordinator.waitForGrant(
|
|
itemId: itemId,
|
|
service: extensionId,
|
|
startFlow: (cancellationSignal) =>
|
|
_runVerificationFlow(extensionId, cancellationSignal),
|
|
);
|
|
}
|
|
|
|
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',
|
|
);
|
|
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(
|
|
DownloadItem item,
|
|
String errorMsg,
|
|
String? verificationService,
|
|
) async {
|
|
final targetService = (verificationService ?? '').trim().isNotEmpty
|
|
? verificationService!.trim()
|
|
: item.service;
|
|
if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) {
|
|
_log.e(
|
|
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
|
|
);
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: errorMsg,
|
|
errorType: DownloadErrorType.verificationRequired,
|
|
);
|
|
_failedInSession++;
|
|
return true;
|
|
}
|
|
_log.i(
|
|
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
|
|
);
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.downloading,
|
|
error: 'Waiting for verification',
|
|
errorType: DownloadErrorType.verificationRequired,
|
|
);
|
|
|
|
try {
|
|
await _notificationService.showVerificationRequired();
|
|
} catch (error) {
|
|
_log.w('Failed to show the verification-required notification: $error');
|
|
}
|
|
|
|
late final bool verified;
|
|
try {
|
|
verified = await _openVerificationAndWait(item.id, targetService);
|
|
} finally {
|
|
try {
|
|
await _notificationService.cancelVerificationRequired();
|
|
} catch (error) {
|
|
_log.w(
|
|
'Failed to clear the verification-required notification: $error',
|
|
);
|
|
}
|
|
}
|
|
final current = _findItemById(item.id);
|
|
if (current == null || _isLocallyCancelled(item.id, item: current)) {
|
|
_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;
|
|
}
|
|
|
|
// Only a completed grant consumes the automatic retry. If bootstrap or
|
|
// browser launch failed before a challenge opened, a later attempt must
|
|
// still be allowed after the network changes.
|
|
_verificationRetryGuard.recordVerificationResult(
|
|
item.id,
|
|
targetService,
|
|
granted: verified,
|
|
);
|
|
if (verified) {
|
|
_log.i(
|
|
'Verification complete for $targetService; retrying ${item.track.name}',
|
|
);
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.queued,
|
|
progress: 0,
|
|
speedMBps: 0,
|
|
error: 'Retrying after verification',
|
|
errorType: DownloadErrorType.verificationRequired,
|
|
);
|
|
_saveQueueToStorage();
|
|
return true;
|
|
}
|
|
|
|
_log.e('Verification did not complete for $targetService');
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: errorMsg,
|
|
errorType: DownloadErrorType.verificationRequired,
|
|
);
|
|
_failedInSession++;
|
|
return true;
|
|
}
|
|
|
|
Duration _rateLimitBackoffDelay(String errorMsg) {
|
|
final lower = errorMsg.toLowerCase();
|
|
final retryAfterMatch = RegExp(
|
|
r'retry[- ]?after(?: seconds)?[:= ]+(\d+)',
|
|
caseSensitive: false,
|
|
).firstMatch(lower);
|
|
final parsedSeconds = retryAfterMatch == null
|
|
? null
|
|
: int.tryParse(retryAfterMatch.group(1) ?? '');
|
|
final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt();
|
|
return Duration(seconds: seconds);
|
|
}
|
|
|
|
Future<bool> _handleRateLimitedDownload(
|
|
DownloadItem item,
|
|
String errorMsg,
|
|
) async {
|
|
if (_rateLimitRetriedItemIds.contains(item.id)) {
|
|
return false;
|
|
}
|
|
_rateLimitRetriedItemIds.add(item.id);
|
|
|
|
final delay = _rateLimitBackoffDelay(errorMsg);
|
|
_log.i(
|
|
'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s',
|
|
);
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.downloading,
|
|
error: 'Rate limited, retrying after ${delay.inSeconds}s',
|
|
errorType: DownloadErrorType.rateLimit,
|
|
);
|
|
|
|
await Future<void>.delayed(delay);
|
|
final current = _findItemById(item.id);
|
|
if (current == null || _isLocallyCancelled(item.id, item: current)) {
|
|
return true;
|
|
}
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.queued,
|
|
progress: 0,
|
|
speedMBps: 0,
|
|
error: 'Retrying after rate limit',
|
|
errorType: DownloadErrorType.rateLimit,
|
|
);
|
|
_saveQueueToStorage();
|
|
return true;
|
|
}
|
|
}
|