//! TRACES: FR-CAT-10 | NFR-PORT-1 //! Finding a camera card on Android, and the permission that makes it readable. //! //! `dr_plat::volumes` reads the mount table, which Android does not let an //! app read; the volumes are listed by `StorageManager`, which is Java. So //! this is the Android half of "where is the card": `Cards.java` does the //! asking and this is the JNI bridge to it, through the helpers `saf` already //! has. //! //! What comes back is an ordinary path — `/storage/9C33-6BBD` — and the rest //! of the import reads it exactly as it reads `/run/media/…` on the desktop. //! That only works once the user has granted "all files access", which is //! [`has_access`] and [`request_access`]. use std::path::PathBuf; /// Whether the app may read a card's files by path. #[cfg(target_os = "android")] pub fn has_access() -> bool { crate::saf::call("checking card access", |env, context| { let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?; env.call_static_method( &cls, jni::jni_str!("hasAccess"), jni::jni_sig!("(Landroid/content/Context;)Z"), &[context.into()], )? .z() }) .unwrap_or_else(|e| { log::warn!("{e}"); false }) } /// Open the system page where the user grants it. #[cfg(target_os = "android")] pub fn request_access() { let opened = crate::saf::call("asking for card access", |env, context| { let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?; env.call_static_method( &cls, jni::jni_str!("requestAccess"), jni::jni_sig!("(Landroid/content/Context;)V"), &[context.into()], )?; Ok(()) }); if let Err(e) = opened { log::warn!("{e}"); } } /// Every mounted volume that is not the device's own storage. /// /// Empty when there is none, and when the platform could not be asked — the /// page says "insert a card" either way, which is the thing to do in both. #[cfg(target_os = "android")] pub fn volumes() -> Vec { let lines = crate::saf::call("listing storage volumes", |env, context| { let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?; let value = env .call_static_method( &cls, jni::jni_str!("volumes"), jni::jni_sig!("(Landroid/content/Context;)[Ljava/lang/String;"), &[context.into()], )? .l()?; if value.is_null() { return Ok(Vec::new()); } let array = env.cast_local::(value)?; let count = array.len(env)?; let mut out = Vec::with_capacity(count); for i in 0..count { let element = array.get_element(env, i)?; if let Some(line) = crate::saf::text(env, element)? { out.push(line); } } Ok(out) }) .unwrap_or_else(|e| { log::warn!("{e}"); Vec::new() }); let mut found: Vec = lines .iter() .filter_map(|line| parse(line)) .map(|(path, label, removable)| dr_plat::Volume { has_dcim: path.join("DCIM").is_dir(), label, removable, path, }) .collect(); // The order `dr_plat::volumes` gives: likely cards first, then by name. found.sort_by(|a, b| { b.is_likely_card() .cmp(&a.is_likely_card()) .then_with(|| a.label.cmp(&b.label)) }); found } /// One line from `Cards.volumes`: `path \t description \t removable`. /// /// `None` for a line that does not have the three fields, which would be a /// mismatch between the two halves rather than anything a device does. #[cfg_attr(not(target_os = "android"), allow(dead_code))] fn parse(line: &str) -> Option<(PathBuf, String, bool)> { let mut fields = line.split('\t'); let path = fields.next().filter(|p| !p.is_empty())?; let label = fields.next()?; let removable = fields.next()? == "1"; if fields.next().is_some() { return None; } Some((PathBuf::from(path), label.to_string(), removable)) } #[cfg(test)] mod tests { use super::*; #[test] fn a_volume_line_is_read_as_cards_java_writes_it() { // The format is a contract between two files in two languages, and // only a device would otherwise notice it break. let java = include_str!( "../../../apps/darkroom-android/android/java/paris/tourolle/darkroom/Cards.java" ); assert!( java.contains(r#"out.add(path + "\t" + description.replace('\t', ' ') + "\t""#), "Cards.volumes no longer writes the line this parses" ); assert_eq!( parse("/storage/9C33-6BBD\tSanDisk SD card\t1"), Some(( PathBuf::from("/storage/9C33-6BBD"), "SanDisk SD card".to_string(), true )) ); assert_eq!( parse("/storage/1234-ABCD\tUSB drive\t0").map(|v| v.2), Some(false) ); } #[test] fn a_malformed_line_is_dropped_rather_than_guessed_at() { assert_eq!(parse(""), None); assert_eq!(parse("/storage/9C33-6BBD"), None); assert_eq!(parse("\tlabel\t1"), None); assert_eq!(parse("/a\tb\t1\textra"), None); } }