Skip to main content

cranpose_services/
file_picker.rs

1//! Native, cross-platform file and folder picker.
2//!
3//! Applications pick through the [`FilePicker`] obtained from
4//! [`local_file_picker`] and receive an opaque [`PickedEntry`] handle, not a
5//! filesystem path. This is deliberate: on Android (Storage Access Framework
6//! `content://` trees), iOS (`UIDocumentPicker` security-scoped URLs) and the
7//! web (File System Access handles) the user can choose folders served by the
8//! *system* document providers — a mounted WebDAV share, cloud storage, etc. —
9//! which do not map to a local path. [`PickedEntry::read_bytes`] and
10//! [`PickedEntry::list`] read through the originating provider on every
11//! platform.
12//!
13//! The picker is asynchronous and must run on the UI thread, so callers drive
14//! it from [`cranpose_core::LaunchedEffectAsync`].
15
16use cranpose_core::compositionLocalOfWithPolicy;
17use cranpose_core::CompositionLocal;
18use cranpose_core::CompositionLocalProvider;
19use cranpose_macros::composable;
20use std::cell::RefCell;
21use std::future::Future;
22use std::pin::Pin;
23use std::rc::Rc;
24
25/// Errors produced while presenting a picker or reading a picked entry.
26#[derive(thiserror::Error, Debug, Clone)]
27pub enum FilePickerError {
28    /// Presenting the picker failed.
29    #[error("file picker failed: {0}")]
30    Failed(String),
31    /// Reading or listing a picked entry failed.
32    #[error("reading picked entry failed: {0}")]
33    ReadFailed(String),
34    /// `list` was called on a file, or `read_bytes` on a folder.
35    #[error("picked entry is a {actual}, expected a {expected}")]
36    WrongKind {
37        /// The kind the entry actually is.
38        actual: &'static str,
39        /// The kind the operation expected.
40        expected: &'static str,
41    },
42    /// The picker requires a cranpose-services feature that is not enabled.
43    #[error("{operation} requires cranpose-services feature `{feature}`")]
44    UnsupportedFeature {
45        /// The attempted operation.
46        operation: &'static str,
47        /// The feature that enables it.
48        feature: &'static str,
49    },
50    /// No picker is available on this platform/build.
51    #[error("file picking is not available on this platform")]
52    UnsupportedPlatform,
53}
54
55/// A `'static` future returned by picker operations, polled on the UI thread.
56pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
57
58/// Whether a [`PickedEntry`] is a single file or a folder/tree.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum PickedKind {
61    /// A single file.
62    File,
63    /// A folder (directory tree).
64    Folder,
65}
66
67/// A named filter limiting the file types offered by the picker.
68#[derive(Clone, Debug, Default)]
69pub struct FileFilter {
70    /// Human-readable group name, for example `"Audio"`.
71    pub label: String,
72    /// Accepted extensions without the leading dot, for example `["mp3", "flac"]`.
73    pub extensions: Vec<String>,
74}
75
76impl FileFilter {
77    /// Creates a filter from a label and a set of extensions.
78    pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
79        Self {
80            label: label.into(),
81            extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
82        }
83    }
84}
85
86/// Options controlling a pick request.
87#[derive(Clone, Debug, Default)]
88pub struct FilePickerOptions {
89    /// Dialog title.
90    pub title: Option<String>,
91    /// File-type filters (ignored by folder pickers and some platforms).
92    pub filters: Vec<FileFilter>,
93}
94
95impl FilePickerOptions {
96    /// Sets the dialog title.
97    pub fn with_title(mut self, title: impl Into<String>) -> Self {
98        self.title = Some(title.into());
99        self
100    }
101
102    /// Adds a file-type filter.
103    pub fn with_filter(mut self, filter: FileFilter) -> Self {
104        self.filters.push(filter);
105        self
106    }
107}
108
109/// A request to save bytes to a user-chosen destination.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct SaveFileRequest {
112    /// Suggested file name (including extension).
113    pub file_name: String,
114    /// MIME type of `bytes` — used by backends that need one (Android's
115    /// `ACTION_CREATE_DOCUMENT`, the web download).
116    pub mime_type: String,
117    /// The file payload.
118    pub bytes: Vec<u8>,
119}
120
121impl SaveFileRequest {
122    pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>, bytes: Vec<u8>) -> Self {
123        Self {
124            file_name: file_name.into(),
125            mime_type: mime_type.into(),
126            bytes,
127        }
128    }
129}
130
131/// An opaque handle to a picked file or folder.
132///
133/// This is **not** guaranteed to be a filesystem path. Use [`read_bytes`] to
134/// read a file and [`list`] to enumerate a folder's immediate children; both
135/// go through the originating system provider.
136///
137/// [`read_bytes`]: PickedEntry::read_bytes
138/// [`list`]: PickedEntry::list
139pub trait PickedEntry {
140    /// The display name (last path/URI component).
141    fn name(&self) -> String;
142
143    /// Whether this entry is a file or a folder.
144    fn kind(&self) -> PickedKind;
145
146    /// A user-facing identifier (a path or a `content://`/`file://` URI). Not
147    /// guaranteed to be a usable filesystem path.
148    fn display_path(&self) -> String;
149
150    /// Reads the full contents of a [`PickedKind::File`] entry.
151    fn read_bytes(&self) -> PickerFuture<Result<Vec<u8>, FilePickerError>>;
152
153    /// Lists the immediate children of a [`PickedKind::Folder`] entry.
154    fn list(&self) -> PickerFuture<Result<Vec<PickedEntryRef>, FilePickerError>>;
155}
156
157/// Shared handle to a [`PickedEntry`].
158pub type PickedEntryRef = Rc<dyn PickedEntry>;
159
160/// A folder being enumerated, yielding its files as the provider discovers
161/// them.
162///
163/// Walking a deep tree from a slow provider (cloud storage, a mounted WebDAV
164/// share) can take a long time, so the files are delivered incrementally rather
165/// than all at once: poll [`take_ready`] each frame to drain newly-found files,
166/// and [`is_finished`] to know when the walk is done. Callers can show the
167/// running count and start playing the first file without waiting for the rest.
168///
169/// [`take_ready`]: FolderStream::take_ready
170/// [`is_finished`]: FolderStream::is_finished
171pub trait FolderStream {
172    /// Files discovered since the previous call (drains the ready queue).
173    fn take_ready(&self) -> Vec<PickedEntryRef>;
174
175    /// Whether enumeration has finished (no more files will be discovered).
176    fn is_finished(&self) -> bool;
177
178    /// Takes the error that ended enumeration early, if any.
179    fn take_error(&self) -> Option<FilePickerError>;
180}
181
182/// Shared handle to a [`FolderStream`].
183pub type FolderStreamRef = Rc<dyn FolderStream>;
184
185/// A selection recovered by [`FilePicker::take_resumed_picks`] after the
186/// composition that requested it was destroyed mid-pick (Android recreates the
187/// activity when the SAF picker covers it on some devices). It is consumed
188/// exactly like a freshly-returned pick.
189pub enum ResumedPick {
190    /// A single picked file.
191    File(PickedEntryRef),
192    /// A picked folder, streamed like [`FilePicker::pick_folder_streaming`].
193    Folder(FolderStreamRef),
194}
195
196/// Recursively collects every [`PickedKind::File`] under `entry`.
197fn collect_files(entry: PickedEntryRef) -> PickerFuture<Vec<PickedEntryRef>> {
198    Box::pin(async move {
199        match entry.kind() {
200            PickedKind::File => vec![entry],
201            PickedKind::Folder => {
202                let mut files = Vec::new();
203                if let Ok(children) = entry.list().await {
204                    for child in children {
205                        files.extend(collect_files(child).await);
206                    }
207                }
208                files
209            }
210        }
211    })
212}
213
214/// A [`FolderStream`] whose files were all gathered up front (the default for
215/// providers that enumerate eagerly, e.g. the local filesystem). It yields
216/// everything on the first poll and is immediately finished.
217struct ReadyFolderStream {
218    ready: RefCell<Vec<PickedEntryRef>>,
219}
220
221impl FolderStream for ReadyFolderStream {
222    fn take_ready(&self) -> Vec<PickedEntryRef> {
223        std::mem::take(&mut self.ready.borrow_mut())
224    }
225
226    fn is_finished(&self) -> bool {
227        true
228    }
229
230    fn take_error(&self) -> Option<FilePickerError> {
231        None
232    }
233}
234
235/// Wraps an eager [`FilePicker::pick_folder`] as a [`FolderStream`] by walking
236/// the whole tree up front. Used as the default for every backend that does not
237/// stream natively.
238fn eager_folder_stream(
239    folder: PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>,
240) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
241    Box::pin(async move {
242        match folder.await? {
243            None => Ok(None),
244            Some(entry) => {
245                let files = collect_files(entry).await;
246                Ok(Some(Rc::new(ReadyFolderStream {
247                    ready: RefCell::new(files),
248                }) as FolderStreamRef))
249            }
250        }
251    })
252}
253
254/// Presents native file and folder pickers.
255pub trait FilePicker {
256    /// Presents a single-file picker. Resolves to `None` if cancelled.
257    fn pick_file(
258        &self,
259        options: FilePickerOptions,
260    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
261
262    /// Presents a folder/tree picker. Resolves to `None` if cancelled.
263    fn pick_folder(
264        &self,
265        options: FilePickerOptions,
266    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
267
268    /// Presents a folder picker and streams the tree's files as they are
269    /// discovered (see [`FolderStream`]).
270    ///
271    /// The default walks the picked folder eagerly via [`pick_folder`] and
272    /// yields every file at once; backends served by a slow provider (Android's
273    /// Storage Access Framework) override this to stream during the walk.
274    /// Resolves to `None` if cancelled.
275    ///
276    /// [`pick_folder`]: FilePicker::pick_folder
277    fn pick_folder_streaming(
278        &self,
279        options: FilePickerOptions,
280    ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
281        eager_folder_stream(self.pick_folder(options))
282    }
283
284    /// Reclaims selections whose results arrived after the requesting
285    /// composition was torn down. On Android the activity (and the native app)
286    /// can be destroyed and recreated while the SAF picker is in front, so a
287    /// pick in flight at that moment would otherwise be lost; the app drains
288    /// this on startup to recover it. Returns the orphaned selections, usually
289    /// none. Backends that never lose a result (desktop, web, iOS, the
290    /// fallbacks) keep the default empty implementation.
291    fn take_resumed_picks(&self) -> Vec<ResumedPick> {
292        Vec::new()
293    }
294
295    /// Presents a save destination chooser and writes `request.bytes` there.
296    /// Resolves to `Ok(false)` if the user cancelled, `Ok(true)` once written.
297    /// Backends without a save affordance report
298    /// [`FilePickerError::UnsupportedPlatform`].
299    fn save_file(&self, request: SaveFileRequest) -> PickerFuture<Result<bool, FilePickerError>> {
300        let _ = request;
301        Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
302    }
303}
304
305/// Shared handle to a [`FilePicker`].
306pub type FilePickerRef = Rc<dyn FilePicker>;
307
308thread_local! {
309    static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
310}
311
312/// Registers the platform-provided picker (Android SAF / iOS UIDocumentPicker).
313///
314/// The cranpose crate's Android and iOS backends call this during startup, when
315/// they have access to the Activity / root view controller. Once registered it
316/// takes precedence over the built-in desktop/web pickers.
317pub fn set_platform_file_picker(picker: FilePickerRef) {
318    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
319}
320
321/// Removes any registered platform picker (used in tests and teardown).
322pub fn clear_platform_file_picker() {
323    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
324}
325
326fn registered_platform_file_picker() -> Option<FilePickerRef> {
327    PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
328}
329
330/// The picker installed by [`ProvideFilePicker`]: a registered platform picker
331/// if present, otherwise the built-in backend for this target.
332struct PlatformFilePicker;
333
334impl FilePicker for PlatformFilePicker {
335    fn pick_file(
336        &self,
337        options: FilePickerOptions,
338    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
339        if let Some(picker) = registered_platform_file_picker() {
340            return picker.pick_file(options);
341        }
342        builtin_pick(options, PickedKind::File)
343    }
344
345    fn pick_folder(
346        &self,
347        options: FilePickerOptions,
348    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
349        if let Some(picker) = registered_platform_file_picker() {
350            return picker.pick_folder(options);
351        }
352        builtin_pick(options, PickedKind::Folder)
353    }
354
355    fn pick_folder_streaming(
356        &self,
357        options: FilePickerOptions,
358    ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
359        if let Some(picker) = registered_platform_file_picker() {
360            return picker.pick_folder_streaming(options);
361        }
362        eager_folder_stream(builtin_pick(options, PickedKind::Folder))
363    }
364
365    fn take_resumed_picks(&self) -> Vec<ResumedPick> {
366        if let Some(picker) = registered_platform_file_picker() {
367            return picker.take_resumed_picks();
368        }
369        Vec::new()
370    }
371
372    fn save_file(&self, request: SaveFileRequest) -> PickerFuture<Result<bool, FilePickerError>> {
373        if let Some(picker) = registered_platform_file_picker() {
374            return picker.save_file(request);
375        }
376        builtin_save(request)
377    }
378}
379
380#[cfg_attr(
381    not(any(feature = "file-picker-native", feature = "file-picker-web")),
382    allow(unused_variables)
383)]
384fn builtin_pick(
385    options: FilePickerOptions,
386    kind: PickedKind,
387) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
388    #[cfg(all(
389        not(target_arch = "wasm32"),
390        not(target_os = "android"),
391        not(target_os = "ios"),
392        feature = "file-picker-native"
393    ))]
394    {
395        return desktop::pick(options, kind);
396    }
397
398    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
399    {
400        return web::pick(options, kind);
401    }
402
403    #[allow(unreachable_code)]
404    Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
405}
406
407#[cfg_attr(
408    not(any(feature = "file-picker-native", feature = "file-picker-web")),
409    allow(unused_variables)
410)]
411fn builtin_save(request: SaveFileRequest) -> PickerFuture<Result<bool, FilePickerError>> {
412    #[cfg(all(
413        not(target_arch = "wasm32"),
414        not(target_os = "android"),
415        not(target_os = "ios"),
416        feature = "file-picker-native"
417    ))]
418    {
419        return desktop::save(request);
420    }
421
422    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
423    {
424        return web::save(request);
425    }
426
427    #[allow(unreachable_code)]
428    Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
429}
430
431/// The default picker (the platform backend).
432pub fn default_file_picker() -> FilePickerRef {
433    Rc::new(PlatformFilePicker)
434}
435
436/// The [`CompositionLocal`] carrying the active [`FilePicker`].
437pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
438    thread_local! {
439        static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
440    }
441
442    LOCAL_FILE_PICKER.with(|cell| {
443        cell.borrow_mut()
444            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
445            .clone()
446    })
447}
448
449/// Provides the default [`FilePicker`] to descendant composables.
450#[allow(non_snake_case)]
451#[composable]
452pub fn ProvideFilePicker(content: impl FnOnce()) {
453    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
454    let picker_local = local_file_picker();
455
456    CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
457        content();
458    });
459}
460
461#[cfg(all(
462    not(target_arch = "wasm32"),
463    not(target_os = "android"),
464    not(target_os = "ios"),
465    feature = "file-picker-native"
466))]
467mod desktop;
468
469#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
470mod web;
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn options_builder_sets_title_and_filters() {
478        let options = FilePickerOptions::default()
479            .with_title("Pick audio")
480            .with_filter(FileFilter::new("Audio", &["mp3", "flac"]));
481        assert_eq!(options.title.as_deref(), Some("Pick audio"));
482        assert_eq!(options.filters.len(), 1);
483        assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
484    }
485
486    #[test]
487    fn default_picker_is_created() {
488        let picker = default_file_picker();
489        assert_eq!(Rc::strong_count(&picker), 1);
490    }
491
492    #[test]
493    fn registered_platform_picker_takes_precedence() {
494        struct Marker;
495        impl FilePicker for Marker {
496            fn pick_file(
497                &self,
498                _options: FilePickerOptions,
499            ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
500                Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
501            }
502            fn pick_folder(
503                &self,
504                _options: FilePickerOptions,
505            ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
506                Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
507            }
508        }
509
510        clear_platform_file_picker();
511        assert!(registered_platform_file_picker().is_none());
512        set_platform_file_picker(Rc::new(Marker));
513        assert!(registered_platform_file_picker().is_some());
514
515        let result =
516            pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
517        assert!(matches!(result, Err(FilePickerError::Failed(_))));
518        clear_platform_file_picker();
519    }
520}