fix(download): preserve SAF and defer background starts

This commit is contained in:
zarzet
2026-08-09 05:23:47 +07:00
parent 2612da81c3
commit a59c749089
9 changed files with 271 additions and 33 deletions
+5
View File
@@ -261,6 +261,11 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_maybeAutoScanLocalLibrary();
if (ref.exists(downloadQueueProvider)) {
ref
.read(downloadQueueProvider.notifier)
.resumePendingDownloadsOnForeground();
}
} else if (state == AppLifecycleState.paused) {
// Last reliable moment before the OS may kill the process: make sure
// any debounced download-queue persistence reaches disk.
+77 -19
View File
@@ -62,6 +62,18 @@ const String safPermissionLostErrorMessage =
const String downloadFolderAccessLostErrorMessage =
'Download folder access lost. Please re-select your download folder in Settings.';
enum StorageWriteRecovery { requestSafAccess, useAppFolderFallback }
StorageWriteRecovery storageWriteRecoveryFor({required bool useSaf}) {
return useSaf
? StorageWriteRecovery.requestSafAccess
: StorageWriteRecovery.useAppFolderFallback;
}
bool canStartForegroundDownloadForLifecycle(AppLifecycleState? lifecycleState) {
return lifecycleState == AppLifecycleState.resumed;
}
/// Keeps a download in its finalizing state until its durable Library record
/// has been written. If persistence fails, completion is deliberately not
/// published so the queue can surface the error instead of losing the file
@@ -179,6 +191,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _failedInSession = 0;
int _queueItemSequence = 0;
bool _isLoaded = false;
bool _foregroundResumeScheduled = false;
final Set<String> _ensuredDirs = {};
final Map<String, Future<void>> _qualityVariantFileLocks = {};
Future<String>? _appFolderStorageFallback;
@@ -279,6 +292,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await _queuePersistenceWrite;
}
/// Restarts a queue that was deliberately left pending because Android did
/// not allow a foreground service to be launched while the app was hidden.
void resumePendingDownloadsOnForeground() {
if (_foregroundResumeScheduled ||
state.isProcessing ||
state.isPaused ||
!state.items.any((item) => item.status == DownloadStatus.queued)) {
return;
}
_foregroundResumeScheduled = true;
Future.microtask(() async {
_foregroundResumeScheduled = false;
if (!state.isProcessing &&
!state.isPaused &&
state.items.any((item) => item.status == DownloadStatus.queued)) {
await _processQueue();
}
});
}
Future<void> _loadQueueFromStorage() async {
if (_isLoaded) return;
_isLoaded = true;
@@ -1239,14 +1272,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<void> _processQueue() async {
if (state.isProcessing) return;
if (Platform.isAndroid &&
state.items.any((item) => item.status == DownloadStatus.queued) &&
!canStartForegroundDownloadForLifecycle(
WidgetsBinding.instance.lifecycleState,
)) {
_log.i(
'Download queue is waiting for the app to return to the foreground',
);
return;
}
var settings = ref.read(settingsProvider);
updateSettings(settings);
var isSafMode = _isSafMode(settings);
var iosDownloadBookmarkActive = false;
// Validate SAF before handing the batch to either queue implementation.
// A restored/missing tree URI and an OEM-revoked grant both fall back to a
// verified writable app folder instead of failing the whole queue.
// Never silently redirect a user-selected SAF destination into private app
// storage: keep the selection intact so the UI can request access again.
if (Platform.isAndroid && settings.storageMode == 'saf') {
var safAccessible = settings.downloadTreeUri.isNotEmpty;
if (safAccessible) {
@@ -1261,26 +1305,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (!safAccessible) {
_log.w(
'SAF grant is missing or no longer writable; using app-folder fallback',
'SAF grant is missing or no longer writable; download location must be reselected',
);
try {
await _activateAppFolderStorageFallback();
settings = ref.read(settingsProvider);
isSafMode = false;
} catch (e) {
_log.e('Could not activate app-folder storage fallback: $e');
for (final item in state.items) {
if (item.status == DownloadStatus.queued) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: safPermissionLostErrorMessage,
errorType: DownloadErrorType.permission,
);
}
for (final item in state.items) {
if (item.status == DownloadStatus.queued) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: safPermissionLostErrorMessage,
errorType: DownloadErrorType.permission,
);
}
return;
}
return;
}
}
if (Platform.isAndroid &&
@@ -1320,6 +1357,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// Native request construction can perform asynchronous metadata lookups.
// The app may have moved to the background while those were running.
if (Platform.isAndroid &&
!canStartForegroundDownloadForLifecycle(
WidgetsBinding.instance.lifecycleState,
)) {
_log.i(
'Download queue moved to the background during preparation; deferring start',
);
_stopConnectivityMonitoring();
return;
}
state = state.copyWith(isProcessing: true);
_log.i('Starting queue processing...');
@@ -1343,6 +1393,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
_log.d('Foreground service started');
} catch (e) {
if (isForegroundServiceStartNotAllowed(e)) {
_log.w(
'Android deferred the download service start until the app returns to the foreground',
);
state = state.copyWith(isProcessing: false, currentDownload: null);
_stopConnectivityMonitoring();
return;
}
_log.e('Failed to start foreground service: $e');
}
}
@@ -47,14 +47,40 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
return false;
}
final failedOutputDir = context.storageMode == 'saf'
? null
: context.outputDir;
if (storageWriteRecoveryFor(useSaf: context.storageMode == 'saf') ==
StorageWriteRecovery.requestSafAccess) {
_log.w(
'Native worker lost SAF access; cancelling the run without changing the selected destination',
);
try {
await PlatformBridge.cancelNativeDownloadWorker();
} catch (e) {
_log.w('Failed to cancel native worker after SAF access loss: $e');
}
for (final pendingId in contexts.keys) {
if (reconciledIds.contains(pendingId)) continue;
final pending = _findItemById(pendingId);
if (pending == null ||
pending.status == DownloadStatus.completed ||
pending.status == DownloadStatus.skipped) {
continue;
}
reconciledIds.add(pendingId);
updateItemStatus(
pendingId,
DownloadStatus.failed,
error: safPermissionLostErrorMessage,
errorType: DownloadErrorType.permission,
);
_failedInSession++;
}
return true;
}
final fallbackRoot = await _activateAppFolderStorageFallback(
failedOutputDir: failedOutputDir,
failedOutputDir: context.outputDir,
);
if (context.storageMode != 'saf' &&
_pathIsInside(context.outputDir, fallbackRoot)) {
if (_pathIsInside(context.outputDir, fallbackRoot)) {
// This request already used the verified fallback. Do not create an
// infinite retry loop if the failure has another device-specific cause.
return false;
@@ -557,6 +583,16 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
});
}
if (!canStartForegroundDownloadForLifecycle(
WidgetsBinding.instance.lifecycleState,
)) {
_log.i(
'Native worker preparation finished after the app entered the background; deferring start',
);
_stopConnectivityMonitoring();
return true;
}
state = state.copyWith(isProcessing: true, isPaused: false);
_totalQueuedAtStart = queuedItems.length;
_completedInSession = 0;
@@ -608,6 +644,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
await Future<void>.delayed(const Duration(seconds: 1));
}
} catch (e, stack) {
if (isForegroundServiceStartNotAllowed(e)) {
_log.w(
'Android rejected the native worker start while backgrounded; keeping the queue pending',
);
await _clearNativeWorkerRunId(runId);
state = state.copyWith(isProcessing: false, currentDownload: null);
return true;
}
if (e is _NativeWorkerStartupTimeout) {
_log.w(
'Android native worker did not publish a matching snapshot; cancelling native worker and falling back to Dart queue',
@@ -574,6 +574,19 @@ class _DownloadRun {
_log.i('Download was cancelled before storage fallback, skipping');
return false;
}
if (storageWriteRecoveryFor(useSaf: effectiveSafMode) ==
StorageWriteRecovery.requestSafAccess) {
_log.w(
'SAF write failed; preserving the selected destination for reauthorization',
);
result = {
...result,
'success': false,
'error': safPermissionLostErrorMessage,
'error_type': 'permission',
};
return true;
}
_log.w('Storage write failed, retrying with a writable app folder');
try {
await n._activateAppFolderStorageFallback(
@@ -1679,14 +1692,17 @@ class _DownloadRun {
String errorMsg = e.toString();
DownloadErrorType errorType = DownloadErrorType.unknown;
if (isStorageWriteFailure(errorMessage: errorMsg)) {
if (isStorageWriteFailure(errorMessage: errorMsg) &&
storageWriteRecoveryFor(useSaf: n._isSafMode(settings)) ==
StorageWriteRecovery.requestSafAccess) {
errorMsg = safPermissionLostErrorMessage;
errorType = DownloadErrorType.permission;
} else if (isStorageWriteFailure(errorMessage: errorMsg)) {
try {
await n._activateAppFolderStorageFallback(
failedOutputDir: n._isSafMode(settings)
? null
: (effectiveOutputDir.isNotEmpty
? effectiveOutputDir
: n.state.outputDir),
failedOutputDir: effectiveOutputDir.isNotEmpty
? effectiveOutputDir
: n.state.outputDir,
);
n.updateItemStatus(item.id, DownloadStatus.queued, progress: 0.0);
_log.w(
@@ -1701,7 +1717,9 @@ class _DownloadRun {
}
}
if (errorMsg.contains('could not find Deezer equivalent') ||
if (errorType == DownloadErrorType.permission) {
// Keep the explicit SAF reauthorization error selected above.
} else if (errorMsg.contains('could not find Deezer equivalent') ||
errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
errorType = DownloadErrorType.notFound;
+8
View File
@@ -11,6 +11,14 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlatformBridge');
const foregroundServiceStartNotAllowedCode =
'foreground_service_start_not_allowed';
bool isForegroundServiceStartNotAllowed(Object error) {
return error is PlatformException &&
error.code == foregroundServiceStartNotAllowedCode;
}
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
class ExtensionSessionGrantEvent {