diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 87f47eb5..e14b4dc4 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import UniformTypeIdentifiers import Gobackend @main @@ -23,6 +24,9 @@ import Gobackend /// Currently accessed security-scoped URL for library folder private var activeSecurityScopedURL: URL? + /// Pending Flutter result for the native folder picker + private var pendingDirectoryPickerResult: FlutterResult? + /// Whether a download queue is active; while true a background task is /// started on each background entry to extend execution time. Main-thread only. private var downloadsActive = false @@ -329,6 +333,9 @@ import Gobackend endBackgroundDownloadTask() result(nil) return + case "pickIosDirectory": + pickIosDirectory(result: result) + return default: break } @@ -1173,8 +1180,40 @@ import Gobackend } } + // MARK: - Native Folder Picker + + /// Present a native folder picker and return `{path, bookmark}` where the + /// security-scoped bookmark is created inside the picker callback, while + /// the picker's access grant is still active. Returns nil on cancel. + private func pickIosDirectory(result: @escaping FlutterResult) { + if pendingDirectoryPickerResult != nil { + result(FlutterError( + code: "PICKER_ACTIVE", + message: "A folder picker is already active", + details: nil + )) + return + } + guard var topController = window?.rootViewController else { + result(FlutterError( + code: "NO_VIEW_CONTROLLER", + message: "No view controller available to present the folder picker", + details: nil + )) + return + } + while let presented = topController.presentedViewController { + topController = presented + } + pendingDirectoryPickerResult = result + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder]) + picker.delegate = self + picker.allowsMultipleSelection = false + topController.present(picker, animated: true) + } + // MARK: - iOS Security-Scoped Bookmark Helpers - + /// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker). /// The path must currently be accessible (within the same picker session). /// Returns base64-encoded bookmark data. @@ -1295,6 +1334,50 @@ import Gobackend } } +extension AppDelegate: UIDocumentPickerDelegate { + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + guard let result = pendingDirectoryPickerResult else { return } + pendingDirectoryPickerResult = nil + guard let url = urls.first else { + result(nil) + return + } + // The bookmark must be created here, from the picker's own URL, while + // its security-scoped grant is active. A URL rebuilt from the path + // string later has no grant, so bookmark creation either fails or + // yields a bookmark that cannot re-open the folder. + let didStartAccess = url.startAccessingSecurityScopedResource() + defer { + if didStartAccess { url.stopAccessingSecurityScopedResource() } + } + do { + let bookmarkData = try url.bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + result([ + "path": url.path, + "bookmark": bookmarkData.base64EncodedString(), + ]) + } catch { + result(FlutterError( + code: "BOOKMARK_FAILED", + message: "Failed to create bookmark for \(url.path): \(error.localizedDescription)", + details: nil + )) + } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + pendingDirectoryPickerResult?(nil) + pendingDirectoryPickerResult = nil + } +} + private final class ClosureStreamHandler: NSObject, FlutterStreamHandler { typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError? typealias CancelHandler = (_ arguments: Any?) -> FlutterError? diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ff426739..290362e5 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -7137,12 +7137,23 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: resolvedPath); } } else { + _log.e('Failed to access iOS download folder bookmark'); _log.w( - 'Failed to access iOS download folder bookmark, falling back to app Documents folder', + 'The saved download folder may have been moved, deleted, or its access grant lost', ); - final musicDir = await _ensureDefaultDocumentsOutputDir(); - state = state.copyWith(outputDir: musicDir.path); - ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path); + for (final item in state.items) { + if (item.status == DownloadStatus.queued) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: + 'Download folder access lost. Please re-select your download folder in Settings.', + ); + } + } + state = state.copyWith(isProcessing: false); + await PlatformBridge.endBackgroundDownloadTask(); + return; } } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 5a035a7c..a9b717c9 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -252,6 +252,13 @@ class SettingsNotifier extends Notifier { Future _normalizeIosDownloadDirectoryIfNeeded() async { if (!Platform.isIOS) return; + // A security-scoped bookmark is the source of truth for folders picked + // from Files: its stored path may legitimately point outside the app + // container, and the download flow re-resolves the real path from the + // bookmark. "Fixing" the path here would also drop the bookmark and + // silently revert the user's chosen folder. + if (state.downloadDirectoryBookmark.isNotEmpty) return; + final currentDir = state.downloadDirectory.trim(); if (currentDir.isEmpty) return; diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 0f259430..4f355a5b 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -599,12 +598,10 @@ class _FilesSettingsPageState extends ConsumerState { subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), onTap: () async { Navigator.pop(ctx); - if (Platform.isIOS) { - await Future.delayed(const Duration(milliseconds: 250)); - } - String? result; + await Future.delayed(const Duration(milliseconds: 250)); + IosPickedDirectory? picked; try { - result = await FilePicker.getDirectoryPath(); + picked = await PlatformBridge.pickIosDirectory(); } catch (e) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( @@ -619,53 +616,31 @@ class _FilesSettingsPageState extends ConsumerState { } return; } - if (result != null) { - if (Platform.isIOS) { - final validation = validateIosPath(result); - if (!validation.isValid) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text( - validation.errorReason ?? - context.l10n.setupIcloudNotSupported, - ), - backgroundColor: Theme.of(ctx).colorScheme.error, - duration: const Duration(seconds: 4), - ), - ); - } - return; - } + if (picked == null) return; - final bookmark = - await PlatformBridge.createIosBookmarkFromPath(result); - if (bookmark == null || bookmark.isEmpty) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text( - ctx.l10n.snackbarFolderPickerFailed( - ctx.l10n.errorCouldNotKeepFolderAccess, - ), - ), - backgroundColor: Theme.of(ctx).colorScheme.error, - duration: const Duration(seconds: 4), - ), - ); - } - return; - } - - ref - .read(settingsProvider.notifier) - .setDownloadDirectory(result, iosBookmark: bookmark); - return; + final validation = validateIosPath(picked.path); + if (!validation.isValid) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text( + validation.errorReason ?? + context.l10n.setupIcloudNotSupported, + ), + backgroundColor: Theme.of(ctx).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); } - ref - .read(settingsProvider.notifier) - .setDownloadDirectory(result); + return; } + + ref + .read(settingsProvider.notifier) + .setDownloadDirectory( + picked.path, + iosBookmark: picked.bookmark, + ); }, ), Padding( diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 1f15b6c2..b541296e 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -121,22 +121,32 @@ class _LibrarySettingsPageState extends ConsumerState { final granted = await _requestStoragePermission(); if (!granted) return; } + if (Platform.isIOS) { + IosPickedDirectory? picked; + try { + picked = await PlatformBridge.pickIosDirectory(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarFolderPickerFailed(e.toString()), + ), + ), + ); + } + return; + } + if (picked != null) { + ref + .read(settingsProvider.notifier) + .setLocalLibraryPathAndBookmark(picked.path, picked.bookmark); + } + return; + } final result = await FilePicker.getDirectoryPath(); if (result != null) { - if (Platform.isIOS) { - final bookmark = await PlatformBridge.createIosBookmarkFromPath( - result, - ); - if (bookmark != null && bookmark.isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setLocalLibraryPathAndBookmark(result, bookmark); - } else { - ref.read(settingsProvider.notifier).setLocalLibraryPath(result); - } - } else { - ref.read(settingsProvider.notifier).setLocalLibraryPath(result); - } + ref.read(settingsProvider.notifier).setLocalLibraryPath(result); } } } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 0fb2d2cc..d9ab6e9e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -354,13 +353,11 @@ class _SetupScreenState extends ConsumerState { title: Text(context.l10n.setupChooseFromFiles), onTap: () async { Navigator.pop(ctx); - if (Platform.isIOS) { - await Future.delayed(const Duration(milliseconds: 250)); - } + await Future.delayed(const Duration(milliseconds: 250)); - String? result; + IosPickedDirectory? picked; try { - result = await FilePicker.getDirectoryPath(); + picked = await PlatformBridge.pickIosDirectory(); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -376,7 +373,7 @@ class _SetupScreenState extends ConsumerState { return; } - if (result == null) { + if (picked == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -387,52 +384,29 @@ class _SetupScreenState extends ConsumerState { 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 ?? - context.l10n.errorInvalidFolderSelected, - ), - backgroundColor: Theme.of(context).colorScheme.error, - duration: const Duration(seconds: 4), + // Validate the selected path is writable + final validation = validateIosPath(picked.path); + if (!validation.isValid) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + validation.errorReason ?? + context.l10n.errorInvalidFolderSelected, ), - ); - } - return; + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); } - - final bookmark = - await PlatformBridge.createIosBookmarkFromPath(result); - if (bookmark == null || bookmark.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarFolderPickerFailed( - context.l10n.errorCouldNotKeepFolderAccess, - ), - ), - backgroundColor: Theme.of(context).colorScheme.error, - duration: const Duration(seconds: 4), - ), - ); - } - return; - } - - setState(() { - _selectedDirectory = result; - _selectedDirectoryBookmark = bookmark; - }); return; } - setState(() => _selectedDirectory = result); + final pickedDir = picked; + setState(() { + _selectedDirectory = pickedDir.path; + _selectedDirectoryBookmark = pickedDir.bookmark; + }); }, ), const SizedBox(height: 16), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 783337f4..59eb947c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -22,6 +22,15 @@ class ExtensionSessionGrantEvent { }); } +/// Folder picked via the native iOS document picker: the filesystem path plus +/// the security-scoped bookmark that re-grants access across app launches. +class IosPickedDirectory { + final String path; + final String bookmark; + + const IosPickedDirectory({required this.path, required this.bookmark}); +} + class _BridgeCacheEntry { final Map value; final DateTime expiresAt; @@ -2041,6 +2050,20 @@ class PlatformBridge { return const {}; } + /// Present the native iOS folder picker. The security-scoped bookmark is + /// created inside the picker callback while its access grant is still + /// active — creating it later from a bare path loses the grant. + /// Returns null when the user cancels; throws on picker/bookmark failure. + static Future pickIosDirectory() async { + final result = await _channel.invokeMethod('pickIosDirectory'); + final map = _decodeNullableMapResult(result, 'pickIosDirectory'); + if (map == null) return null; + final path = map['path'] as String? ?? ''; + final bookmark = map['bookmark'] as String? ?? ''; + if (path.isEmpty || bookmark.isEmpty) return null; + return IosPickedDirectory(path: path, bookmark: bookmark); + } + /// Create a security-scoped bookmark from a filesystem path picked by /// FilePicker on iOS. Must be called while the picker session is still active. /// Returns base64-encoded bookmark data, or null on failure.