Files
SpotiFLAC-Mobile/lib/providers/download_queue_provider_verification.dart
T

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;
}
}