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
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;
}