Skip to main content

day_piece_camera/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-piece-camera: a camera viewfinder with photo capture for Day apps.
5//!
6//! An external Day Piece: one Rust API in front, the platform's camera behind it (AVFoundation
7//! on iOS, CameraX on Android, the NDK camera kit on HarmonyOS), declared as
8//! `[package.metadata.day.*]` in Cargo.toml and staged by `day build`. The renderers register
9//! link-time into each backend's slice; nothing in day knows this crate exists. The desktops and
10//! the web have no camera arm: there the kind realizes day's placeholder leaf, and [`support`]
11//! answers `Unsupported` so an app can leave its camera page out.
12//!
13//! ```ignore
14//! let shutter = Trigger::new();
15//! let state = Signal::new(CameraState::Idle);
16//! let photo: Signal<Option<Photo>> = Signal::new(None);
17//! camera()
18//!     .auto_request()      // ask for the permission the first time the session is wanted
19//!     .capture(shutter)    // fire it to take a photo
20//!     .state(state)        // what the camera is doing
21//!     .photo(photo)        // the last capture, a JPEG in the app's cache directory
22//!     .frame(320.0, 240.0)
23//! ```
24//!
25//! The piece never starts a session without the camera permission: it reads
26//! `day_part_permissions::status` first, and only [`Camera::auto_request`] makes it ask.
27//! Prompting otherwise belongs to the app, which owns the rationale and decides when a system
28//! dialog is welcome.
29
30use std::io;
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33
34use day_core::{BuildCx, Flex, Piece, RNode, with_tree};
35use day_part_permissions::{self as perms, Permission, Status};
36use day_pieces::{Decorated, IntoReactive, Reactive};
37use day_reactive::{AnyBinding, Binding, Signal, Trigger, on_main, watch};
38use day_spec::Event;
39
40pub const KIND: &str = "day.piece.camera";
41
42/// Which camera the viewfinder shows.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum Facing {
45    /// The rear camera, the one a photo is usually taken with.
46    #[default]
47    Back,
48    /// The screen-side camera.
49    Front,
50}
51
52impl Facing {
53    /// The value that crosses the native boundary: 0 back, 1 front.
54    pub fn code(self) -> i32 {
55        match self {
56            Facing::Back => 0,
57            Facing::Front => 1,
58        }
59    }
60
61    /// The other camera.
62    pub fn flipped(self) -> Facing {
63        match self {
64            Facing::Back => Facing::Front,
65            Facing::Front => Facing::Back,
66        }
67    }
68}
69
70/// Full props (realize). `active` is whether the session should run from the first frame; it
71/// is only ever true when the permission is already held.
72#[derive(Clone, Debug, Default, PartialEq)]
73pub struct CameraProps {
74    pub facing: Facing,
75    pub active: bool,
76}
77
78/// Sparse reconcile patch: the commands Rust sends the native camera after build.
79#[derive(Clone, Debug, PartialEq)]
80pub enum CameraPatch {
81    /// Run the session. Sent only once the permission is held.
82    Start,
83    /// Stop the session and release the camera.
84    Stop,
85    /// Take one photo; the native side answers with a [`report::PHOTO`] event.
86    Capture,
87    /// Switch cameras, live.
88    Facing(Facing),
89    /// HarmonyOS only: the ArkTS surface the preview renders into is ready; the text is its id.
90    /// The front-end forwards the [`report::SURFACE`] event as this patch so the renderer can
91    /// hand the surface to the NDK camera. Every other backend ignores it.
92    Surface(String),
93}
94
95/// What the camera is doing, as the native side reports it.
96#[derive(Clone, Debug, Default, PartialEq, Eq)]
97pub enum CameraState {
98    /// No session: never started, stopped, or waiting for a permission the app has not asked for.
99    #[default]
100    Idle,
101    /// The session is being configured and started.
102    Starting,
103    /// The preview is live and a capture will succeed.
104    Running,
105    /// This device has no usable camera: the iOS Simulator, a desktop without one.
106    Unavailable,
107    /// The permission was refused, or the OS restricts it, so the session cannot start.
108    Denied,
109    /// The native side failed; the text is its message.
110    Error(String),
111}
112
113impl CameraState {
114    /// The locale-independent label a script can assert on.
115    pub fn label(&self) -> &'static str {
116        match self {
117            CameraState::Idle => "idle",
118            CameraState::Starting => "starting",
119            CameraState::Running => "running",
120            CameraState::Unavailable => "unavailable",
121            CameraState::Denied => "denied",
122            CameraState::Error(_) => "error",
123        }
124    }
125
126    /// The state a native report means. [`report::PHOTO`] is not a state and maps to `None`.
127    pub fn from_report(num: f64, text: &str) -> Option<CameraState> {
128        Some(match num as i32 {
129            report::STOPPED => CameraState::Idle,
130            report::STARTING => CameraState::Starting,
131            report::RUNNING => CameraState::Running,
132            report::UNAVAILABLE => CameraState::Unavailable,
133            report::DENIED => CameraState::Denied,
134            report::ERROR => CameraState::Error(text.to_string()),
135            _ => return None,
136        })
137    }
138}
139
140/// The codes the native sides report through `Event::Custom { num, .. }`. `num` is the
141/// discriminator: a Custom event's `tag` is empty across JNI and the C ABI, so it is never
142/// matched on.
143pub mod report {
144    pub const STOPPED: i32 = 0;
145    pub const STARTING: i32 = 1;
146    pub const RUNNING: i32 = 2;
147    pub const UNAVAILABLE: i32 = 3;
148    pub const DENIED: i32 = 4;
149    pub const ERROR: i32 = 5;
150    /// A photo landed on disk. `text` is `path`, `width` and `height`, 0x1F-separated.
151    pub const PHOTO: i32 = 6;
152    /// HarmonyOS only: the ArkTS side's preview surface exists; `text` is its surface id.
153    pub const SURFACE: i32 = 7;
154}
155
156/// A photo the camera took: a JPEG under the app's cache directory.
157///
158/// The file is the app's to keep, move, or delete; the piece never touches the photo library,
159/// so no Photos permission is involved. [`Photo::bytes`] reads it on demand.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct Photo {
162    pub path: PathBuf,
163    pub width: u32,
164    pub height: u32,
165}
166
167impl Photo {
168    /// Parse a [`report::PHOTO`] payload: `path`, then the pixel size, 0x1F-separated. A
169    /// payload without a size is still a photo; the size then reads 0 × 0.
170    pub fn from_report(text: &str) -> Option<Photo> {
171        let mut parts = text.split('\u{1f}');
172        let path = parts.next().filter(|p| !p.is_empty())?;
173        let width = parts
174            .next()
175            .and_then(|w| w.trim().parse().ok())
176            .unwrap_or(0);
177        let height = parts
178            .next()
179            .and_then(|h| h.trim().parse().ok())
180            .unwrap_or(0);
181        Some(Photo {
182            path: PathBuf::from(path),
183            width,
184            height,
185        })
186    }
187
188    /// The JPEG bytes, read from the cache file; shared, since a photo is large and a viewer
189    /// keeps a reference to it.
190    pub fn bytes(&self) -> io::Result<Arc<Vec<u8>>> {
191        std::fs::read(&self.path).map(Arc::new)
192    }
193
194    /// The file's name, for a caption.
195    pub fn file_name(&self) -> String {
196        Path::new(&self.path)
197            .file_name()
198            .map(|n| n.to_string_lossy().into_owned())
199            .unwrap_or_default()
200    }
201}
202
203/// What this backend realizes: `Native` where a camera arm is compiled in, `Unsupported`
204/// elsewhere, where the kind renders day's placeholder leaf. `Native` is a promise about the
205/// code, not the hardware; the iOS Simulator reports [`CameraState::Unavailable`] at run time.
206pub fn support() -> day_spec::Support {
207    if cfg!(any(
208        all(feature = "uikit", target_os = "ios"),
209        all(feature = "material", target_os = "android"),
210        all(feature = "arkui", target_env = "ohos"),
211    )) {
212        day_spec::Support::Native
213    } else {
214        day_spec::Support::Unsupported
215    }
216}
217
218/// A native camera viewfinder. Bind the session to `.active(signal)`, take photos with
219/// `.capture(trigger)`, and read them back through `.photo(signal)`.
220pub struct Camera {
221    facing: Reactive<Facing>,
222    active: Reactive<bool>,
223    auto_request: bool,
224    capture: Option<Trigger>,
225    state: Option<AnyBinding<CameraState>>,
226    photo: Option<AnyBinding<Option<Photo>>>,
227}
228
229/// `camera()`: the rear camera's live preview, running whenever the permission is held.
230pub fn camera() -> Camera {
231    Camera {
232        facing: Reactive::Const(Facing::Back),
233        active: Reactive::Const(true),
234        auto_request: false,
235        capture: None,
236        state: None,
237        photo: None,
238    }
239}
240
241#[day_pieces::builder(CameraBuilder)]
242impl Camera {
243    /// Which camera to show: a constant, a `Signal<Facing>`, or a `Fn() -> Facing`. When it is
244    /// reactive the viewfinder switches live.
245    pub fn facing<M>(mut self, facing: impl IntoReactive<Facing, M>) -> Self {
246        self.facing = facing.into_reactive();
247        self
248    }
249
250    /// Whether the session should run: a constant, a `Signal<bool>`, or a closure. Default
251    /// true. The session runs only while this is true and the permission is held; `false`
252    /// stops it and releases the camera.
253    pub fn active<M>(mut self, active: impl IntoReactive<bool, M>) -> Self {
254        self.active = active.into_reactive();
255        self
256    }
257
258    /// Ask for the camera permission the first time the session is wanted and the OS has not
259    /// decided yet. Opt-in: an app that wants its own rationale screen first requests through
260    /// `day_part_permissions` itself and leaves this off.
261    pub fn auto_request(mut self) -> Self {
262        self.auto_request = true;
263        self
264    }
265
266    /// Take a photo whenever `trigger` fires.
267    pub fn capture(mut self, trigger: Trigger) -> Self {
268        self.capture = Some(trigger);
269        self
270    }
271
272    /// Where the camera reports what it is doing: a `Signal<CameraState>` or any other binding.
273    pub fn state(mut self, state: impl Binding<CameraState>) -> Self {
274        self.state = Some(AnyBinding::new(state));
275        self
276    }
277
278    /// Where each capture lands: a `Signal<Option<Photo>>` or any other binding.
279    pub fn photo(mut self, photo: impl Binding<Option<Photo>>) -> Self {
280        self.photo = Some(AnyBinding::new(photo));
281        self
282    }
283}
284
285impl Piece for Camera {
286    fn build(self, cx: &mut BuildCx) -> RNode {
287        let Camera {
288            facing,
289            active,
290            auto_request,
291            capture,
292            state,
293            photo,
294        } = self;
295        let set_state = move |next: CameraState| {
296            if let Some(state) = state
297                && state.get_untracked() != next
298            {
299                state.set(next);
300            }
301        };
302        // Whether the first frame may run: only with the permission already held. Anything else
303        // starts idle and is resolved reactively below, after the node exists to patch.
304        let wanted = active.get_untracked();
305        let held = perms::status(Permission::Camera) == Status::Granted;
306        let props = CameraProps {
307            facing: facing.get_untracked(),
308            active: wanted && held,
309        };
310        // A viewfinder fills the space it is offered (constrain via `.frame(w, h)`).
311        let node = cx.leaf(
312            KIND,
313            &props,
314            Flex {
315                grow_w: true,
316                grow_h: true,
317                ..Default::default()
318            },
319        );
320        let send = move |patch: CameraPatch| {
321            with_tree(|t| t.patch(node, Box::new(patch), false));
322        };
323        // A permission answer arrives on an unspecified thread, and the state binding never
324        // leaves the main one: the answer crosses back through this relay's `Setter`, and the
325        // binding is written from here.
326        let denied_relay: Signal<Option<CameraState>> = Signal::new(None);
327        watch(
328            move || denied_relay.get(),
329            move |next, _| {
330                if let Some(next) = next {
331                    set_state(next.clone());
332                }
333            },
334        );
335
336        // The session follows `active`, gated on the permission. Denied and restricted are
337        // reported as such; an undecided permission is asked for only under `auto_request`,
338        // and otherwise leaves the camera idle for the app to resolve.
339        let start = move || match perms::status(Permission::Camera) {
340            Status::Granted => send(CameraPatch::Start),
341            Status::Prompt if auto_request => {
342                set_state(CameraState::Starting);
343                // The completion runs on an unspecified thread; the patch has to be issued on
344                // the main thread, where the tree lives, and a denial reaches the state binding
345                // through the relay's `Setter`.
346                let denied = denied_relay.setter();
347                perms::request(Permission::Camera, move |status| {
348                    on_main(move || {
349                        if status == Status::Granted {
350                            with_tree(|t| t.patch(node, Box::new(CameraPatch::Start), false));
351                        } else {
352                            denied.set(Some(CameraState::Denied));
353                        }
354                    });
355                });
356            }
357            Status::Prompt => set_state(CameraState::Idle),
358            Status::Denied | Status::Restricted => set_state(CameraState::Denied),
359            // Ungated platforms answer Granted; the rest have no camera arm to start.
360            _ => set_state(CameraState::Unavailable),
361        };
362        if wanted && !held {
363            start();
364        }
365        if let Reactive::Dyn(read) = active {
366            // Only a change moves the session: the watch re-runs on any dependency of the
367            // closure, and re-sending Start to a running camera would reconfigure it.
368            let last = std::rc::Rc::new(std::cell::Cell::new(wanted));
369            watch(
370                move || read(),
371                move |now, _| {
372                    if *now == last.get() {
373                        return;
374                    }
375                    last.set(*now);
376                    if *now {
377                        start();
378                    } else {
379                        send(CameraPatch::Stop);
380                    }
381                },
382            );
383        }
384        if let Reactive::Dyn(read) = facing {
385            let last = std::rc::Rc::new(std::cell::Cell::new(props.facing));
386            watch(
387                move || read(),
388                move |f, _| {
389                    if *f != last.get() {
390                        last.set(*f);
391                        send(CameraPatch::Facing(*f));
392                    }
393                },
394            );
395        }
396        if let Some(capture) = capture {
397            watch(
398                move || capture.track(),
399                move |_, _| send(CameraPatch::Capture),
400            );
401        }
402        cx.on(node, move |ev| {
403            if let Event::Custom { num, text, .. } = ev {
404                if *num as i32 == report::SURFACE {
405                    send(CameraPatch::Surface(text.clone()));
406                    return;
407                }
408                if *num as i32 == report::PHOTO {
409                    if let Some(photo) = photo
410                        && let Some(shot) = Photo::from_report(text)
411                    {
412                        photo.set(Some(shot));
413                    }
414                    return;
415                }
416                if let Some(next) = CameraState::from_report(*num, text) {
417                    set_state(next);
418                }
419            }
420        });
421        node
422    }
423}
424
425// ---------------------------------------------------------------------------
426// Per-toolkit native renderers: the three mobile toolkits. Each registers a `Renderer`
427// link-time into its backend's `RENDERERS` slice; `#[cfg]` gates each to its feature + target.
428// ---------------------------------------------------------------------------
429
430day_pieces::glue_modules!(uikit, material, arkui);
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn photo_report_parses_path_and_size() {
438        let p = Photo::from_report("/tmp/day-camera/a.jpg\u{1f}4032\u{1f}3024").unwrap();
439        assert_eq!(p.path, PathBuf::from("/tmp/day-camera/a.jpg"));
440        assert_eq!((p.width, p.height), (4032, 3024));
441        assert_eq!(p.file_name(), "a.jpg");
442    }
443
444    #[test]
445    fn photo_report_without_size_is_still_a_photo() {
446        let p = Photo::from_report("/tmp/x.jpg").unwrap();
447        assert_eq!((p.width, p.height), (0, 0));
448        assert!(Photo::from_report("").is_none());
449    }
450
451    #[test]
452    fn state_reports_map_and_photo_is_not_a_state() {
453        assert_eq!(
454            CameraState::from_report(report::RUNNING as f64, ""),
455            Some(CameraState::Running)
456        );
457        assert_eq!(
458            CameraState::from_report(report::ERROR as f64, "boom"),
459            Some(CameraState::Error("boom".into()))
460        );
461        assert_eq!(CameraState::from_report(report::PHOTO as f64, "x"), None);
462        assert_eq!(CameraState::Unavailable.label(), "unavailable");
463    }
464
465    #[test]
466    fn facing_codes_round_trip() {
467        assert_eq!(Facing::Back.code(), 0);
468        assert_eq!(Facing::Front.code(), 1);
469        assert_eq!(Facing::Back.flipped(), Facing::Front);
470    }
471}