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.
415 lines
17 KiB
Plaintext
415 lines
17 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
||
import { Segmented, TextRow, Check, ChoiceChip } from "controls.slint";
|
||
import { PathRow, Pickers } from "pickers.slint";
|
||
|
||
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a
|
||
// Import: bringing a card into the library.
|
||
//
|
||
// A full-window page, the same shape as Settings and for the first of the same
|
||
// reasons — it refers to the library constantly, and a modal that has to be
|
||
// dismissed to check where something went is a dialogue arguing with itself.
|
||
//
|
||
// The second reason does not apply and produces the one structural difference:
|
||
// settings save on change and have nothing to confirm, where an import is a
|
||
// decision. So this page has a commit button, and everything above it is the
|
||
// sentence that button completes: take *these* files, from *there*, and put
|
||
// them *here*.
|
||
//
|
||
// # Why the destination is shown and not editable
|
||
//
|
||
// A photograph goes into the library, and the library is chosen at launch.
|
||
// Offering a second place to set it here would let the two disagree, and the
|
||
// disagreement would be discovered as a folder of photographs the grid does
|
||
// not show. What *is* editable is the layout within it (FR-NC-7a), which is a
|
||
// different question with a visible answer right beside it.
|
||
|
||
// One volume the app found, as a row that can be picked.
|
||
//
|
||
// The subtitle carries why it is being offered — a camera card, a removable
|
||
// drive — because "EOS DIGITAL" and "archive" look equally plausible in a list
|
||
// and only one of them is what the user just plugged in.
|
||
component VolumeRow inherits Rectangle {
|
||
in property <string> label;
|
||
in property <string> detail;
|
||
in property <bool> selected: false;
|
||
callback clicked();
|
||
|
||
height: 44px;
|
||
border-radius: Theme.radius-sm;
|
||
background: root.selected ? Theme.selected
|
||
: (touch.pressed ? Theme.pressed
|
||
: (touch.has-hover ? Theme.hover : transparent));
|
||
border-width: root.selected ? 1px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
|
||
touch := TouchArea {
|
||
clicked => { root.clicked(); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
spacing: 1px;
|
||
Label { text: root.label; body: true; }
|
||
Caption { text: root.detail; }
|
||
}
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
}
|
||
|
||
export component ImportPage inherits Rectangle {
|
||
// --- where the photographs are coming from ---------------------------
|
||
in property <[string]> volume-labels;
|
||
in property <[string]> volume-details;
|
||
/// -1 while nothing is chosen, which is what the page opens on.
|
||
in property <int> volume-selected: -1;
|
||
/// The chosen source, also typable — a card mounted somewhere this app
|
||
/// does not think to look is still a card.
|
||
in-out property <string> card-path;
|
||
/// 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;
|
||
/// "1,847 photographs · 61.2 GB". Empty before anything has been counted.
|
||
in property <string> survey-summary;
|
||
|
||
// --- where they are going --------------------------------------------
|
||
/// The library folder on the server — the only destination there is.
|
||
///
|
||
/// Empty means no account is signed in, and Import stays disabled: the
|
||
/// library lives on the server, so an import without one has nowhere to
|
||
/// go at all.
|
||
in property <string> upload-target;
|
||
in-out property <string> folder-template;
|
||
/// The template expanded against a real date, so the answer to "what will
|
||
/// this do" is on screen rather than in the manual.
|
||
in property <string> template-preview;
|
||
|
||
in property <[string]> mode-labels;
|
||
in property <int> mode-selected: 0;
|
||
/// Whether the chosen mode will eventually erase the card.
|
||
in property <bool> mode-is-move: false;
|
||
|
||
in property <[string]> duplicate-labels;
|
||
in property <int> duplicate-selected: 0;
|
||
in property <bool> verify: true;
|
||
in property <bool> backup-on: false;
|
||
in-out property <string> backup-path;
|
||
|
||
callback template-changed(string);
|
||
callback mode-picked(int);
|
||
callback duplicate-picked(int);
|
||
callback verify-toggled(bool);
|
||
callback backup-toggled(bool);
|
||
callback backup-path-changed(string);
|
||
callback choose-backup();
|
||
|
||
// --- doing it --------------------------------------------------------
|
||
in property <bool> running: false;
|
||
in property <float> fraction: 0;
|
||
/// What it is doing now, or what it did — one line, always present once
|
||
/// something has happened.
|
||
in property <string> status;
|
||
in property <string> error;
|
||
/// Whether Import can be pressed at all.
|
||
in property <bool> can-start: false;
|
||
|
||
callback begin();
|
||
callback stop();
|
||
callback close();
|
||
|
||
background: Theme.ground;
|
||
|
||
// Somewhere for a key to start from, so Android's back gesture closes the
|
||
// page rather than the application. Same reasoning as the settings page.
|
||
FocusScope {
|
||
width: 0px;
|
||
height: 0px;
|
||
init => { self.focus(); }
|
||
}
|
||
|
||
VerticalLayout {
|
||
// --- header ------------------------------------------------------
|
||
Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Button {
|
||
text: "‹ Back";
|
||
y: (parent.height - self.height) / 2;
|
||
// Leaving mid-import would abandon a worker with no way
|
||
// back to it: the page is the only thing that reports what
|
||
// it is doing.
|
||
enabled: !root.running;
|
||
clicked => { root.close(); }
|
||
}
|
||
|
||
Value { text: "Import"; }
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
|
||
if root.running: Button {
|
||
text: "Stop";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.stop(); }
|
||
}
|
||
|
||
if !root.running: Button {
|
||
text: "Import";
|
||
y: (parent.height - self.height) / 2;
|
||
enabled: root.can-start;
|
||
clicked => { root.begin(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// The bar sits directly under the header while a run is going, which
|
||
// is where every other progress bar in this application is.
|
||
if root.running: ProgressBar {
|
||
fraction: root.fraction;
|
||
indeterminate: root.surveying;
|
||
}
|
||
|
||
if root.error != "": Rectangle {
|
||
height: 32px;
|
||
background: Theme.surface;
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
Caption { text: root.error; warn: true; overflow: elide; }
|
||
}
|
||
}
|
||
|
||
// Named child plus an explicit viewport height, as the settings page
|
||
// does it: a Flickable cannot measure an anonymous layout, and scrolls
|
||
// nowhere without being told how tall its contents are.
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
viewport-height: content.preferred-height;
|
||
|
||
content := VerticalLayout {
|
||
width: 100%;
|
||
padding: Theme.gap;
|
||
spacing: Theme.gap;
|
||
alignment: start;
|
||
|
||
// --- source ------------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "From"; }
|
||
|
||
// 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 SD card or card reader found. Insert one and press Refresh.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
for label[i] in root.volume-labels: VolumeRow {
|
||
label: label;
|
||
detail: root.volume-details[i];
|
||
selected: i == root.volume-selected;
|
||
clicked => { root.volume-picked(i); }
|
||
}
|
||
|
||
// A card mounted somewhere this app does not think to
|
||
// look is still a card: the dialogue reaches it, where
|
||
// the list above only knows the usual mount points.
|
||
PathRow {
|
||
label: "Location";
|
||
path: root.card-path;
|
||
placeholder: "/run/media/…";
|
||
button-text: "Browse…";
|
||
enabled: !root.running;
|
||
choose => { root.choose-card(); }
|
||
typed(t) => { root.card-path-changed(t); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Button {
|
||
text: "Refresh";
|
||
enabled: !root.running;
|
||
clicked => { root.refresh-volumes(); }
|
||
}
|
||
// Says what was found the moment it is known, so the
|
||
// user is not asked to press Import on faith.
|
||
Caption {
|
||
text: root.surveying ? "Looking…"
|
||
: (root.survey-summary != "" ? root.survey-summary : "");
|
||
vertical-alignment: center;
|
||
}
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
|
||
if root.card-path != "" && !root.card-looks-right && !root.surveying: Caption {
|
||
// Advisory, not a block: an archive folder is a
|
||
// legitimate source and has no DCIM either.
|
||
text: "No DCIM folder here — check this is the card and not somewhere else.";
|
||
wrap: word-wrap;
|
||
}
|
||
}
|
||
|
||
// --- destination -------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "Into"; }
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Library"; }
|
||
Value {
|
||
text: root.upload-target != "" ? root.upload-target
|
||
: "not signed in";
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
// The order is the promise, and a user watching a slow
|
||
// upload needs to already know the photographs are safe.
|
||
// Said here, beside the destination, rather than beside the
|
||
// Import button where it would read as a warning.
|
||
if root.upload-target != "": Caption {
|
||
text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
if root.upload-target == "": Caption {
|
||
text: "Sign in first — the library this imports into is on the server.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
TextRow {
|
||
label: "Folders";
|
||
text <=> root.folder-template;
|
||
placeholder: "{yyyy}/{yyyy}-{mm}-{dd}";
|
||
field-width: 320px;
|
||
hint: "Tokens: {yyyy} {yy} {mm} {dd} {date} {make} {model}";
|
||
enabled: !root.running;
|
||
accepted(t) => { root.template-changed(t); }
|
||
}
|
||
|
||
// The template's answer, against a real photograph's date.
|
||
// A template language nobody can predict the output of is
|
||
// a template language nobody will change.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Example"; }
|
||
Value { text: root.template-preview; }
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
}
|
||
|
||
// --- how ----------------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "How"; }
|
||
|
||
Segmented {
|
||
label: "Transfer";
|
||
options: root.mode-labels;
|
||
selected: root.mode-selected;
|
||
enabled: !root.running;
|
||
picked(i) => { root.mode-picked(i); }
|
||
}
|
||
|
||
if root.mode-is-move: Caption {
|
||
// The one operation in this application with no undo,
|
||
// and the only honest place to say so is beside the
|
||
// control that arms it (FR-NC-7b).
|
||
text: "The card is emptied only of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Segmented {
|
||
label: "Already in the library";
|
||
options: root.duplicate-labels;
|
||
selected: root.duplicate-selected;
|
||
enabled: !root.running;
|
||
picked(i) => { root.duplicate-picked(i); }
|
||
}
|
||
|
||
Check {
|
||
label: "Verify every file";
|
||
hint: "Reads each copy back and compares it. Catches a dying card, which is otherwise invisible until the photograph is opened.";
|
||
checked: root.verify;
|
||
// `Check` has no enabled property, so the guard is on
|
||
// the callback: a toggle mid-run would change what the
|
||
// running worker was told to do, which it cannot hear.
|
||
toggled(v) => { if (!root.running) { root.verify-toggled(v); } }
|
||
}
|
||
|
||
Check {
|
||
label: "Second copy";
|
||
hint: "Written from the same read as the first, so it is a check on the copy rather than a copy of it.";
|
||
checked: root.backup-on;
|
||
toggled(v) => { if (!root.running) { root.backup-toggled(v); } }
|
||
}
|
||
|
||
if root.backup-on: PathRow {
|
||
label: "Second copy in";
|
||
// The toggle stores a single space to mean "on, no
|
||
// folder yet" (see `import_ui`), which is not a path.
|
||
path: root.backup-path == " " ? "" : root.backup-path;
|
||
placeholder: "No folder chosen";
|
||
enabled: !root.running;
|
||
choose => { root.choose-backup(); }
|
||
typed(t) => { root.backup-path-changed(t); }
|
||
}
|
||
}
|
||
|
||
// --- what happened -----------------------------------------
|
||
if root.status != "": Panel {
|
||
PanelHeading { text: root.running ? "Working" : "Result"; }
|
||
Caption { text: root.status; wrap: word-wrap; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|