diff --git a/.changes/dialog-folder-mobile.md b/.changes/dialog-folder-mobile.md new file mode 100644 index 00000000..e0cee606 --- /dev/null +++ b/.changes/dialog-folder-mobile.md @@ -0,0 +1,6 @@ +--- +"dialog": patch +"dialog-js": patch +--- + +Add support for folder picker on iOS and Android. \ No newline at end of file diff --git a/plugins/dialog/android/src/main/java/DialogPlugin.kt b/plugins/dialog/android/src/main/java/DialogPlugin.kt index af0467d8..1aec3735 100644 --- a/plugins/dialog/android/src/main/java/DialogPlugin.kt +++ b/plugins/dialog/android/src/main/java/DialogPlugin.kt @@ -47,6 +47,14 @@ class SaveFileDialogOptions { lateinit var filters: Array } +@InvokeArg +class FolderPickerOptions { + var title: String? = null + var multiple: Boolean? = null + var recursive: Boolean? = null + var canCreateDirectories: Boolean? = null +} + @TauriPlugin class DialogPlugin(private val activity: Activity): Plugin(activity) { var filePickerOptions: FilePickerOptions? = null @@ -249,4 +257,55 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) { invoke.reject(message) } } + + @Command + fun showFolderPicker(invoke: Invoke) { + try { + val args = invoke.parseArgs(FolderPickerOptions::class.java) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + if (args.title != null) { + intent.putExtra(Intent.EXTRA_TITLE, args.title) + } + + // Note: Android's document tree picker doesn't support multiple selection natively + // recursive and canCreateDirectories are handled at filesystem level + + startActivityForResult(invoke, intent, "folderPickerResult") + } catch (ex: Exception) { + val message = ex.message ?: "Failed to pick folder" + Logger.error(message) + invoke.reject(message) + } + } + + @ActivityCallback + fun folderPickerResult(invoke: Invoke, result: ActivityResult) { + try { + when (result.resultCode) { + Activity.RESULT_OK -> { + val callResult = createFolderPickerResult(result.data) + invoke.resolve(callResult) + } + Activity.RESULT_CANCELED -> invoke.reject("Folder picker cancelled") + else -> invoke.reject("Failed to pick folder") + } + } catch (ex: java.lang.Exception) { + val message = ex.message ?: "Failed to read folder pick result" + Logger.error(message) + invoke.reject(message) + } + } + + private fun createFolderPickerResult(data: Intent?): JSObject { + val callResult = JSObject() + if (data == null) { + callResult.put("directories", null) + return callResult + } + val uri = data.data + val directories = JSArray.from(arrayOf(uri.toString())) + callResult.put("directories", directories) + return callResult + } } \ No newline at end of file diff --git a/plugins/dialog/android/src/main/java/FilePickerUtils.kt b/plugins/dialog/android/src/main/java/FilePickerUtils.kt index 7029d4c6..33932a44 100644 --- a/plugins/dialog/android/src/main/java/FilePickerUtils.kt +++ b/plugins/dialog/android/src/main/java/FilePickerUtils.kt @@ -198,6 +198,22 @@ class FilePickerUtils { } return os.toByteArray() } + + private fun isTreeUri(uri: Uri): Boolean { + return DocumentsContract.isTreeUri(uri) + } + + fun getTreePathFromUri(context: Context, uri: Uri): String? { + if (!isTreeUri(uri)) return null + + val docId = DocumentsContract.getTreeDocumentId(uri) + val split = docId.split(":") + return if ("primary".equals(split[0], ignoreCase = true)) { + "${Environment.getExternalStorageDirectory()}/${split[1]}" + } else { + null + } + } } } diff --git a/plugins/dialog/ios/Sources/DialogPlugin.swift b/plugins/dialog/ios/Sources/DialogPlugin.swift index b3f7e7da..6ebb801d 100644 --- a/plugins/dialog/ios/Sources/DialogPlugin.swift +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -38,6 +38,14 @@ struct SaveFileDialogOptions: Decodable { var defaultPath: String? } +struct FolderPickerOptions: Decodable { + var title: String? + var defaultPath: String? + var multiple: Bool? + var recursive: Bool? + var canCreateDirectories: Bool? +} + class DialogPlugin: Plugin { var filePickerController: FilePickerController! @@ -168,6 +176,47 @@ class DialogPlugin: Plugin { } } + @objc public func showFolderPicker(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(FolderPickerOptions.self) + + onFilePickerResult = { (event: FilePickerEvent) -> Void in + switch event { + case .selected(let urls): + invoke.resolve(["directories": urls]) + case .cancelled: + invoke.resolve(["directories": nil]) + case .error(let error): + invoke.reject(error) + } + } + + DispatchQueue.main.async { + let picker: UIDocumentPickerViewController + if #available(iOS 14.0, *) { + picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) + } else { + picker = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open) + } + + if let title = args.title { + picker.title = title + } + + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } + + picker.delegate = self.filePickerController + picker.allowsMultipleSelection = args.multiple ?? false + + // Note: canCreateDirectories is only supported on macOS + // recursive is handled at the filesystem access level, not in the picker + + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } + private func presentViewController(_ viewControllerToPresent: UIViewController) { self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) } diff --git a/plugins/dialog/ios/Sources/FilePickerController.swift b/plugins/dialog/ios/Sources/FilePickerController.swift index b2752f0b..4da7c46f 100644 --- a/plugins/dialog/ios/Sources/FilePickerController.swift +++ b/plugins/dialog/ios/Sources/FilePickerController.swift @@ -7,6 +7,7 @@ import MobileCoreServices import PhotosUI import Photos import Tauri +import UniformTypeIdentifiers public class FilePickerController: NSObject { var plugin: DialogPlugin @@ -118,6 +119,15 @@ public class FilePickerController: NSObject { extension FilePickerController: UIDocumentPickerDelegate { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + // Check if this is a folder picker by examining the URLs + let isFolder = urls.first?.hasDirectoryPath ?? false + + if isFolder { + self.plugin.onFilePickerEvent(.selected(urls)) + return + } + + // Handle regular files do { let temporaryUrls = try urls.map { try saveTemporaryFile($0) } self.plugin.onFilePickerEvent(.selected(temporaryUrls)) diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index c3caf027..ae74bf8a 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -16,9 +16,7 @@ use crate::{ #[derive(Serialize)] #[serde(untagged)] pub enum OpenResponse { - #[cfg(desktop)] Folders(Option>), - #[cfg(desktop)] Folder(Option), Files(Option>), File(Option), @@ -132,8 +130,7 @@ pub(crate) async fn open( dialog_builder = dialog_builder.add_filter(filter.name, &extensions); } - let res = if options.directory { - #[cfg(desktop)] + if options.directory { { let tauri_scope = window.state::(); @@ -149,9 +146,9 @@ pub(crate) async fn open( } } } - OpenResponse::Folders( - folders.map(|folders| folders.into_iter().map(|p| p.simplified()).collect()), - ) + return Ok(OpenResponse::Folders(folders.map(|folders| { + folders.into_iter().map(|p| p.simplified()).collect() + }))); } else { let folder = dialog_builder.blocking_pick_folder(); if let Some(folder) = &folder { @@ -162,14 +159,14 @@ pub(crate) async fn open( tauri_scope.allow_directory(&path, options.directory)?; } } - OpenResponse::Folder(folder.map(|p| p.simplified())) + return Ok(OpenResponse::Folder(folder.map(|p| p.simplified()))); } } - #[cfg(mobile)] - return Err(crate::Error::FolderPickerNotImplemented); - } else if options.multiple { - let tauri_scope = window.state::(); + } + // Handle file selection + if options.multiple { + let tauri_scope = window.state::(); let files = dialog_builder.blocking_pick_files(); if let Some(files) = &files { for file in files { @@ -177,16 +174,16 @@ pub(crate) async fn open( if let Some(s) = window.try_fs_scope() { s.allow_file(&path)?; } - tauri_scope.allow_file(&path)?; } } } - OpenResponse::Files(files.map(|files| files.into_iter().map(|f| f.simplified()).collect())) + Ok(OpenResponse::Files(files.map(|files| { + files.into_iter().map(|f| f.simplified()).collect() + }))) } else { let tauri_scope = window.state::(); let file = dialog_builder.blocking_pick_file(); - if let Some(file) = &file { if let Ok(path) = file.clone().into_path() { if let Some(s) = window.try_fs_scope() { @@ -195,9 +192,8 @@ pub(crate) async fn open( tauri_scope.allow_file(&path)?; } } - OpenResponse::File(file.map(|f| f.simplified())) - }; - Ok(res) + Ok(OpenResponse::File(file.map(|f| f.simplified()))) + } } #[allow(unused_variables)] diff --git a/plugins/dialog/src/error.rs b/plugins/dialog/src/error.rs index 0c3ed5b8..8d46b32d 100644 --- a/plugins/dialog/src/error.rs +++ b/plugins/dialog/src/error.rs @@ -16,9 +16,6 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), - #[cfg(mobile)] - #[error("Folder picker is not implemented on mobile")] - FolderPickerNotImplemented, #[error(transparent)] Fs(#[from] tauri_plugin_fs::Error), } diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index 2ef1c1ea..78364f5a 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -506,7 +506,6 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(desktop)] pub fn pick_folder) + Send + 'static>(self, f: F) { pick_folder(self, f) } @@ -528,7 +527,6 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(desktop)] pub fn pick_folders>) + Send + 'static>(self, f: F) { pick_folders(self, f) } @@ -611,7 +609,6 @@ impl FileDialogBuilder { /// // the folder path is `None` if the user closed the dialog /// } /// ``` - #[cfg(desktop)] pub fn blocking_pick_folder(self) -> Option { blocking_fn!(self, pick_folder) } @@ -631,7 +628,6 @@ impl FileDialogBuilder { /// // the folder paths value is `None` if the user closed the dialog /// } /// ``` - #[cfg(desktop)] pub fn blocking_pick_folders(self) -> Option> { blocking_fn!(self, pick_folders) } diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index b73def4f..ebe156e4 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -54,6 +54,11 @@ struct SaveFileResponse { file: FilePath, } +#[derive(Debug, Deserialize)] +struct FolderPickerResponse { + directories: Vec, +} + pub fn pick_file) + Send + 'static>( dialog: FileDialogBuilder, f: F, @@ -125,3 +130,39 @@ pub fn show_message_dialog( f(res.map(|r| r.value).unwrap_or_default()) }); } + +#[cfg(mobile)] +pub fn pick_folders>) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showFolderPicker", dialog.payload(true)); + if let Ok(response) = res { + f(Some(response.directories)) + } else { + f(None) + } + }); +} + +#[cfg(mobile)] +pub fn pick_folder) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showFolderPicker", dialog.payload(false)); + if let Ok(response) = res { + f(Some(response.directories.into_iter().next().unwrap())) + } else { + f(None) + } + }); +}