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/// An opaque handle to a picked file or folder.
110///
111/// This is **not** guaranteed to be a filesystem path. Use [`read_bytes`] to
112/// read a file and [`list`] to enumerate a folder's immediate children; both
113/// go through the originating system provider.
114///
115/// [`read_bytes`]: PickedEntry::read_bytes
116/// [`list`]: PickedEntry::list
117pub trait PickedEntry {
118    /// The display name (last path/URI component).
119    fn name(&self) -> String;
120
121    /// Whether this entry is a file or a folder.
122    fn kind(&self) -> PickedKind;
123
124    /// A user-facing identifier (a path or a `content://`/`file://` URI). Not
125    /// guaranteed to be a usable filesystem path.
126    fn display_path(&self) -> String;
127
128    /// Reads the full contents of a [`PickedKind::File`] entry.
129    fn read_bytes(&self) -> PickerFuture<Result<Vec<u8>, FilePickerError>>;
130
131    /// Lists the immediate children of a [`PickedKind::Folder`] entry.
132    fn list(&self) -> PickerFuture<Result<Vec<PickedEntryRef>, FilePickerError>>;
133}
134
135/// Shared handle to a [`PickedEntry`].
136pub type PickedEntryRef = Rc<dyn PickedEntry>;
137
138/// A folder being enumerated, yielding its files as the provider discovers
139/// them.
140///
141/// Walking a deep tree from a slow provider (cloud storage, a mounted WebDAV
142/// share) can take a long time, so the files are delivered incrementally rather
143/// than all at once: poll [`take_ready`] each frame to drain newly-found files,
144/// and [`is_finished`] to know when the walk is done. Callers can show the
145/// running count and start playing the first file without waiting for the rest.
146///
147/// [`take_ready`]: FolderStream::take_ready
148/// [`is_finished`]: FolderStream::is_finished
149pub trait FolderStream {
150    /// Files discovered since the previous call (drains the ready queue).
151    fn take_ready(&self) -> Vec<PickedEntryRef>;
152
153    /// Whether enumeration has finished (no more files will be discovered).
154    fn is_finished(&self) -> bool;
155
156    /// Takes the error that ended enumeration early, if any.
157    fn take_error(&self) -> Option<FilePickerError>;
158}
159
160/// Shared handle to a [`FolderStream`].
161pub type FolderStreamRef = Rc<dyn FolderStream>;
162
163/// A selection recovered by [`FilePicker::take_resumed_picks`] after the
164/// composition that requested it was destroyed mid-pick (Android recreates the
165/// activity when the SAF picker covers it on some devices). It is consumed
166/// exactly like a freshly-returned pick.
167pub enum ResumedPick {
168    /// A single picked file.
169    File(PickedEntryRef),
170    /// A picked folder, streamed like [`FilePicker::pick_folder_streaming`].
171    Folder(FolderStreamRef),
172}
173
174/// Recursively collects every [`PickedKind::File`] under `entry`.
175fn collect_files(entry: PickedEntryRef) -> PickerFuture<Vec<PickedEntryRef>> {
176    Box::pin(async move {
177        match entry.kind() {
178            PickedKind::File => vec![entry],
179            PickedKind::Folder => {
180                let mut files = Vec::new();
181                if let Ok(children) = entry.list().await {
182                    for child in children {
183                        files.extend(collect_files(child).await);
184                    }
185                }
186                files
187            }
188        }
189    })
190}
191
192/// A [`FolderStream`] whose files were all gathered up front (the default for
193/// providers that enumerate eagerly, e.g. the local filesystem). It yields
194/// everything on the first poll and is immediately finished.
195struct ReadyFolderStream {
196    ready: RefCell<Vec<PickedEntryRef>>,
197}
198
199impl FolderStream for ReadyFolderStream {
200    fn take_ready(&self) -> Vec<PickedEntryRef> {
201        std::mem::take(&mut self.ready.borrow_mut())
202    }
203
204    fn is_finished(&self) -> bool {
205        true
206    }
207
208    fn take_error(&self) -> Option<FilePickerError> {
209        None
210    }
211}
212
213/// Wraps an eager [`FilePicker::pick_folder`] as a [`FolderStream`] by walking
214/// the whole tree up front. Used as the default for every backend that does not
215/// stream natively.
216fn eager_folder_stream(
217    folder: PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>,
218) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
219    Box::pin(async move {
220        match folder.await? {
221            None => Ok(None),
222            Some(entry) => {
223                let files = collect_files(entry).await;
224                Ok(Some(Rc::new(ReadyFolderStream {
225                    ready: RefCell::new(files),
226                }) as FolderStreamRef))
227            }
228        }
229    })
230}
231
232/// Presents native file and folder pickers.
233pub trait FilePicker {
234    /// Presents a single-file picker. Resolves to `None` if cancelled.
235    fn pick_file(
236        &self,
237        options: FilePickerOptions,
238    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
239
240    /// Presents a folder/tree picker. Resolves to `None` if cancelled.
241    fn pick_folder(
242        &self,
243        options: FilePickerOptions,
244    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
245
246    /// Presents a folder picker and streams the tree's files as they are
247    /// discovered (see [`FolderStream`]).
248    ///
249    /// The default walks the picked folder eagerly via [`pick_folder`] and
250    /// yields every file at once; backends served by a slow provider (Android's
251    /// Storage Access Framework) override this to stream during the walk.
252    /// Resolves to `None` if cancelled.
253    ///
254    /// [`pick_folder`]: FilePicker::pick_folder
255    fn pick_folder_streaming(
256        &self,
257        options: FilePickerOptions,
258    ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
259        eager_folder_stream(self.pick_folder(options))
260    }
261
262    /// Reclaims selections whose results arrived after the requesting
263    /// composition was torn down. On Android the activity (and the native app)
264    /// can be destroyed and recreated while the SAF picker is in front, so a
265    /// pick in flight at that moment would otherwise be lost; the app drains
266    /// this on startup to recover it. Returns the orphaned selections, usually
267    /// none. Backends that never lose a result (desktop, web, iOS, the
268    /// fallbacks) keep the default empty implementation.
269    fn take_resumed_picks(&self) -> Vec<ResumedPick> {
270        Vec::new()
271    }
272}
273
274/// Shared handle to a [`FilePicker`].
275pub type FilePickerRef = Rc<dyn FilePicker>;
276
277thread_local! {
278    static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
279}
280
281/// Registers the platform-provided picker (Android SAF / iOS UIDocumentPicker).
282///
283/// The cranpose crate's Android and iOS backends call this during startup, when
284/// they have access to the Activity / root view controller. Once registered it
285/// takes precedence over the built-in desktop/web pickers.
286pub fn set_platform_file_picker(picker: FilePickerRef) {
287    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
288}
289
290/// Removes any registered platform picker (used in tests and teardown).
291pub fn clear_platform_file_picker() {
292    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
293}
294
295fn registered_platform_file_picker() -> Option<FilePickerRef> {
296    PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
297}
298
299/// The picker installed by [`ProvideFilePicker`]: a registered platform picker
300/// if present, otherwise the built-in backend for this target.
301struct PlatformFilePicker;
302
303impl FilePicker for PlatformFilePicker {
304    fn pick_file(
305        &self,
306        options: FilePickerOptions,
307    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
308        if let Some(picker) = registered_platform_file_picker() {
309            return picker.pick_file(options);
310        }
311        builtin_pick(options, PickedKind::File)
312    }
313
314    fn pick_folder(
315        &self,
316        options: FilePickerOptions,
317    ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
318        if let Some(picker) = registered_platform_file_picker() {
319            return picker.pick_folder(options);
320        }
321        builtin_pick(options, PickedKind::Folder)
322    }
323
324    fn pick_folder_streaming(
325        &self,
326        options: FilePickerOptions,
327    ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
328        if let Some(picker) = registered_platform_file_picker() {
329            return picker.pick_folder_streaming(options);
330        }
331        eager_folder_stream(builtin_pick(options, PickedKind::Folder))
332    }
333
334    fn take_resumed_picks(&self) -> Vec<ResumedPick> {
335        if let Some(picker) = registered_platform_file_picker() {
336            return picker.take_resumed_picks();
337        }
338        Vec::new()
339    }
340}
341
342#[cfg_attr(
343    not(any(feature = "file-picker-native", feature = "file-picker-web")),
344    allow(unused_variables)
345)]
346fn builtin_pick(
347    options: FilePickerOptions,
348    kind: PickedKind,
349) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
350    #[cfg(all(
351        not(target_arch = "wasm32"),
352        not(target_os = "android"),
353        not(target_os = "ios"),
354        feature = "file-picker-native"
355    ))]
356    {
357        return desktop::pick(options, kind);
358    }
359
360    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
361    {
362        return web::pick(options, kind);
363    }
364
365    #[allow(unreachable_code)]
366    Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
367}
368
369/// The default picker (the platform backend).
370pub fn default_file_picker() -> FilePickerRef {
371    Rc::new(PlatformFilePicker)
372}
373
374/// The [`CompositionLocal`] carrying the active [`FilePicker`].
375pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
376    thread_local! {
377        static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
378    }
379
380    LOCAL_FILE_PICKER.with(|cell| {
381        cell.borrow_mut()
382            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
383            .clone()
384    })
385}
386
387/// Provides the default [`FilePicker`] to descendant composables.
388#[allow(non_snake_case)]
389#[composable]
390pub fn ProvideFilePicker(content: impl FnOnce()) {
391    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
392    let picker_local = local_file_picker();
393
394    CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
395        content();
396    });
397}
398
399#[cfg(all(
400    not(target_arch = "wasm32"),
401    not(target_os = "android"),
402    not(target_os = "ios"),
403    feature = "file-picker-native"
404))]
405mod desktop;
406
407#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
408mod web;
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn options_builder_sets_title_and_filters() {
416        let options = FilePickerOptions::default()
417            .with_title("Pick audio")
418            .with_filter(FileFilter::new("Audio", &["mp3", "flac"]));
419        assert_eq!(options.title.as_deref(), Some("Pick audio"));
420        assert_eq!(options.filters.len(), 1);
421        assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
422    }
423
424    #[test]
425    fn default_picker_is_created() {
426        let picker = default_file_picker();
427        assert_eq!(Rc::strong_count(&picker), 1);
428    }
429
430    #[test]
431    fn registered_platform_picker_takes_precedence() {
432        struct Marker;
433        impl FilePicker for Marker {
434            fn pick_file(
435                &self,
436                _options: FilePickerOptions,
437            ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
438                Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
439            }
440            fn pick_folder(
441                &self,
442                _options: FilePickerOptions,
443            ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
444                Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
445            }
446        }
447
448        clear_platform_file_picker();
449        assert!(registered_platform_file_picker().is_none());
450        set_platform_file_picker(Rc::new(Marker));
451        assert!(registered_platform_file_picker().is_some());
452
453        let result =
454            pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
455        assert!(matches!(result, Err(FilePickerError::Failed(_))));
456        clear_platform_file_picker();
457    }
458}