From 555fd77cc841d35d1b8e2183e68fec79d71e3a49 Mon Sep 17 00:00:00 2001 From: Abelardo Ramirez Date: Mon, 3 Aug 2026 09:45:02 -0600 Subject: [PATCH] fix(ios): stop dropping the security-scoped download folder bookmark Fixes #439, likely also #302. Reported: on iOS, the download folder silently reverts to the default SpotiFLAC folder after starting a download (and stays reverted after closing and reopening the app). _processQueue ran an iCloud/writable-path shape check against the persisted downloadDirectory string and, if it looked like iCloud Drive or failed the structural writable-path check, called setDownloadDirectory() to reset it to the default folder. That call passes no iosBookmark argument, so it also wipes the user's security-scoped bookmark in the persisted settings - permanently, not just for the current run. This check ran unconditionally, even when a bookmark was already present for that folder. A folder picked from Files can legitimately have a persisted path that looks like iCloud Drive, or that fails this generic structural check, while the bookmark itself still grants real write access - the bookmark is the actual source of truth, and the queue already has a separate, correct bookmark-resolution step right after this one (StartAccessingIosBookmark, with its own proper failure handling that fails the queued items with a clear message instead of destroying the setting). The earlier path-shape check just never deferred to it. lib/utils/file_access.dart: extract the run/skip decision into shouldValidateIosOutputDir(isIOS, isSafMode, outputDir, downloadDirectoryBookmark) - skips whenever a bookmark is present - and use it in download_queue_provider.dart in place of the inline condition. Kept as a plain function taking isIOS as a parameter since Platform.isIOS itself can't be exercised from a host test run. test/file_access_ios_test.dart covers: runs for a plain app-folder path, skips when a bookmark is present (the exact #439 case), skips off iOS, skips in SAF mode, and skips when there's no output dir yet. Verification: flutter analyze and flutter test (224 tests, all green, including the 5 new ones) both clean. --- lib/providers/download_queue_provider.dart | 11 +++- lib/utils/file_access.dart | 24 +++++++ test/file_access_ios_test.dart | 76 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 test/file_access_ios_test.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 186a1adb..71105155 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1470,8 +1470,15 @@ class DownloadQueueNotifier extends Notifier { await _initOutputDir(); } - // iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access) - if (!isSafMode && Platform.isIOS && state.outputDir.isNotEmpty) { + // iOS: Validate that outputDir is writable (not iCloud Drive which Go + // can't access), unless a bookmark makes this app-Documents path-shape + // check irrelevant (see shouldValidateIosOutputDir). + if (shouldValidateIosOutputDir( + isIOS: Platform.isIOS, + isSafMode: isSafMode, + outputDir: state.outputDir, + downloadDirectoryBookmark: settings.downloadDirectoryBookmark, + )) { final isICloudPath = state.outputDir.contains('Mobile Documents') || state.outputDir.contains('CloudDocs') || diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 4db8ace8..7251c8ca 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -6,6 +6,30 @@ import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; +/// Whether the queue should run its iOS output-directory path-shape check +/// (which can replace [outputDir] with the default Documents folder when the +/// path looks like iCloud Drive or an invalid container path). +/// +/// A security-scoped bookmark is the source of truth for a folder picked +/// from Files: its persisted path may legitimately contain substrings that +/// look like iCloud Drive, or fail the structural writable-path check, even +/// though the bookmark itself grants real write access. Running the +/// path-shape check anyway would reset - and via setDownloadDirectory, +/// permanently drop - a perfectly valid bookmarked folder. When a bookmark +/// is present, the queue's later bookmark-resolution step is authoritative +/// instead. +bool shouldValidateIosOutputDir({ + required bool isIOS, + required bool isSafMode, + required String outputDir, + required String downloadDirectoryBookmark, +}) { + return isIOS && + !isSafMode && + outputDir.isNotEmpty && + downloadDirectoryBookmark.isEmpty; +} + /// Regular expression to detect iOS app container paths. /// Matches paths like /var/mobile/Containers/Data/Application/{UUID} /// or /private/var/mobile/Containers/Data/Application/{UUID} diff --git a/test/file_access_ios_test.dart b/test/file_access_ios_test.dart new file mode 100644 index 00000000..fb6a1cb7 --- /dev/null +++ b/test/file_access_ios_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +void main() { + group('shouldValidateIosOutputDir', () { + test('validates a non-empty app-folder path on iOS', () { + expect( + shouldValidateIosOutputDir( + isIOS: true, + isSafMode: false, + outputDir: + '/private/var/mobile/Containers/Data/Application/ABC/Documents/SpotiFLAC', + downloadDirectoryBookmark: '', + ), + isTrue, + ); + }); + + test( + 'skips validation when a security-scoped bookmark is present (#439)', + () { + // The persisted path may look like iCloud Drive or fail the + // structural writable-path check even though the bookmark grants + // real write access - the bookmark, not this path shape, is + // authoritative. Running the check anyway is what silently reset + // and dropped the user's chosen folder. + expect( + shouldValidateIosOutputDir( + isIOS: true, + isSafMode: false, + outputDir: + '/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Music', + downloadDirectoryBookmark: 'bookmark-bytes', + ), + isFalse, + ); + }, + ); + + test('skips validation off iOS', () { + expect( + shouldValidateIosOutputDir( + isIOS: false, + isSafMode: false, + outputDir: '/data/user/0/com.zarz.spotiflac/files/SpotiFLAC', + downloadDirectoryBookmark: '', + ), + isFalse, + ); + }); + + test('skips validation in SAF mode', () { + expect( + shouldValidateIosOutputDir( + isIOS: true, + isSafMode: true, + outputDir: '/some/path', + downloadDirectoryBookmark: '', + ), + isFalse, + ); + }); + + test('skips validation when there is no output directory yet', () { + expect( + shouldValidateIosOutputDir( + isIOS: true, + isSafMode: false, + outputDir: '', + downloadDirectoryBookmark: '', + ), + isFalse, + ); + }); + }); +}