Skip to main content

cranpose_services/
image_picker.rs

1//! Photo/image picker: choose a single image from the user's photo library.
2//!
3//! On iOS "photos" and "files" are distinct — a scanner's document photos live
4//! in the Photos library, which the file picker (`UIDocumentPicker`) cannot
5//! reach. The iOS backend registers a `UIImagePickerController`-based photo
6//! picker through [`set_platform_image_picker`]. The compiled-in default
7//! delegates to the file picker with an image filter, which is the right
8//! behavior on desktop (a file dialog), the web (`<input accept="image/*">`),
9//! and Android (SAF), where a plain file picker already surfaces images.
10
11use crate::file_picker::{default_file_picker, FileFilter, FilePickerOptions, PickerFuture};
12use crate::registry::ServiceRegistry;
13use cranpose_core::compositionLocalOfWithPolicy;
14use cranpose_core::CompositionLocal;
15use cranpose_core::CompositionLocalProvider;
16use cranpose_macros::composable;
17use std::cell::RefCell;
18use std::sync::Arc;
19
20/// Image extensions offered when the picker falls back to the file picker.
21pub const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "heic", "heif"];
22
23#[derive(thiserror::Error, Debug)]
24pub enum ImagePickerError {
25    #[error("image picking is not supported on this platform")]
26    Unsupported,
27    #[error("failed to pick an image: {0}")]
28    Failed(String),
29}
30
31/// Where an image comes from.
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum ImageSource {
34    /// The user's photo library (or a file dialog on platforms without one).
35    PhotoLibrary,
36    /// A photo captured with the device camera.
37    Camera,
38}
39
40/// Picks or captures a single image and returns its encoded bytes.
41pub trait ImagePicker: Send + Sync {
42    /// Present a picker/camera for `source` and resolve to the chosen image's
43    /// encoded bytes, or `None` if the user cancelled.
44    fn pick_image(
45        &self,
46        source: ImageSource,
47    ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>>;
48}
49
50pub type ImagePickerRef = Arc<dyn ImagePicker>;
51
52static PLATFORM_IMAGE_PICKER: ServiceRegistry<dyn ImagePicker> = ServiceRegistry::new();
53
54/// Installs a platform image picker, replacing any previously installed one.
55/// The iOS backend registers a photo-library picker here.
56pub fn set_platform_image_picker(picker: ImagePickerRef) {
57    PLATFORM_IMAGE_PICKER.set(picker);
58}
59
60/// Removes any registered platform image picker (tests and teardown).
61pub fn clear_platform_image_picker() {
62    PLATFORM_IMAGE_PICKER.clear();
63}
64
65fn registered_platform_image_picker() -> Option<ImagePickerRef> {
66    PLATFORM_IMAGE_PICKER.get_or_warn("image picker")
67}
68
69struct PlatformImagePicker;
70
71impl ImagePicker for PlatformImagePicker {
72    fn pick_image(
73        &self,
74        source: ImageSource,
75    ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>> {
76        if let Some(picker) = registered_platform_image_picker() {
77            return picker.pick_image(source);
78        }
79        // No platform picker: a live camera needs one, but a library pick can
80        // fall back to the file picker filtered to images.
81        if source == ImageSource::Camera {
82            return Box::pin(async { Err(ImagePickerError::Unsupported) });
83        }
84        Box::pin(async {
85            let picker = default_file_picker();
86            let options = FilePickerOptions::default()
87                .with_title("Choose image")
88                .with_filter(FileFilter::new("Images", IMAGE_EXTENSIONS));
89            match picker.pick_file(options).await {
90                Ok(Some(entry)) => match entry.read_all().await {
91                    Ok(bytes) => Ok(Some(bytes)),
92                    Err(error) => Err(ImagePickerError::Failed(error.to_string())),
93                },
94                Ok(None) => Ok(None),
95                Err(error) => Err(ImagePickerError::Failed(error.to_string())),
96            }
97        })
98    }
99}
100
101pub fn default_image_picker() -> ImagePickerRef {
102    Arc::new(PlatformImagePicker)
103}
104
105pub fn local_image_picker() -> CompositionLocal<ImagePickerRef> {
106    thread_local! {
107        static LOCAL_IMAGE_PICKER: RefCell<Option<CompositionLocal<ImagePickerRef>>> = const { RefCell::new(None) };
108    }
109
110    LOCAL_IMAGE_PICKER.with(|cell| {
111        let mut local = cell.borrow_mut();
112        local
113            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_image_picker, Arc::ptr_eq))
114            .clone()
115    })
116}
117
118#[allow(non_snake_case)]
119#[composable]
120pub fn ProvideImagePicker(content: impl FnOnce()) {
121    let picker = cranpose_core::remember(default_image_picker).with(|state| state.clone());
122    let local = local_image_picker();
123
124    CompositionLocalProvider(vec![local.provides(picker)], move || {
125        content();
126    });
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::sync::RwLock;
133
134    struct FixedImagePicker {
135        bytes: RwLock<Option<Vec<u8>>>,
136    }
137
138    impl ImagePicker for FixedImagePicker {
139        fn pick_image(
140            &self,
141            _source: ImageSource,
142        ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>> {
143            let bytes = self.bytes.read().unwrap().clone();
144            Box::pin(async move { Ok(bytes) })
145        }
146    }
147
148    #[test]
149    fn registered_image_picker_takes_precedence() {
150        let _guard = crate::registry::test_service_guard();
151        clear_platform_image_picker();
152        set_platform_image_picker(Arc::new(FixedImagePicker {
153            bytes: RwLock::new(Some(vec![1, 2, 3])),
154        }));
155        let picker = default_image_picker();
156        let result = pollster::block_on(picker.pick_image(ImageSource::Camera));
157        assert_eq!(result.unwrap(), Some(vec![1, 2, 3]));
158        clear_platform_image_picker();
159    }
160
161    #[test]
162    fn camera_is_unsupported_without_a_platform_picker() {
163        let _guard = crate::registry::test_service_guard();
164        clear_platform_image_picker();
165        let result = pollster::block_on(default_image_picker().pick_image(ImageSource::Camera));
166        assert!(matches!(result, Err(ImagePickerError::Unsupported)));
167    }
168}