Commit Graph
4 Commits
Author SHA1 Message Date
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
dtourolle 92d4b23bed Give an album a folder on the tablet, through Android's folder picker
Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.

The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.

Two things the first build on the tablet got wrong, recorded where they
are fixed:

- Our classes must be loaded through Context.getClassLoader(). The
  class of what ndk_context holds is a framework class from the boot
  loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
  and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.

Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
2026-09-26 14:13:53 -04:00
dtourolle 352e59498b Open the bundled manual from Help and from Settings
The packages now carry the manual, but nothing in the application opened
it: the help sheet listed gestures and stopped there.

The help sheet gains a Manual button beside Done, and Settings a Manual
row under About beside the version. Both go through dr_ui::manual, which
finds the installed page through dr_plat::system_data_dirs (the package's
share directory on Linux, the executable's directory on Windows), and a
development build also in the checkout it was compiled from. A copy with
no manual says so on the status line rather than doing nothing.

On the desktop the page goes to the system browser. A section is a URL
fragment, and xdg-open's generic mode and Windows' FileProtocolHandler
both turn a file: URL into a path and drop the fragment, so a section is
opened through a one-line redirect page written to the data directory:
the opener gets a plain path, which every opener keeps, and the browser
follows the redirect to index.html#section itself. The launcher behind
the sign-in's open_in_browser is split out so both share it; the https
check stays with the sign-in.

Android has no path to give a browser: an asset is not a file, a copy in
private storage is unreadable to other apps, a file: URI across apps is
refused, and a content: URI leaves the browser resolving every picture
against the provider. So ManualActivity, a WebView reading
file:///android_asset/manual/index.html straight out of the APK, shows
it, started by class name with the section as an extra. JavaScript is
off, links off the page go to the browser, and the theme is day-night so
the page's own light and dark follow the system. A test checks that the
manifest, the Java class and dr_ui agree on the name and the extra.
2026-09-24 22:56:09 -04:00
dtourolleandClaude Opus 5 09dddde594 Take the photograph another app hands over, and hand an export back
FR-PLAT-AND-6 asks for two things this app did neither of: be a receiver for
image view and share intents, and share exported results out through a
FileProvider. The manifest declared one activity with one MAIN/LAUNCHER filter,
so nothing on the device ever offered DarkRoom for a photograph, and there was
no route out at all — Android has refused file:// URIs between apps since API
24, and a content:// URI needs a provider to be behind it.

Inbound. Three filters now: VIEW for a gallery or a file manager, SEND and
SEND_MULTIPLE for the share sheet, all on image/*. `android_main` reads the
launch Intent before it gives `app` away to Slint, and what comes back is
passed to `dr_ui::run` exactly as argv is on the desktop — `startup_action`
already treats a non-empty list as "the user asked for these specifically",
which is what a share is.

The URIs are copied into the cache before the viewer opens, and that cost is
real: a shared raw file is written once, in full, on the startup path. A
content:// URI is a handle into another app's provider, not a path, and the
decoders take paths; the alternative is teaching the whole read path about
URIs, which is FR-PLAT-AND-1's SAF connector and is not built.

Outbound. ExportProvider serves one directory — getFilesDir(), which is the
same path `internal_data_path` gives the Rust side — and refuses everything
else by canonicalising the request and checking it is inside that root, so
`../` and a planted symlink fail the same test. Not AndroidX's FileProvider,
because AndroidX is a Maven artefact and this build has no resolver; what it
does is a hundred lines and they are here.

The share half has no caller. The provider, the URI grant and the chooser are
all in place, but the control that would invoke them belongs in `ui/dr-ui`, and
wiring it needs an `AndroidApp` the interface can reach. It is documented as
unwired and deliberately not tagged as covering the requirement.

`launchMode="singleTask"` comes with the filters and is not decoration: another
app can now launch this activity while it is running, and the default mode
answers that by creating a second NativeActivity in the same process — a second
android_main, a second Slint backend, a second wgpu device. The cost of the
fix is stated in the manifest: a share arriving while DarkRoom is already open
brings it forward without opening the image, because onNewIntent has no route
through android-activity's event stream.

The Java is Java because Android constructs it: a ContentProvider is
instantiated by the system from its manifest entry, and getIntent() exists only
on an activity object. Both directions live there rather than in JNI so that
what crosses the boundary is two method signatures instead of forty, each of
which is a string checked at run time and nowhere else.

What a test can hold: the declarations. Nothing about an Intent or a
ContentProvider is reachable from `cargo test`, but an intent filter that is
deleted takes the app out of every "open with" menu silently, and an authority
that stops matching its class raises a SecurityException inside somebody else's
app. The tests in lib.rs read the manifest and ExportProvider.java through
`include_str!` and hold both to that, on the host, which is the only place in
the workspace that looks at either file from Rust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:32:48 +02:00