mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 13:22:49 +02:00
fix(download): preserve SAF and defer background starts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user