Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/dialog-folder-mobile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"dialog": patch
"dialog-js": patch
---

Add support for folder picker on iOS and Android.
59 changes: 59 additions & 0 deletions plugins/dialog/android/src/main/java/DialogPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ class SaveFileDialogOptions {
lateinit var filters: Array<Filter>
}

@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
Expand Down Expand Up @@ -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
}
}
16 changes: 16 additions & 0 deletions plugins/dialog/android/src/main/java/FilePickerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}

Expand Down
49 changes: 49 additions & 0 deletions plugins/dialog/ios/Sources/DialogPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions plugins/dialog/ios/Sources/FilePickerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import MobileCoreServices
import PhotosUI
import Photos
import Tauri
import UniformTypeIdentifiers

public class FilePickerController: NSObject {
var plugin: DialogPlugin
Expand Down Expand Up @@ -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))
Expand Down
32 changes: 14 additions & 18 deletions plugins/dialog/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ use crate::{
#[derive(Serialize)]
#[serde(untagged)]
pub enum OpenResponse {
#[cfg(desktop)]
Folders(Option<Vec<FilePath>>),
#[cfg(desktop)]
Folder(Option<FilePath>),
Files(Option<Vec<FilePath>>),
File(Option<FilePath>),
Expand Down Expand Up @@ -132,8 +130,7 @@ pub(crate) async fn open<R: Runtime>(
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
}

let res = if options.directory {
#[cfg(desktop)]
if options.directory {
{
let tauri_scope = window.state::<tauri::scope::Scopes>();

Expand All @@ -149,9 +146,9 @@ pub(crate) async fn open<R: Runtime>(
}
}
}
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 {
Expand All @@ -162,31 +159,31 @@ pub(crate) async fn open<R: Runtime>(
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::<tauri::scope::Scopes>();
}

// Handle file selection
if options.multiple {
let tauri_scope = window.state::<tauri::scope::Scopes>();
let files = dialog_builder.blocking_pick_files();
if let Some(files) = &files {
for file in files {
if let Ok(path) = file.clone().into_path() {
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::<tauri::scope::Scopes>();
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() {
Expand All @@ -195,9 +192,8 @@ pub(crate) async fn open<R: Runtime>(
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)]
Expand Down
3 changes: 0 additions & 3 deletions plugins/dialog/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down
4 changes: 0 additions & 4 deletions plugins/dialog/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(())
/// });
/// ```
#[cfg(desktop)]
pub fn pick_folder<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
pick_folder(self, f)
}
Expand All @@ -528,7 +527,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(())
/// });
/// ```
#[cfg(desktop)]
pub fn pick_folders<F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(self, f: F) {
pick_folders(self, f)
}
Expand Down Expand Up @@ -611,7 +609,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// // the folder path is `None` if the user closed the dialog
/// }
/// ```
#[cfg(desktop)]
pub fn blocking_pick_folder(self) -> Option<FilePath> {
blocking_fn!(self, pick_folder)
}
Expand All @@ -631,7 +628,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// // the folder paths value is `None` if the user closed the dialog
/// }
/// ```
#[cfg(desktop)]
pub fn blocking_pick_folders(self) -> Option<Vec<FilePath>> {
blocking_fn!(self, pick_folders)
}
Expand Down
41 changes: 41 additions & 0 deletions plugins/dialog/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ struct SaveFileResponse {
file: FilePath,
}

#[derive(Debug, Deserialize)]
struct FolderPickerResponse {
directories: Vec<FilePath>,
}

pub fn pick_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
dialog: FileDialogBuilder<R>,
f: F,
Expand Down Expand Up @@ -125,3 +130,39 @@ pub fn show_message_dialog<R: Runtime, F: FnOnce(bool) + Send + 'static>(
f(res.map(|r| r.value).unwrap_or_default())
});
}

#[cfg(mobile)]
pub fn pick_folders<R: Runtime, F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(
dialog: FileDialogBuilder<R>,
f: F,
) {
std::thread::spawn(move || {
let res = dialog
.dialog
.0
.run_mobile_plugin::<FolderPickerResponse>("showFolderPicker", dialog.payload(true));
if let Ok(response) = res {
f(Some(response.directories))
} else {
f(None)
}
});
}

#[cfg(mobile)]
pub fn pick_folder<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
dialog: FileDialogBuilder<R>,
f: F,
) {
std::thread::spawn(move || {
let res = dialog
.dialog
.0
.run_mobile_plugin::<FolderPickerResponse>("showFolderPicker", dialog.payload(false));
if let Ok(response) = res {
f(Some(response.directories.into_iter().next().unwrap()))
} else {
f(None)
}
});
}
Loading