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:
2026-09-30 21:30:09 -04:00
parent fcccc2c2e0
commit caae65c78d
11 changed files with 568 additions and 115 deletions
@@ -4,9 +4,10 @@
Deliberately minimal: this packages the viewer for on-device testing (spike
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
here is a distribution manifest yet. Only network access is declared: file
access needs no manifest permission because the library grid reads through
SAF, which grants per-tree at runtime (ARCH §6.9).
here is a distribution manifest yet. The library grid needs no storage
permission, because it reads through SAF, which grants per-tree at runtime
(ARCH §6.9); the one storage permission declared is for importing from a
camera card, which is read by path.
Minimal is not the same as empty, and the entries below that are not the
activity are the difference. A manifest is the only place a component can be
@@ -21,13 +22,29 @@
WebDAV listing, thumbnail and image fetches. Without it Android refuses
socket creation outright, and the failure is invisible — no panic to
catch, no log line, just a worker thread that stops. Storage is the
separate case that genuinely needs no permission here, because SAF
grants per-tree at runtime (ARCH §6.9). -->
separate case: the library and album folders need no permission
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
work on unmetered-and-charging, which means knowing the network type. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
as files, and "all files access" is what makes an SD card or a USB
card reader readable by path on API 30 and up (see Cards.java). It is
granted on a system settings page, not a dialog; the import page
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
same thing for API 28 and 29, and means nothing above them; on 29 it
reads by path only with requestLegacyExternalStorage, which is why
<application> carries that flag.
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
kinds. DarkRoom is not distributed through Play. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
dependable (NFR-COMPAT-1). Marked required so an unsupported device
fails at install rather than at first frame. -->
@@ -53,6 +70,7 @@
android:icon="@mipmap/ic_launcher"
android:hasCode="true"
android:allowBackup="false"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true">
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
@@ -0,0 +1,150 @@
package paris.tourolle.darkroom;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.Settings;
import android.util.Log;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Finding a camera card, and the permission that makes it readable (FR-CAT-10).
*
* <p>An import reads the card as files: the survey walks it, the probe reads
* each header and the copy streams each original, all through the same
* {@code std::fs} code the desktop uses. Android hands out such paths —
* {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access"
* ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD
* card and of a USB card reader. Below API 30 the same paths are readable
* with {@code READ_EXTERNAL_STORAGE}.
*
* <p>Not the folder picker {@link FolderPicker} uses for albums. A tree
* granted through SAF is {@code content://} URIs, not paths, and since API 30
* the picker refuses the root of a card outright; reading a card through it
* would mean a second storage implementation under the importer, where this
* needs none.
*
* <p>Google Play restricts this permission to file managers and the like.
* DarkRoom is not distributed through Play, so the restriction does not
* apply; it would need revisiting if that changed.
*/
public final class Cards {
private static final String TAG = "DarkRoom";
private Cards() {
}
/** Whether this app may read a card's files by path. */
public static boolean hasAccess(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
}
/**
* Open the system page where the user grants it.
*
* <p>A settings page rather than a permission dialog because there is no
* dialog for this one on API 30 and up: the user flips "Allow access to
* manage all files" for this app. Below 30 the context is the application
* context, which cannot raise a runtime permission request (that needs an
* Activity's result), so the app's own settings page is the route there
* too. Either way the app learns of the grant by asking
* {@link #hasAccess} again.
*/
public static void requestAccess(Context context) {
Uri self = Uri.parse("package:" + context.getPackageName());
Intent intent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self);
} else {
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self);
}
// The context is not an Activity; see FolderPicker.start.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
context.startActivity(intent);
} catch (RuntimeException e) {
// Some builds ship without the per-app page; the list of every
// app holding the permission is the fallback that always exists.
Log.w(TAG, "no per-app all-files page; opening the list", e);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(list);
}
}
}
/**
* Every mounted volume other than the device's own storage.
*
* <p>One string per volume, {@code path \t description \t removable},
* where removable is {@code 1} or {@code 0}: the reason {@link Intents}
* gives for keeping the JNI surface to strings. The primary volume is left
* out — it is the device's internal storage, never a card — and so is
* anything not mounted, which is a card being ejected or one the system
* could not read.
*/
public static String[] volumes(Context context) {
List<String> out = new ArrayList<String>();
StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
if (manager == null) {
return new String[0];
}
for (StorageVolume volume : manager.getStorageVolumes()) {
if (volume.isPrimary()) {
continue;
}
String state = volume.getState();
if (!Environment.MEDIA_MOUNTED.equals(state)
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
continue;
}
String path = path(volume);
if (path == null) {
Log.w(TAG, "a mounted volume with no path: " + volume);
continue;
}
String description = volume.getDescription(context);
if (description == null) {
description = new File(path).getName();
}
out.add(path + "\t" + description.replace('\t', ' ') + "\t"
+ (volume.isRemovable() ? "1" : "0"));
}
return out.toArray(new String[0]);
}
/**
* Where the volume is mounted.
*
* <p>{@code getDirectory} is API 30. Below it the same answer is the
* hidden {@code getPath}, which every release from 24 to 29 has, reached by
* reflection because android.jar does not declare it.
*/
private static String path(StorageVolume volume) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
File dir = volume.getDirectory();
return dir == null ? null : dir.getPath();
}
try {
Object path = StorageVolume.class.getMethod("getPath").invoke(volume);
return path == null ? null : path.toString();
} catch (ReflectiveOperationException e) {
Log.w(TAG, "StorageVolume.getPath", e);
return null;
}
}
}
File diff suppressed because one or more lines are too long
+24 -24
View File
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
<sub>`ui/dr-ui/ui/app.slint:2025`</sub>
<sub>`ui/dr-ui/ui/app.slint:2029`</sub>
### Move a magnified photograph about
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2121`</sub>
<sub>`ui/dr-ui/ui/app.slint:2125`</sub>
### Paint a mask by hand
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2212`</sub>
<sub>`ui/dr-ui/ui/app.slint:2216`</sub>
### Open this list
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
<sub>`ui/dr-ui/ui/app.slint:2438`</sub>
<sub>`ui/dr-ui/ui/app.slint:2442`</sub>
### Take back the last change
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2468`</sub>
<sub>`ui/dr-ui/ui/app.slint:2472`</sub>
### Do it again after taking it back
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
<sub>`ui/dr-ui/ui/app.slint:2482`</sub>
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
### Remove a repair
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, then Delete Repair
- **Keyboard** — `Delete` or `Backspace`, while repairing
<sub>`ui/dr-ui/ui/app.slint:2502`</sub>
<sub>`ui/dr-ui/ui/app.slint:2506`</sub>
### Copy the settings from this photograph
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2521`</sub>
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
### Paste the settings onto this photograph
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2534`</sub>
<sub>`ui/dr-ui/ui/app.slint:2538`</sub>
### Choose which kinds of edit a copy carries
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
<sub>`ui/dr-ui/ui/app.slint:2552`</sub>
<sub>`ui/dr-ui/ui/app.slint:2556`</sub>
### Export this photograph as the last one was
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
<sub>`ui/dr-ui/ui/app.slint:2577`</sub>
<sub>`ui/dr-ui/ui/app.slint:2581`</sub>
### Choose how to export, then export
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
<sub>`ui/dr-ui/ui/app.slint:2590`</sub>
<sub>`ui/dr-ui/ui/app.slint:2594`</sub>
### Keep a crop that leaves a mask outside
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
<sub>`ui/dr-ui/ui/app.slint:2655`</sub>
<sub>`ui/dr-ui/ui/app.slint:2659`</sub>
### Go back to the grid
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
<sub>`ui/dr-ui/ui/app.slint:2672`</sub>
<sub>`ui/dr-ui/ui/app.slint:2676`</sub>
### Nudge the control last moved
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
<sub>`ui/dr-ui/ui/app.slint:2701`</sub>
<sub>`ui/dr-ui/ui/app.slint:2705`</sub>
### Change which group of adjustments is on screen
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2729`</sub>
<sub>`ui/dr-ui/ui/app.slint:2733`</sub>
### Look at the photograph at 1:1
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
<sub>`ui/dr-ui/ui/app.slint:2765`</sub>
<sub>`ui/dr-ui/ui/app.slint:2769`</sub>
### Rate this photograph
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
- **Pointer** — Click a star in the top bar
- **Keyboard** — `0`–`5`
<sub>`ui/dr-ui/ui/app.slint:2822`</sub>
<sub>`ui/dr-ui/ui/app.slint:2826`</sub>
### Pick or reject this photograph
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
<sub>`ui/dr-ui/ui/app.slint:2828`</sub>
<sub>`ui/dr-ui/ui/app.slint:2832`</sub>
### Give this photograph a colour label
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
<sub>`ui/dr-ui/ui/app.slint:2858`</sub>
<sub>`ui/dr-ui/ui/app.slint:2862`</sub>
### Move to the next or previous photograph
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
<sub>`ui/dr-ui/ui/app.slint:2883`</sub>
<sub>`ui/dr-ui/ui/app.slint:2887`</sub>
### See the photograph before you edited it
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:3008`</sub>
<sub>`ui/dr-ui/ui/app.slint:3012`</sub>
### Put one control back to its default
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
<sub>`ui/dr-ui/ui/app.slint:1024`</sub>
<sub>`ui/dr-ui/ui/app.slint:1026`</sub>
### Do what a sheet offers
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
- **Pointer** — Press its button — Export, or Copy
- **Keyboard** — `Enter`, on the export and copy sheets
<sub>`ui/dr-ui/ui/app.slint:1034`</sub>
<sub>`ui/dr-ui/ui/app.slint:1036`</sub>
### Scroll by the scrollbar
+18 -19
View File
@@ -4,7 +4,7 @@
//! 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 hands out a document tree the user picked (ARCH §6.9).
//! 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.
//!
@@ -67,22 +67,18 @@ impl Volume {
/// 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 `false` on Android, for two reasons that both have to be fixed before
/// it can change:
/// 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.
///
/// - There is no mount table to read and no path to type. Storage is reached
/// through a tree the user granted, and a removable volume appears there or
/// not at all (ARCH §6.9).
/// - Nothing implements [`WritableStorage`](crate::WritableStorage) except
/// [`LocalStorage`](crate::LocalStorage), so there is no destination to write
/// into even once a source is named.
///
/// The engine above this is already portable — it takes storage traits and
/// never a path — so what this gates is the *interface*, and it stops being
/// `false` when a SAF implementation lands rather than when the importer is
/// rewritten.
/// 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!(target_os = "linux")
cfg!(any(target_os = "linux", target_os = "android"))
}
/// Every mounted volume that might hold photographs.
@@ -130,9 +126,9 @@ fn platform_volumes() -> Vec<Volume> {
/// Everywhere else: no answer, and saying so is the honest result.
///
/// On Android the question is not merely unanswerable but wrong — storage is
/// reached through a tree the user granted, and a card appears there or not at
/// all (ARCH §6.9, FR-PLAT-AND-1).
/// 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()
@@ -358,7 +354,10 @@ tmpfs /run/user/1000 tmpfs rw,nosuid 0 0
// 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!(target_os = "linux"));
assert_eq!(
imports_supported(),
cfg!(any(target_os = "linux", target_os = "android"))
);
if !imports_supported() {
assert!(volumes().is_empty());
}
+160
View File
@@ -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
View File
@@ -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>,
+1
View File
@@ -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
View File
@@ -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);
}
+4
View File
@@ -569,6 +569,7 @@ export component AppWindow inherits Window {
in property <int> import-volume-selected: -1;
in property <string> import-card-path: "";
in property <bool> import-card-looks-right: false;
in property <bool> import-needs-access: false;
in property <bool> import-surveying: false;
in property <string> import-survey-summary: "";
in property <string> import-upload-target: "";
@@ -630,6 +631,7 @@ export component AppWindow inherits Window {
callback import-card-path-changed(string);
callback import-choose-card();
callback import-refresh-volumes();
callback import-grant-access();
callback import-template-changed(string);
callback import-mode-picked(int);
callback import-duplicate-picked(int);
@@ -1122,6 +1124,7 @@ in property <bool> panel-visible: true;
volume-selected: root.import-volume-selected;
card-path: root.import-card-path;
card-looks-right: root.import-card-looks-right;
needs-access: root.import-needs-access;
surveying: root.import-surveying;
survey-summary: root.import-survey-summary;
upload-target: root.import-upload-target;
@@ -1145,6 +1148,7 @@ in property <bool> panel-visible: true;
card-path-changed(t) => { root.import-card-path-changed(t); }
choose-card => { root.import-choose-card(); }
refresh-volumes() => { root.import-refresh-volumes(); }
grant-access() => { root.import-grant-access(); }
template-changed(t) => { root.import-template-changed(t); }
mode-picked(i) => { root.import-mode-picked(i); }
duplicate-picked(i) => { root.import-duplicate-picked(i); }
+24 -2
View File
@@ -75,12 +75,17 @@ export component ImportPage inherits Rectangle {
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
/// sources do not, so this informs and never blocks.
in property <bool> card-looks-right: false;
/// Android: the app may not read a card until the user allows "all files
/// access", which is a switch in the system settings, not a dialog.
in property <bool> needs-access: false;
callback volume-picked(int);
callback card-path-changed(string);
/// Browse for the source with the platform's dialogue.
callback choose-card();
callback refresh-volumes();
/// Open the system page where that switch is.
callback grant-access();
// --- what is on it ---------------------------------------------------
in property <bool> surveying: false;
@@ -222,10 +227,27 @@ export component ImportPage inherits Rectangle {
Panel {
PanelHeading { text: "From"; }
if root.volume-labels.length == 0: Caption {
// Android, before the grant. Said once, with the one
// control that fixes it, instead of an empty list that
// would read as "no card inserted".
if root.needs-access: Caption {
text: "To read a camera card, DarkRoom needs \"All files access\". Allow it on the next screen, then come back here.";
wrap: word-wrap;
}
if root.needs-access: HorizontalLayout {
Button {
text: "Allow access";
primary: true;
enabled: !root.running;
clicked => { root.grant-access(); }
}
Rectangle { horizontal-stretch: 1; }
}
if !root.needs-access && root.volume-labels.length == 0: Caption {
text: Pickers.local-paths
? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh."
: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
: "No SD card or card reader found. Insert one and press Refresh.";
wrap: word-wrap;
}