fix(storage): recover from unwritable download folders

This commit is contained in:
zarzet
2026-07-29 02:03:09 +07:00
parent 662558e3e6
commit 8dcf7fa998
9 changed files with 489 additions and 64 deletions
+83 -26
View File
@@ -83,6 +83,28 @@ double nativeWorkerFinalizingProgress(double progress) {
return min(normalized, 0.99);
}
/// Whether a backend failure means the selected destination could not be
/// written. This intentionally stays provider-agnostic: once an output path is
/// unwritable, trying more audio providers against that same path only wastes
/// time and hides the actionable storage failure behind a later network error.
bool isStorageWriteFailure({String? errorType, String? errorMessage}) {
if (errorType?.trim().toLowerCase() == 'permission') return true;
final message = errorMessage?.trim().toLowerCase() ?? '';
if (message.isEmpty) return false;
return message.contains('operation not permitted') ||
message.contains('permission denied') ||
message.contains('read-only file system') ||
message.contains('failed to create file') ||
message.contains('failed to create directory') ||
message.contains('failed to access saf directory') ||
message.contains('failed to create saf file') ||
message.contains('failed to open saf file') ||
message.contains('failed to publish saf') ||
message.contains('failed to publish deferred saf') ||
message.contains('failed to copy extension output to saf');
}
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
@@ -154,6 +176,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _queueItemSequence = 0;
bool _isLoaded = false;
final Set<String> _ensuredDirs = {};
Future<String>? _appFolderStorageFallback;
final Set<String> _rejectedAppFolderRoots = {};
int _idleProgressPollTick = 0;
bool _networkPausedByWifiOnly = false;
List<ConnectivityResult>? _lastConnectivityResults;
@@ -1187,10 +1211,67 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<void> _processQueue() async {
if (state.isProcessing) return;
final settings = ref.read(settingsProvider);
var settings = ref.read(settingsProvider);
updateSettings(settings);
final isSafMode = _isSafMode(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.
if (Platform.isAndroid && settings.storageMode == 'saf') {
var safAccessible = settings.downloadTreeUri.isNotEmpty;
if (safAccessible) {
try {
safAccessible = await PlatformBridge.validateSafTreeAccess(
settings.downloadTreeUri,
);
} catch (e) {
safAccessible = false;
_log.w('SAF access validation threw an error: $e');
}
}
if (!safAccessible) {
_log.w(
'SAF grant is missing or no longer writable; using app-folder fallback',
);
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,
);
}
}
return;
}
}
}
if (Platform.isAndroid &&
!isSafMode &&
state.outputDir.isNotEmpty &&
!await _isDirectoryWritable(Directory(state.outputDir))) {
final failedOutputDir = state.outputDir;
_log.w(
'Configured app-folder path is not writable; resolving a safe fallback',
);
try {
await _activateAppFolderStorageFallback(
failedOutputDir: failedOutputDir,
);
settings = ref.read(settingsProvider);
} catch (e) {
_log.e('No writable app-folder fallback is available: $e');
}
}
if (settings.downloadNetworkMode == 'wifi_only') {
final connectivityResult = await Connectivity().checkConnectivity();
final hasWifi = connectivityResult.contains(ConnectivityResult.wifi);
@@ -1284,30 +1365,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Output directory: ${state.outputDir}');
} else {
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
try {
final accessible = await PlatformBridge.validateSafTreeAccess(
settings.downloadTreeUri,
);
if (!accessible) {
throw const FileSystemException(
'Persisted SAF tree grant is missing or no longer writable',
);
}
} catch (e) {
_log.e('SAF permission validation failed: $e');
_log.w('SAF tree URI may be invalid or permission revoked');
for (final item in state.items) {
if (item.status == DownloadStatus.queued) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: safPermissionLostErrorMessage,
);
}
}
state = state.copyWith(isProcessing: false);
return;
}
}
if (!isSafMode &&
@@ -31,6 +31,57 @@ class _NativeWorkerStartupTimeout implements Exception {
}
extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
Future<bool> _recoverNativeWorkerStorageFailure({
required _NativeWorkerRequestContext context,
required String errorType,
required String errorMessage,
required Map<String, _NativeWorkerRequestContext> contexts,
required Set<String> reconciledIds,
}) async {
if (!isStorageWriteFailure(
errorType: errorType,
errorMessage: errorMessage,
)) {
return false;
}
final failedOutputDir = context.storageMode == 'saf'
? null
: context.outputDir;
final fallbackRoot = await _activateAppFolderStorageFallback(
failedOutputDir: failedOutputDir,
);
if (context.storageMode != 'saf' &&
_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;
}
_log.w(
'Native worker could not write its destination; cancelling this run '
'and retrying pending items in $fallbackRoot',
);
try {
await PlatformBridge.cancelNativeDownloadWorker();
} catch (e) {
_log.w('Failed to cancel native worker for storage fallback: $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.queued, progress: 0.0);
}
return true;
}
Future<void> _persistNativeFinalizedHistoryFallback(
_NativeWorkerRequestContext context,
Map<String, dynamic> result,
@@ -844,10 +895,10 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
}
if (status == 'failed' || status == 'skipped') {
reconciledIds.add(itemId);
final result = itemSnapshot['result'];
final error = itemSnapshot['error']?.toString();
if (status == 'skipped') {
reconciledIds.add(itemId);
updateItemStatus(itemId, DownloadStatus.skipped);
} else {
final resultMap = result is Map
@@ -864,6 +915,20 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
final errorType = backendErrorType == DownloadErrorType.unknown
? _downloadErrorTypeFromMessage(errorMsg)
: backendErrorType;
try {
if (await _recoverNativeWorkerStorageFailure(
context: context,
errorType: resultMap?['error_type']?.toString() ?? '',
errorMessage: errorMsg,
contexts: contexts,
reconciledIds: reconciledIds,
)) {
continue;
}
} catch (e) {
_log.e('Could not recover native-worker storage failure: $e');
}
reconciledIds.add(itemId);
if (errorType == DownloadErrorType.verificationRequired) {
_log.i(
'Android native worker requires verification for ${current.track.name}; switching back to interactive queue',
@@ -32,6 +32,123 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
return musicDir;
}
Future<Directory?> _ensureAndroidAppSpecificOutputDir() async {
final externalDir = await getExternalStorageDirectory();
if (externalDir == null) return null;
final outputDir = Directory(
'${externalDir.path}${Platform.pathSeparator}$_defaultOutputFolderName',
);
if (!await outputDir.exists()) {
await outputDir.create(recursive: true);
}
return outputDir;
}
bool _pathIsInside(String path, String directory) {
String normalize(String value) =>
value.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), '');
final normalizedPath = normalize(path);
final normalizedDirectory = normalize(directory);
return normalizedPath == normalizedDirectory ||
normalizedPath.startsWith('$normalizedDirectory/');
}
Future<bool> _isDirectoryWritable(Directory directory) async {
File? probe;
try {
if (!await directory.exists()) {
await directory.create(recursive: true);
}
probe = File(
'${directory.path}${Platform.pathSeparator}'
'.spotiflac-write-${DateTime.now().microsecondsSinceEpoch}',
);
await probe.writeAsBytes(const [0], flush: true);
return await probe.exists();
} catch (_) {
return false;
} finally {
if (probe != null) {
try {
if (await probe.exists()) await probe.delete();
} catch (_) {}
}
}
}
Future<Directory> _findWritableAppFolder({String? failedOutputDir}) async {
final candidates = <Future<Directory?> Function()>[
if (Platform.isAndroid) _ensureDefaultAndroidMusicOutputDir,
if (Platform.isAndroid) _ensureAndroidAppSpecificOutputDir,
() async => _ensureDefaultDocumentsOutputDir(),
];
for (final resolveCandidate in candidates) {
try {
final candidate = await resolveCandidate();
if (candidate == null) continue;
if (_rejectedAppFolderRoots.contains(candidate.path)) continue;
if (failedOutputDir != null &&
failedOutputDir.isNotEmpty &&
_pathIsInside(failedOutputDir, candidate.path)) {
_rejectedAppFolderRoots.add(candidate.path);
continue;
}
if (await _isDirectoryWritable(candidate)) return candidate;
} catch (e) {
_log.w('App-folder fallback candidate is not writable: $e');
}
}
throw const FileSystemException(
'No writable app-managed download folder is available',
);
}
/// Switches future requests to a verified writable app-managed directory.
/// Concurrent SAF failures share one resolution so an album download does
/// not run several write probes or race settings updates.
Future<String> _activateAppFolderStorageFallback({
String? failedOutputDir,
}) async {
final existing = _appFolderStorageFallback;
if (existing != null) {
final existingPath = await existing;
if (failedOutputDir == null ||
failedOutputDir.isEmpty ||
!_pathIsInside(failedOutputDir, existingPath)) {
return existingPath;
}
_rejectedAppFolderRoots.add(existingPath);
_appFolderStorageFallback = null;
}
final fallback = () async {
final directory = await _findWritableAppFolder(
failedOutputDir: failedOutputDir,
);
state = state.copyWith(outputDir: directory.path);
_ensuredDirs.add(directory.path);
await ref
.read(settingsProvider.notifier)
.useAppFolderStorage(directory.path);
_log.w(
'Storage access failed; switched downloads to app folder: '
'${directory.path}',
);
return directory.path;
}();
_appFolderStorageFallback = fallback;
try {
return await fallback;
} catch (_) {
if (identical(_appFolderStorageFallback, fallback)) {
_appFolderStorageFallback = null;
}
rethrow;
}
}
Future<void> _initOutputDir() async {
if (state.outputDir.isEmpty) {
try {
@@ -5,15 +5,11 @@ part of 'download_queue_provider.dart';
/// the native download call, decrypt/convert/embed finalization, and the
/// history hand-off. Queue scheduling stays in the main file.
extension _SingleItemDownload on DownloadQueueNotifier {
bool _isSafWriteFailure(Map<String, dynamic> result) {
final error = (result['error'] ?? result['message'] ?? '')
.toString()
.toLowerCase();
if (error.isEmpty) return false;
return error.contains('saf') ||
error.contains('content uri') ||
error.contains('permission denied') ||
error.contains('documentfile');
bool _isStorageWriteFailure(Map<String, dynamic> result) {
return isStorageWriteFailure(
errorType: result['error_type']?.toString(),
errorMessage: (result['error'] ?? result['message'])?.toString(),
);
}
Future<String?> _runPostProcessingHooks(String filePath, Track track) async {
@@ -568,38 +564,43 @@ class _DownloadRun {
outputDir: effectiveOutputDir,
);
if (effectiveSafMode &&
result['success'] != true &&
n._isSafWriteFailure(result)) {
if (result['success'] != true && n._isStorageWriteFailure(result)) {
if (n._isLocallyCancelled(item.id)) {
_log.i('Download was cancelled before SAF fallback, skipping');
_log.i('Download was cancelled before storage fallback, skipping');
return false;
}
_log.w('SAF write failed, retrying with app-private storage');
final fallbackDir =
appOutputDir ??
await n._buildOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
);
appOutputDir = fallbackDir;
final fallbackResult = await _invokeDownload(
useSaf: false,
outputDir: fallbackDir,
);
if (fallbackResult['success'] == true) {
effectiveSafMode = false;
effectiveOutputDir = fallbackDir;
finalSafFileName = null;
_log.w('Storage write failed, retrying with a writable app folder');
try {
await n._activateAppFolderStorageFallback(
failedOutputDir: effectiveSafMode ? null : effectiveOutputDir,
);
final fallbackDir =
appOutputDir ??
await n._buildOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
);
appOutputDir = fallbackDir;
final fallbackResult = await _invokeDownload(
useSaf: false,
outputDir: fallbackDir,
);
result = fallbackResult;
if (fallbackResult['success'] == true) {
effectiveSafMode = false;
effectiveOutputDir = fallbackDir;
finalSafFileName = null;
}
} catch (e) {
_log.e('App-folder storage fallback failed: $e');
}
}
return true;
@@ -1672,6 +1673,28 @@ class _DownloadRun {
String errorMsg = e.toString();
DownloadErrorType errorType = DownloadErrorType.unknown;
if (isStorageWriteFailure(errorMessage: errorMsg)) {
try {
await n._activateAppFolderStorageFallback(
failedOutputDir: n._isSafMode(settings)
? null
: (effectiveOutputDir.isNotEmpty
? effectiveOutputDir
: n.state.outputDir),
);
n.updateItemStatus(item.id, DownloadStatus.queued, progress: 0.0);
_log.w(
'Storage exception recovered; re-queued ${item.track.name} in the app folder',
);
return;
} catch (fallbackError) {
_log.e(
'Could not recover storage exception with app-folder fallback: '
'$fallbackError',
);
}
}
if (errorMsg.contains('could not find Deezer equivalent') ||
errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
+15
View File
@@ -460,6 +460,21 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
/// Atomically leaves SAF and persists a writable app-managed destination.
///
/// Keeping these fields in one update avoids an intermediate saved state
/// where app-folder mode still points at the SAF display name, or where an
/// invalid tree URI can switch the mode back to SAF.
Future<void> useAppFolderStorage(String directory) async {
state = state.copyWith(
storageMode: 'app',
downloadDirectory: directory,
downloadDirectoryBookmark: '',
downloadTreeUri: '',
);
await _saveSettings();
}
void setDownloadTreeUri(String uri, {String? displayName}) {
final nextDisplay = displayName ?? state.downloadDirectory;
state = state.copyWith(