Files
dtourolle caae65c78d 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.
2026-09-30 21:30:09 -04:00

381 lines
14 KiB
Rust

//! TRACES: FR-CAT-10 | NFR-PORT-1
//! Finding the card.
//!
//! FR-CAT-10 asks for removable-volume insertion to be detected "where the
//! platform permits", which is a careful phrase and this module is why. There
//! is no portable answer: Linux has a mount table and a sysfs flag, Android
//! has neither and lists its volumes through a Java service (`dr_ui::cards`).
//! So this reports what it can and returns an empty list where it cannot,
//! and every caller must still offer the user a way to say where the card is.
//!
//! # This is the one place besides `grant` that names a path
//!
//! `storage` explains why nothing above it takes a `Path`: a library location
//! is a [`crate::storage::LocalStorage::grant`] away from being a `RootId`,
//! and Android has no path to give. Volume discovery sits on the same side of
//! that line — it produces exactly what `grant` consumes, the folder that is
//! about to become a root. It is discovery of a path rather than use of one.
//!
//! # Why a heuristic rather than a device manager
//!
//! Talking to udisks2 over D-Bus would be authoritative and would add a D-Bus
//! dependency, a service that may not be running, and a permission dialog on
//! some desktops — for a question the filesystem can answer directly. The
//! mount table plus the sysfs `removable` flag covers USB card readers,
//! phones mounted as storage, and external drives. What it does not cover is
//! an internal SD reader that reports itself fixed, which is why a `DCIM`
//! directory is reported alongside and weighted more heavily than the flag:
//! a volume with `DCIM` on it is a camera card whatever sysfs believes.
use std::path::PathBuf;
// Only the mount-table reader below borrows a path; `Volume` owns one.
#[cfg(target_os = "linux")]
use std::path::Path;
/// Somewhere a card might be.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Volume {
/// What to call it in a list. The mount point's last component, which is
/// what desktops name after the filesystem label.
pub label: String,
/// Where it is mounted. Hand this to
/// [`LocalStorage::grant`](crate::storage::LocalStorage::grant).
pub path: PathBuf,
/// Whether the kernel calls the underlying device removable.
pub removable: bool,
/// Whether it holds a `DCIM` directory — the strongest signal there is
/// that this is a camera card rather than a backup drive.
pub has_dcim: bool,
}
impl Volume {
/// Whether to show this one first.
///
/// `DCIM` outranks the kernel flag: an internal SD reader often reports
/// its device as fixed, and the user with a card in it does not care what
/// sysfs thinks.
pub fn is_likely_card(&self) -> bool {
self.has_dcim || self.removable
}
}
/// TRACES: FR-CAT-10 | NFR-PORT-1
/// Whether an import can reach a card on this platform at all.
///
/// **Not the same question as "did [`volumes`] find anything".** An empty list
/// on Linux means no card is plugged in, and the user can plug one in or type
/// where it is mounted. `false` here means the operation cannot be performed
/// however hard the user tries, and the interface should not offer it.
///
/// It is `true` on Linux and on Android. On Android the card is read by
/// path too — `/storage/9C33-6BBD` — once the user has granted "all files
/// access", but finding it takes the platform's `StorageManager`, which is
/// Java. So [`volumes`] still answers empty there, and `dr_ui::cards` lists
/// the volumes and asks for the permission instead.
///
/// The engine above this takes storage traits and never a path, and the
/// importer only ever *writes* into its own staging directory, which is a
/// plain directory on every platform. So what this gates is whether a card
/// can be *read*, nothing more.
pub const fn imports_supported() -> bool {
cfg!(any(target_os = "linux", target_os = "android"))
}
/// Every mounted volume that might hold photographs.
///
/// Ordered with the likely cards first and, within each group, by label, so
/// the list does not reorder itself between two calls a second apart.
/// Returns empty where the platform does not permit the question — which is
/// not an error and must not be presented as one.
pub fn volumes() -> Vec<Volume> {
let mut found = platform_volumes();
found.sort_by(|a, b| {
b.is_likely_card()
.cmp(&a.is_likely_card())
.then_with(|| a.label.cmp(&b.label))
});
found
}
#[cfg(target_os = "linux")]
fn platform_volumes() -> Vec<Volume> {
let table = match std::fs::read_to_string("/proc/mounts") {
Ok(t) => t,
Err(e) => {
log::debug!("no mount table: {e}");
return Vec::new();
}
};
parse_mounts(&table)
.into_iter()
.filter(is_candidate)
.map(|m| {
let removable = device_is_removable(&m.device).unwrap_or(false)
// A desktop that automounts under /run/media or /media has
// already decided this is removable, and it had udisks2 to ask.
|| under_media_dir(&m.mount_point);
Volume {
label: label_for(&m.mount_point),
has_dcim: m.mount_point.join("DCIM").is_dir(),
removable,
path: m.mount_point,
}
})
.collect()
}
/// Everywhere else: no answer, and saying so is the honest result.
///
/// On Android there is no readable mount table either; the volumes come from
/// `StorageManager` through `dr_ui::cards`, which needs the JNI this crate
/// does not have.
#[cfg(not(target_os = "linux"))]
fn platform_volumes() -> Vec<Volume> {
Vec::new()
}
/// One line of the mount table.
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct Mount {
device: String,
mount_point: PathBuf,
fs_type: String,
}
/// Parse `/proc/mounts`.
///
/// Split out from the reading so it can be tested against real tables without
/// a card plugged in — which is the only way this file is testable at all.
#[cfg(target_os = "linux")]
fn parse_mounts(table: &str) -> Vec<Mount> {
table
.lines()
.filter_map(|line| {
let mut fields = line.split_whitespace();
let device = fields.next()?;
let mount_point = fields.next()?;
let fs_type = fields.next()?;
Some(Mount {
device: unescape(device),
mount_point: PathBuf::from(unescape(mount_point)),
fs_type: fs_type.to_string(),
})
})
.collect()
}
/// Undo the octal escaping the kernel applies to spaces and tabs.
///
/// A card is very often labelled with a space in it — `EOS DIGITAL` is what
/// Canon writes — and mounted at `/run/media/duncan/EOS\040DIGITAL`. Without
/// this the path does not exist and the card is invisible.
#[cfg(target_os = "linux")]
fn unescape(field: &str) -> String {
let mut out = String::with_capacity(field.len());
let bytes = field.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 3 < bytes.len() {
let digits = &field[i + 1..i + 4];
if let Ok(c) = u8::from_str_radix(digits, 8) {
out.push(c as char);
i += 4;
continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
/// Whether a mount could hold a photograph library at all.
///
/// Excludes the pseudo-filesystems, which is most of the table, and anything
/// not backed by a block device. A card reader always is.
#[cfg(target_os = "linux")]
fn is_candidate(m: &Mount) -> bool {
const FILESYSTEMS: &[&str] = &[
// What cameras format cards as, in practice: FAT32 below 32 GB,
// exFAT above it, which is why an SDXC card is always exfat.
"vfat", "exfat", "ntfs", "ntfs3",
"fuseblk", // And what an external drive carrying an archive is.
"ext2", "ext3", "ext4", "btrfs", "xfs", "f2fs", "hfsplus", "apfs",
];
m.device.starts_with("/dev/") && FILESYSTEMS.contains(&m.fs_type.as_str())
}
/// Whether the desktop automounted this, which means it already decided.
#[cfg(target_os = "linux")]
fn under_media_dir(mount_point: &Path) -> bool {
let s = mount_point.to_string_lossy();
s.starts_with("/run/media/") || s.starts_with("/media/") || s.starts_with("/mnt/media/")
}
/// Ask sysfs whether the device behind a partition is removable.
///
/// `/dev/sdb1` is a partition; the flag lives on its whole-disk parent,
/// `/sys/block/sdb/removable`. Trailing digits are stripped to get there,
/// except on `mmcblk0p1` and `nvme0n1p1`, whose parents keep their digits and
/// lose only the `pN` suffix — strip naively and `mmcblk0p1` becomes
/// `mmcblk`, which does not exist, and every SD card in an internal reader
/// reports itself fixed.
#[cfg(target_os = "linux")]
fn device_is_removable(device: &str) -> Option<bool> {
let name = device.strip_prefix("/dev/")?;
let disk = whole_disk(name);
let flag = std::fs::read_to_string(format!("/sys/block/{disk}/removable")).ok()?;
Some(flag.trim() == "1")
}
/// The whole-disk name a partition belongs to.
#[cfg(target_os = "linux")]
fn whole_disk(partition: &str) -> String {
// `mmcblk0p1` -> `mmcblk0`, `nvme0n1p3` -> `nvme0n1`: these spell the
// partition with a `p`, and the disk name legitimately ends in a digit.
if partition.starts_with("mmcblk") || partition.starts_with("nvme") {
if let Some((disk, tail)) = partition.rsplit_once('p') {
if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
return disk.to_string();
}
}
return partition.to_string();
}
// `sdb1` -> `sdb`. A SCSI-style disk name never ends in a digit.
partition
.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
}
/// What to call a volume in a list.
#[cfg(target_os = "linux")]
fn label_for(mount_point: &Path) -> String {
mount_point
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| mount_point.to_string_lossy().to_string())
}
// Reading `/proc/mounts` and naming block devices is what these test, so
// they are as Linux-bound as the code above.
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
const TABLE: &str = "\
proc /proc proc rw,nosuid 0 0
sys /sys sysfs rw,nosuid 0 0
/dev/nvme0n1p2 / btrfs rw,noatime 0 0
/dev/nvme0n1p1 /boot vfat rw,relatime 0 0
/dev/sdb1 /run/media/duncan/EOS\\040DIGITAL exfat rw,nosuid 0 0
tmpfs /run/user/1000 tmpfs rw,nosuid 0 0
";
#[test]
fn a_mount_table_yields_its_block_devices() {
let mounts = parse_mounts(TABLE);
assert_eq!(mounts.len(), 6);
let candidates: Vec<_> = mounts.iter().filter(|m| is_candidate(m)).collect();
// Three block-backed filesystems; the pseudo ones are gone.
assert_eq!(candidates.len(), 3);
assert!(candidates.iter().all(|m| m.device.starts_with("/dev/")));
}
#[test]
fn a_card_labelled_with_a_space_is_not_lost() {
// Canon writes `EOS DIGITAL`, the kernel escapes the space, and a path
// that keeps the escape does not exist.
let mounts = parse_mounts(TABLE);
let card = mounts
.iter()
.find(|m| m.device == "/dev/sdb1")
.expect("the card");
assert_eq!(
card.mount_point,
PathBuf::from("/run/media/duncan/EOS DIGITAL")
);
assert_eq!(label_for(&card.mount_point), "EOS DIGITAL");
}
#[test]
fn an_automounted_volume_is_treated_as_removable() {
// The desktop had udisks2 to ask and already decided.
assert!(under_media_dir(Path::new("/run/media/duncan/EOS DIGITAL")));
assert!(under_media_dir(Path::new("/media/usb0")));
assert!(!under_media_dir(Path::new("/home/duncan/Photos")));
// Not a prefix match on the word: `/media-server` is not automounted.
assert!(!under_media_dir(Path::new("/mediaserver/x")));
}
#[test]
fn a_partition_resolves_to_the_disk_that_carries_the_flag() {
assert_eq!(whole_disk("sdb1"), "sdb");
assert_eq!(whole_disk("sda12"), "sda");
// The naive strip turns these into `mmcblk` and `nvme`, neither of
// which exists — and every SD card in an internal reader then reports
// itself fixed.
assert_eq!(whole_disk("mmcblk0p1"), "mmcblk0");
assert_eq!(whole_disk("nvme0n1p3"), "nvme0n1");
// A whole disk named directly is already the answer.
assert_eq!(whole_disk("mmcblk0"), "mmcblk0");
assert_eq!(whole_disk("sdb"), "sdb");
}
#[test]
fn a_camera_card_outranks_a_backup_drive() {
let card = Volume {
label: "EOS DIGITAL".into(),
path: "/run/media/duncan/EOS DIGITAL".into(),
removable: false,
has_dcim: true,
};
let drive = Volume {
label: "archive".into(),
path: "/run/media/duncan/archive".into(),
removable: true,
has_dcim: false,
};
let fixed = Volume {
label: "boot".into(),
path: "/boot".into(),
removable: false,
has_dcim: false,
};
// An internal reader reports its device fixed, and the user with a
// card in it does not care what sysfs thinks.
assert!(card.is_likely_card());
assert!(drive.is_likely_card());
assert!(!fixed.is_likely_card());
}
#[test]
fn a_platform_that_cannot_import_says_so_separately_from_finding_nothing() {
// The two are different claims: an empty list means "plug one in",
// `false` here means "this cannot be done here". An interface that
// conflated them would offer a page that can never be used.
assert_eq!(
imports_supported(),
cfg!(any(target_os = "linux", target_os = "android"))
);
if !imports_supported() {
assert!(volumes().is_empty());
}
}
#[test]
fn asking_where_there_is_no_answer_is_not_an_error() {
// The call must work on a machine with no card, in CI, and on a
// platform that cannot answer at all — an empty list, never a panic.
let _ = volumes();
}
#[test]
fn an_octal_escape_that_is_not_one_is_left_alone() {
// A backslash near the end of a field must not read past it.
assert_eq!(unescape("/mnt/odd\\"), "/mnt/odd\\");
assert_eq!(unescape("/mnt/a\\04"), "/mnt/a\\04");
assert_eq!(unescape("/mnt/a\\040b"), "/mnt/a b");
}
}