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}