diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index fda8ebdb..df0ef0c6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1021,7 +1021,7 @@ import Gobackend // Import Go framework let url = URL(fileURLWithPath: path) do { let bookmarkData = try url.bookmarkData( - options: .minimalBookmark, + options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil ) @@ -1051,7 +1051,7 @@ import Gobackend // Import Go framework do { url = try URL( resolvingBookmarkData: bookmarkData, - options: [], + options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale ) @@ -1086,7 +1086,7 @@ import Gobackend // Import Go framework do { url = try URL( resolvingBookmarkData: bookmarkData, - options: [], + options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale ) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c8ba8a54..be48b6b2 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,6 +11,7 @@ class AppSettings { final String audioQuality; final String filenameFormat; final String downloadDirectory; + final String downloadDirectoryBookmark; final String storageMode; // 'app' or 'saf' final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; @@ -89,6 +90,7 @@ class AppSettings { this.audioQuality = 'LOSSLESS', this.filenameFormat = '{title} - {artist}', this.downloadDirectory = '', + this.downloadDirectoryBookmark = '', this.storageMode = 'app', this.downloadTreeUri = '', this.autoFallback = true, @@ -153,6 +155,7 @@ class AppSettings { String? audioQuality, String? filenameFormat, String? downloadDirectory, + String? downloadDirectoryBookmark, String? storageMode, String? downloadTreeUri, bool? autoFallback, @@ -213,6 +216,8 @@ class AppSettings { audioQuality: audioQuality ?? this.audioQuality, filenameFormat: filenameFormat ?? this.filenameFormat, downloadDirectory: downloadDirectory ?? this.downloadDirectory, + downloadDirectoryBookmark: + downloadDirectoryBookmark ?? this.downloadDirectoryBookmark, storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index abcd5c3d..62e8a409 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -11,6 +11,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS', filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}', downloadDirectory: json['downloadDirectory'] as String? ?? '', + downloadDirectoryBookmark: json['downloadDirectoryBookmark'] as String? ?? '', storageMode: json['storageMode'] as String? ?? 'app', downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, @@ -86,6 +87,7 @@ Map _$AppSettingsToJson( 'audioQuality': instance.audioQuality, 'filenameFormat': instance.filenameFormat, 'downloadDirectory': instance.downloadDirectory, + 'downloadDirectoryBookmark': instance.downloadDirectoryBookmark, 'storageMode': instance.storageMode, 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9fa07345..bb0e936a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4280,6 +4280,7 @@ class DownloadQueueNotifier extends Notifier { final settings = ref.read(settingsProvider); updateSettings(settings); final isSafMode = _isSafMode(settings); + var iosDownloadBookmarkActive = false; if (settings.downloadNetworkMode == 'wifi_only') { final connectivityResult = await Connectivity().checkConnectivity(); final hasWifi = connectivityResult.contains(ConnectivityResult.wifi); @@ -4391,8 +4392,38 @@ class DownloadQueueNotifier extends Notifier { 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}'); - await _processQueueParallel(); + try { + await _processQueueParallel(); + } finally { + if (iosDownloadBookmarkActive) { + await PlatformBridge.stopAccessingIosBookmark(); + iosDownloadBookmarkActive = false; + } + } final stoppedWhilePaused = state.isPaused; final keepConnectivityMonitoring = stoppedWhilePaused && _networkPausedByWifiOnly; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index ac810c2e..54a9a16c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -192,7 +192,10 @@ class SettingsNotifier extends Notifier { if (normalizedDir == currentDir) return; _log.i('Normalized iOS download directory: $currentDir -> $normalizedDir'); - state = state.copyWith(downloadDirectory: normalizedDir); + state = state.copyWith( + downloadDirectory: normalizedDir, + downloadDirectoryBookmark: '', + ); await _saveSettings(); } @@ -260,8 +263,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setDownloadDirectory(String directory) { - state = state.copyWith(downloadDirectory: directory); + void setDownloadDirectory(String directory, {String? iosBookmark}) { + state = state.copyWith( + downloadDirectory: directory, + downloadDirectoryBookmark: iosBookmark ?? '', + ); _saveSettings(); } @@ -277,6 +283,9 @@ class SettingsNotifier extends Notifier { downloadTreeUri: uri, storageMode: uri.isNotEmpty ? 'saf' : state.storageMode, downloadDirectory: nextDisplay, + downloadDirectoryBookmark: uri.isNotEmpty + ? '' + : state.downloadDirectoryBookmark, ); _saveSettings(); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 91e269ce..b79f7bd8 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1420,13 +1420,13 @@ class _DownloadSettingsPageState extends ConsumerState { try { result = await FilePicker.platform.getDirectoryPath(); } catch (e) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( SnackBar( 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), ), ); @@ -1439,24 +1439,55 @@ class _DownloadSettingsPageState extends ConsumerState { if (Platform.isIOS) { final validation = validateIosPath(result); if (!validation.isValid) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( validation.errorReason ?? context.l10n.setupIcloudNotSupported, ), - backgroundColor: Theme.of(ctx).colorScheme.error, + backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 4), ), ); } 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 .read(settingsProvider.notifier) .setDownloadDirectory(result); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setupNoFolderSelected)), + ); } }, ), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index e1469a5e..f2e72d31 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -30,6 +30,7 @@ class _SetupScreenState extends ConsumerState { bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; String? _selectedDirectory; + String? _selectedDirectoryBookmark; String? _selectedTreeUri; bool _isLoading = false; int _androidSdkVersion = 0; @@ -338,7 +339,10 @@ class _SetupScreenState extends ConsumerState { title: Text(context.l10n.setupAppDocumentsFolder), onTap: () async { final dir = await _getDefaultDirectory(); - setState(() => _selectedDirectory = dir); + setState(() { + _selectedDirectory = dir; + _selectedDirectoryBookmark = null; + }); if (ctx.mounted) Navigator.pop(ctx); }, ), @@ -369,30 +373,62 @@ class _SetupScreenState extends ConsumerState { return; } - if (result != null) { - // 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; - } + if (result == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.setupNoFolderSelected), + ), + ); } - 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), @@ -426,14 +462,20 @@ class _SetupScreenState extends ConsumerState { if (!Platform.isAndroid || _selectedTreeUri == null || _selectedTreeUri!.isEmpty) { - final dir = Directory(_selectedDirectory!); - if (!await dir.exists()) { - await dir.create(recursive: true); + final iosBookmark = Platform.isIOS ? _selectedDirectoryBookmark : null; + if (iosBookmark == null || iosBookmark.isEmpty) { + final dir = Directory(_selectedDirectory!); + if (!await dir.exists()) { + await dir.create(recursive: true); + } } ref.read(settingsProvider.notifier).setStorageMode('app'); ref .read(settingsProvider.notifier) - .setDownloadDirectory(_selectedDirectory!); + .setDownloadDirectory( + _selectedDirectory!, + iosBookmark: iosBookmark, + ); ref.read(settingsProvider.notifier).setDownloadTreeUri(''); } else { ref.read(settingsProvider.notifier).setStorageMode('saf');