Import from an SD card or card reader on Android
Import was switched off on Android: `imports_supported` was true only for `target_os = "linux"`, and its comment said Android has no path to read a card by and nowhere to write the copies. Neither holds. With "all files access" (MANAGE_EXTERNAL_STORAGE, API 30) an app reads the root of an SD card or a USB card reader by path, `/storage/9C33-6BBD`, and the importer only ever writes into its own staging directory, which is a plain directory on Android too. So the engine runs unchanged; what was missing was finding the card and the permission. - The manifest declares MANAGE_EXTERNAL_STORAGE, and READ_EXTERNAL_STORAGE up to API 29 with requestLegacyExternalStorage, which is the same access on 28 and 29. - Cards.java lists the mounted non-primary volumes through StorageManager and opens the system "All files access" page for this app. dr_ui::cards is the JNI bridge, through saf's helpers. - The import page on Android asks for the permission with an "Allow access" button until it has it, rather than showing an empty list that reads as "no card", and watches for the grant so the list fills in when the user comes back from settings. Google Play restricts this permission to file managers and the like; DarkRoom is sideloaded, so that does not apply.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
//! 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<dr_plat::Volume> {
|
||||
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::<jni::objects::JObjectArray>(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<dr_plat::Volume> = 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);
|
||||
}
|
||||
}
|
||||
+100
-1
@@ -68,6 +68,13 @@ pub struct ImportController {
|
||||
selected: Cell<i32>,
|
||||
/// The source. Typed or chosen; a card the app did not find is still a card.
|
||||
card: RefCell<String>,
|
||||
/// Android only: the user has not granted "all files access", so no card
|
||||
/// can be read and the page asks for it instead of listing volumes.
|
||||
needs_access: Cell<bool>,
|
||||
/// Android only: watches for that grant after the settings page has been
|
||||
/// opened, so the list fills in when the user comes back.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
access_watch: RefCell<Option<slint::Timer>>,
|
||||
|
||||
/// What the last survey found, `None` before one has run.
|
||||
survey: RefCell<Option<(usize, u64)>>,
|
||||
@@ -106,6 +113,8 @@ impl ImportController {
|
||||
volumes: RefCell::new(Vec::new()),
|
||||
selected: Cell::new(-1),
|
||||
card: RefCell::new(String::new()),
|
||||
needs_access: Cell::new(false),
|
||||
access_watch: RefCell::new(None),
|
||||
survey: RefCell::new(None),
|
||||
surveying: Cell::new(false),
|
||||
upload_target: RefCell::new(String::new()),
|
||||
@@ -137,7 +146,7 @@ impl ImportController {
|
||||
|
||||
/// Look for cards. Cheap, and safe to call whenever the page is shown.
|
||||
fn refresh_volumes(&self) {
|
||||
let found = dr_plat::volumes();
|
||||
let found = self.find_volumes();
|
||||
// Keep a typed path: a refresh must not discard what the user entered
|
||||
// because the app happened to find three other volumes.
|
||||
if self.card.borrow().is_empty() {
|
||||
@@ -160,6 +169,34 @@ impl ImportController {
|
||||
*self.volumes.borrow_mut() = found;
|
||||
}
|
||||
|
||||
/// The mount table, on the desktop.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
|
||||
dr_plat::volumes()
|
||||
}
|
||||
|
||||
/// `StorageManager`, on Android, once the user has allowed the app to read
|
||||
/// a card at all (see [`crate::cards`]).
|
||||
///
|
||||
/// The path cannot be typed here — the field is hidden on Android — so a
|
||||
/// remembered path whose card has been taken out is dropped rather than
|
||||
/// left selected with no way to change it.
|
||||
#[cfg(target_os = "android")]
|
||||
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
|
||||
let granted = crate::cards::has_access();
|
||||
self.needs_access.set(!granted);
|
||||
let found = if granted {
|
||||
crate::cards::volumes()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let card = self.card.borrow().clone();
|
||||
if !found.iter().any(|v| v.path.display().to_string() == card) {
|
||||
self.card.borrow_mut().clear();
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The transfer options a run is started with.
|
||||
fn ingest_options(&self) -> Options {
|
||||
let stored = self.options();
|
||||
@@ -239,6 +276,7 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
|
||||
!card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)),
|
||||
);
|
||||
window.set_import_card_path(card.into());
|
||||
window.set_import_needs_access(ctl.needs_access.get());
|
||||
|
||||
window.set_import_surveying(ctl.surveying.get());
|
||||
window.set_import_survey_summary(
|
||||
@@ -428,6 +466,20 @@ where
|
||||
render(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let context = context.clone();
|
||||
window.on_import_grant_access(move || {
|
||||
#[cfg(target_os = "android")]
|
||||
watch_for_access(&weak, &ctl, &context);
|
||||
// Nothing to grant elsewhere; the control is shown only when
|
||||
// `needs_access` is set, which only Android sets.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = (&weak, &ctl, &context);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Options.
|
||||
@@ -567,6 +619,53 @@ fn wire_running<C, F>(
|
||||
|
||||
/// Take a source the user named — typed, or chosen in the dialogue — rather
|
||||
/// than one from the volume list, and count what is on it.
|
||||
/// Send the user to the system page that grants "all files access", and
|
||||
/// fill the page in once they have.
|
||||
///
|
||||
/// The grant is a settings switch, not a dialog with an answer, and nothing
|
||||
/// tells the app when it flips. So the page asks again twice a second until
|
||||
/// it has it, or for five minutes — long enough to find the switch, short
|
||||
/// enough that a user who walked away is not polled for ever. Coming back to
|
||||
/// the page later re-checks anyway (`import_open`).
|
||||
#[cfg(target_os = "android")]
|
||||
fn watch_for_access<C>(weak: &slint::Weak<AppWindow>, ctl: &Rc<ImportController>, context: &Rc<C>)
|
||||
where
|
||||
C: Fn() -> Option<Context> + 'static,
|
||||
{
|
||||
crate::cards::request_access();
|
||||
|
||||
let since = std::time::Instant::now();
|
||||
let (weak, held, context) = (weak.clone(), ctl.clone(), context.clone());
|
||||
let timer = slint::Timer::default();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(500),
|
||||
move || {
|
||||
let granted = crate::cards::has_access();
|
||||
if !granted && since.elapsed() < std::time::Duration::from_secs(300) {
|
||||
return;
|
||||
}
|
||||
if let Some(t) = held.access_watch.borrow().as_ref() {
|
||||
t.stop();
|
||||
}
|
||||
// Released outside its own callback, as `saf::pick_tree` does: a
|
||||
// timer dropped from inside the closure it is running is dropping
|
||||
// that closure mid-call.
|
||||
let release = held.clone();
|
||||
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
|
||||
release.access_watch.borrow_mut().take();
|
||||
});
|
||||
if granted {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
held.refresh_volumes();
|
||||
survey(&w, &held, &context);
|
||||
render(&w, &held);
|
||||
}
|
||||
},
|
||||
);
|
||||
*ctl.access_watch.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
fn set_card(
|
||||
w: &AppWindow,
|
||||
ctl: &Rc<ImportController>,
|
||||
|
||||
@@ -24,6 +24,7 @@ mod albums_ui;
|
||||
#[cfg(all(feature = "automation", unix))]
|
||||
mod automation;
|
||||
mod bursts;
|
||||
mod cards;
|
||||
mod collections_ui;
|
||||
#[cfg(test)]
|
||||
mod decoder_seam;
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ use jni::strings::JNIStr;
|
||||
/// Run `body` with the application context ndk_context holds, on whatever
|
||||
/// thread this is. It is a `Context`, not the activity — see
|
||||
/// `FolderPicker.start` for what that changes.
|
||||
fn call<T>(
|
||||
pub(crate) fn call<T>(
|
||||
what: &str,
|
||||
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
||||
) -> Result<T, String> {
|
||||
@@ -62,7 +62,7 @@ fn call<T>(
|
||||
/// or the application context behind it) the boot loader defined, and the
|
||||
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
|
||||
/// which is what the first build on the tablet did.
|
||||
fn class<'local>(
|
||||
pub(crate) fn class<'local>(
|
||||
env: &mut jni::Env<'local>,
|
||||
activity: &JObject,
|
||||
name: &JNIStr,
|
||||
@@ -80,7 +80,7 @@ fn class<'local>(
|
||||
}
|
||||
|
||||
/// A Java string result, or `None` for null.
|
||||
fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
||||
pub(crate) fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user