fix(ios): use security-scoped bookmarks for download directory persistence

- Switch iOS bookmark creation from .minimalBookmark to .withSecurityScope
- Add .withSecurityScope option when resolving bookmarks
- Add downloadDirectoryBookmark field to AppSettings for persisting iOS bookmarks
- Resolve bookmark and startAccessingIosBookmark before queue processing
- Guarantee stopAccessingIosBookmark cleanup via try/finally
- Create bookmark on folder pick in both setup screen and download settings
- Clear bookmark when switching to SAF mode or iOS path normalization
- Fix stale bottom sheet context usage (ctx -> context) in download settings
This commit is contained in:
zarzet committed 2026-05-02 01:19:39 +07:00
1 parent 64dbf4441c
commit 45fa33e1ec
7 files changed
+161 -41

No files matched your search

+3 -3
View File
@@ -1021,7 +1021,7 @@ import Gobackend // Import Go framework
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)
do { do {
let bookmarkData = try url.bookmarkData( let bookmarkData = try url.bookmarkData(
options: .minimalBookmark, options: .withSecurityScope,
includingResourceValuesForKeys: nil, includingResourceValuesForKeys: nil,
relativeTo: nil relativeTo: nil
) )
@@ -1051,7 +1051,7 @@ import Gobackend // Import Go framework
do { do {
url = try URL( url = try URL(
resolvingBookmarkData: bookmarkData, resolvingBookmarkData: bookmarkData,
options: [], options: .withSecurityScope,
relativeTo: nil, relativeTo: nil,
bookmarkDataIsStale: &isStale bookmarkDataIsStale: &isStale
) )
@@ -1086,7 +1086,7 @@ import Gobackend // Import Go framework
do { do {
url = try URL( url = try URL(
resolvingBookmarkData: bookmarkData, resolvingBookmarkData: bookmarkData,
options: [], options: .withSecurityScope,
relativeTo: nil, relativeTo: nil,
bookmarkDataIsStale: &isStale bookmarkDataIsStale: &isStale
) )
+5
View File
@@ -11,6 +11,7 @@ class AppSettings {
final String audioQuality; final String audioQuality;
final String filenameFormat; final String filenameFormat;
final String downloadDirectory; final String downloadDirectory;
final String downloadDirectoryBookmark;
final String storageMode; // 'app' or 'saf' final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback; final bool autoFallback;
@@ -89,6 +90,7 @@ class AppSettings {
this.audioQuality = 'LOSSLESS', this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}', this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '', this.downloadDirectory = '',
this.downloadDirectoryBookmark = '',
this.storageMode = 'app', this.storageMode = 'app',
this.downloadTreeUri = '', this.downloadTreeUri = '',
this.autoFallback = true, this.autoFallback = true,
@@ -153,6 +155,7 @@ class AppSettings {
String? audioQuality, String? audioQuality,
String? filenameFormat, String? filenameFormat,
String? downloadDirectory, String? downloadDirectory,
String? downloadDirectoryBookmark,
String? storageMode, String? storageMode,
String? downloadTreeUri, String? downloadTreeUri,
bool? autoFallback, bool? autoFallback,
@@ -213,6 +216,8 @@ class AppSettings {
audioQuality: audioQuality ?? this.audioQuality, audioQuality: audioQuality ?? this.audioQuality,
filenameFormat: filenameFormat ?? this.filenameFormat, filenameFormat: filenameFormat ?? this.filenameFormat,
downloadDirectory: downloadDirectory ?? this.downloadDirectory, downloadDirectory: downloadDirectory ?? this.downloadDirectory,
downloadDirectoryBookmark:
downloadDirectoryBookmark ?? this.downloadDirectoryBookmark,
storageMode: storageMode ?? this.storageMode, storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback, autoFallback: autoFallback ?? this.autoFallback,
+2
View File
@@ -11,6 +11,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS', audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}', filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '', downloadDirectory: json['downloadDirectory'] as String? ?? '',
downloadDirectoryBookmark: json['downloadDirectoryBookmark'] as String? ?? '',
storageMode: json['storageMode'] as String? ?? 'app', storageMode: json['storageMode'] as String? ?? 'app',
downloadTreeUri: json['downloadTreeUri'] as String? ?? '', downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true, autoFallback: json['autoFallback'] as bool? ?? true,
@@ -86,6 +87,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'audioQuality': instance.audioQuality, 'audioQuality': instance.audioQuality,
'filenameFormat': instance.filenameFormat, 'filenameFormat': instance.filenameFormat,
'downloadDirectory': instance.downloadDirectory, 'downloadDirectory': instance.downloadDirectory,
'downloadDirectoryBookmark': instance.downloadDirectoryBookmark,
'storageMode': instance.storageMode, 'storageMode': instance.storageMode,
'downloadTreeUri': instance.downloadTreeUri, 'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback, 'autoFallback': instance.autoFallback,
+32 -1
View File
@@ -4280,6 +4280,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings); updateSettings(settings);
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
var iosDownloadBookmarkActive = false;
if (settings.downloadNetworkMode == 'wifi_only') { if (settings.downloadNetworkMode == 'wifi_only') {
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
final hasWifi = connectivityResult.contains(ConnectivityResult.wifi); final hasWifi = connectivityResult.contains(ConnectivityResult.wifi);
@@ -4391,8 +4392,38 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; return;
} }
} }
if (!isSafMode &&
Platform.isIOS &&
settings.downloadDirectoryBookmark.isNotEmpty) {
final resolvedPath = await PlatformBridge.startAccessingIosBookmark(
settings.downloadDirectoryBookmark,
);
if (resolvedPath != null && resolvedPath.isNotEmpty) {
iosDownloadBookmarkActive = true;
if (resolvedPath != state.outputDir) {
_log.i('Resolved iOS download bookmark path: $resolvedPath');
state = state.copyWith(outputDir: resolvedPath);
}
} else {
_log.w(
'Failed to access iOS download folder bookmark, falling back to app Documents folder',
);
final musicDir = await _ensureDefaultDocumentsOutputDir();
state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
}
}
_log.d('Concurrent downloads: ${state.concurrentDownloads}'); _log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel(); try {
await _processQueueParallel();
} finally {
if (iosDownloadBookmarkActive) {
await PlatformBridge.stopAccessingIosBookmark();
iosDownloadBookmarkActive = false;
}
}
final stoppedWhilePaused = state.isPaused; final stoppedWhilePaused = state.isPaused;
final keepConnectivityMonitoring = final keepConnectivityMonitoring =
stoppedWhilePaused && _networkPausedByWifiOnly; stoppedWhilePaused && _networkPausedByWifiOnly;
+12 -3
View File
@@ -192,7 +192,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (normalizedDir == currentDir) return; if (normalizedDir == currentDir) return;
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir'); _log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
state = state.copyWith(downloadDirectory: normalizedDir); state = state.copyWith(
downloadDirectory: normalizedDir,
downloadDirectoryBookmark: '',
);
await _saveSettings(); await _saveSettings();
} }
@@ -260,8 +263,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setDownloadDirectory(String directory) { void setDownloadDirectory(String directory, {String? iosBookmark}) {
state = state.copyWith(downloadDirectory: directory); state = state.copyWith(
downloadDirectory: directory,
downloadDirectoryBookmark: iosBookmark ?? '',
);
_saveSettings(); _saveSettings();
} }
@@ -277,6 +283,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
downloadTreeUri: uri, downloadTreeUri: uri,
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode, storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
downloadDirectory: nextDisplay, downloadDirectory: nextDisplay,
downloadDirectoryBookmark: uri.isNotEmpty
? ''
: state.downloadDirectoryBookmark,
); );
_saveSettings(); _saveSettings();
} }
@@ -1420,13 +1420,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
try { try {
result = await FilePicker.platform.getDirectoryPath(); result = await FilePicker.platform.getDirectoryPath();
} catch (e) { } catch (e) {
if (ctx.mounted) { if (context.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
ctx.l10n.snackbarFolderPickerFailed(e.toString()), context.l10n.snackbarFolderPickerFailed(e.toString()),
), ),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
); );
@@ -1439,24 +1439,55 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (Platform.isIOS) { if (Platform.isIOS) {
final validation = validateIosPath(result); final validation = validateIosPath(result);
if (!validation.isValid) { if (!validation.isValid) {
if (ctx.mounted) { if (context.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
validation.errorReason ?? validation.errorReason ??
context.l10n.setupIcloudNotSupported, context.l10n.setupIcloudNotSupported,
), ),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
); );
} }
return; return;
} }
final bookmark =
await PlatformBridge.createIosBookmarkFromPath(result);
if (bookmark == null || bookmark.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(
'Could not keep access to the selected folder',
),
),
backgroundColor: Theme.of(
context,
).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result, iosBookmark: bookmark);
return;
} }
ref ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setDownloadDirectory(result); .setDownloadDirectory(result);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.setupNoFolderSelected)),
);
} }
}, },
), ),
+69 -27
View File
@@ -30,6 +30,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool _storagePermissionGranted = false; bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false; bool _notificationPermissionGranted = false;
String? _selectedDirectory; String? _selectedDirectory;
String? _selectedDirectoryBookmark;
String? _selectedTreeUri; String? _selectedTreeUri;
bool _isLoading = false; bool _isLoading = false;
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
@@ -338,7 +339,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
title: Text(context.l10n.setupAppDocumentsFolder), title: Text(context.l10n.setupAppDocumentsFolder),
onTap: () async { onTap: () async {
final dir = await _getDefaultDirectory(); final dir = await _getDefaultDirectory();
setState(() => _selectedDirectory = dir); setState(() {
_selectedDirectory = dir;
_selectedDirectoryBookmark = null;
});
if (ctx.mounted) Navigator.pop(ctx); if (ctx.mounted) Navigator.pop(ctx);
}, },
), ),
@@ -369,30 +373,62 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
return; return;
} }
if (result != null) { if (result == null) {
// iOS: Validate the selected path is writable if (mounted) {
if (Platform.isIOS) { ScaffoldMessenger.of(context).showSnackBar(
final validation = validateIosPath(result); SnackBar(
if (!validation.isValid) { content: Text(context.l10n.setupNoFolderSelected),
if (mounted) { ),
ScaffoldMessenger.of(context).showSnackBar( );
SnackBar(
content: Text(
validation.errorReason ??
'Invalid folder selected',
),
backgroundColor: Theme.of(
context,
).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
} }
setState(() => _selectedDirectory = result); return;
} }
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ?? 'Invalid folder selected',
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
final bookmark =
await PlatformBridge.createIosBookmarkFromPath(result);
if (bookmark == null || bookmark.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(
'Could not keep access to the selected folder',
),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
setState(() {
_selectedDirectory = result;
_selectedDirectoryBookmark = bookmark;
});
return;
}
setState(() => _selectedDirectory = result);
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -426,14 +462,20 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (!Platform.isAndroid || if (!Platform.isAndroid ||
_selectedTreeUri == null || _selectedTreeUri == null ||
_selectedTreeUri!.isEmpty) { _selectedTreeUri!.isEmpty) {
final dir = Directory(_selectedDirectory!); final iosBookmark = Platform.isIOS ? _selectedDirectoryBookmark : null;
if (!await dir.exists()) { if (iosBookmark == null || iosBookmark.isEmpty) {
await dir.create(recursive: true); final dir = Directory(_selectedDirectory!);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
} }
ref.read(settingsProvider.notifier).setStorageMode('app'); ref.read(settingsProvider.notifier).setStorageMode('app');
ref ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setDownloadDirectory(_selectedDirectory!); .setDownloadDirectory(
_selectedDirectory!,
iosBookmark: iosBookmark,
);
ref.read(settingsProvider.notifier).setDownloadTreeUri(''); ref.read(settingsProvider.notifier).setDownloadTreeUri('');
} else { } else {
ref.read(settingsProvider.notifier).setStorageMode('saf'); ref.read(settingsProvider.notifier).setStorageMode('saf');