diff --git a/Cargo.lock b/Cargo.lock
index 308db09..24c8ed3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1226,6 +1226,7 @@ dependencies = [
"android_logger",
"dr-sync",
"dr-ui",
+ "jni 0.21.1",
"log",
"slint",
]
diff --git a/apps/darkroom-android/Cargo.toml b/apps/darkroom-android/Cargo.toml
index bfcbc7b..5699149 100644
--- a/apps/darkroom-android/Cargo.toml
+++ b/apps/darkroom-android/Cargo.toml
@@ -26,6 +26,21 @@ slint.workspace = true
log.workspace = true
android_logger = "0.15"
+# The launch Intent and the share sheet are Java-only surfaces — see `intents`
+# — and JNI is the only way to reach them.
+#
+# Target-gated because the crate still has to compile on the host: it is a
+# workspace member, `cargo test --workspace` builds it, and the manifest tests
+# in `lib.rs` are the one part of it that runs there.
+#
+# 0.21 rather than the 0.22 that android-activity 0.6 uses. Both are already in
+# the lock — Slint's Android backend depends on two major versions of
+# android-activity and pulls both — so this adds nothing to the build either
+# way, and every object here comes from a raw pointer rather than from a type
+# android-activity handed over, so the two never have to agree.
+[target.'cfg(target_os = "android")'.dependencies]
+jni = "0.21"
+
[features]
# Mirrors darkroom-desktop: the CPU readback path is gone since S1 landed
# zero-copy. It mattered more here than on desktop — the same wrong path with
diff --git a/apps/darkroom-android/android/AndroidManifest.xml b/apps/darkroom-android/android/AndroidManifest.xml
index 97a9819..2395a5a 100644
--- a/apps/darkroom-android/android/AndroidManifest.xml
+++ b/apps/darkroom-android/android/AndroidManifest.xml
@@ -7,6 +7,12 @@
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).
+
+ 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
+ declared: an intent filter is how the system learns this app is worth
+ offering for a photograph, and a provider is how it learns the class exists
+ at all. Neither can be moved into code (FR-PLAT-AND-6).
-->
FR-PLAT-AND-6's outbound half. Android has refused {@code file://} URIs + * between apps since API 24 — passing one raises {@code FileUriExposedException} + * in the *sending* process — so the only way to give a photo to the share sheet + * is a {@code content://} URI backed by a provider, plus a per-Intent read + * grant that expires with the task that received it. + * + *
Because AndroidX is a Maven artefact and this build has no Gradle and no
+ * dependency resolver (see docker/android/README.md). Pulling in the one class
+ * would mean adopting the whole mechanism that fetches it. What
+ * {@code FileProvider} does is a hundred lines — map a request path onto a
+ * directory, refuse anything outside it, answer the two columns the share sheet
+ * reads — and those lines are below. The configuration it takes as an XML
+ * {@code {@code getFilesDir()}, which is the same directory the Rust side calls
+ * {@code internal_data_path} and passes to {@code dr_sync::account::set_data_dir}
+ * — {@code ANativeActivity.internalDataPath} and {@code Context.getFilesDir()}
+ * are the same path. Everything the app writes for itself, the export outbox
+ * included, is under it. Nothing else is reachable: a request is resolved
+ * against the real filesystem with {@link File#getCanonicalFile()} and then
+ * checked to be *inside* that root, so {@code ../} and a symlink planted in the
+ * outbox are refused by the same test. Serving a path the caller composed,
+ * unchecked, would turn a share button into a reader for every file this app
+ * can see, which on Android includes credentials and the whole catalog.
+ *
+ * {@code android:exported="false"} in the manifest is the outer half of the
+ * same rule: no app can address this provider at all except through a URI this
+ * app handed it with a read grant attached.
+ */
+public final class ExportProvider extends ContentProvider {
+ private static final String TAG = "DarkRoom";
+
+ /**
+ * Must equal {@code android:authorities} in AndroidManifest.xml.
+ *
+ * A mismatch is not a build error and not a runtime error here: it is a
+ * {@code SecurityException} in whichever app opened the share sheet, naming
+ * an authority that does not exist. A test in {@code lib.rs} asserts the
+ * two strings are the same for that reason.
+ */
+ public static final String AUTHORITY = "paris.tourolle.darkroom.exports";
+
+ /** Nothing to set up; the root is resolved per request against the context. */
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ /**
+ * The {@code content://} URI for a file, or null if it is not one this
+ * provider may serve.
+ *
+ * Returning null rather than an unusable URI keeps the refusal at the
+ * point where the path is known. A URI for a file outside the root would be
+ * rejected later by {@link #openFile}, in the *receiving* app's stack trace,
+ * where nothing says which of our files was asked for.
+ */
+ public static Uri uriFor(Context context, File file) {
+ try {
+ File root = root(context);
+ File target = file.getCanonicalFile();
+ String relative = within(root, target);
+ if (relative == null) {
+ Log.w(TAG, "not shareable, outside " + root + ": " + target);
+ return null;
+ }
+ // Built segment by segment rather than with a composed path
+ // string: appendPath percent-encodes, and getPathSegments below
+ // decodes symmetrically. A file called "Rue d'Alésia.jpg" survives
+ // the round trip only because both halves agree.
+ Uri.Builder builder = new Uri.Builder().scheme("content").authority(AUTHORITY);
+ for (String segment : relative.split("/")) {
+ if (!segment.isEmpty()) {
+ builder.appendPath(segment);
+ }
+ }
+ return builder.build();
+ } catch (IOException e) {
+ Log.w(TAG, "cannot resolve " + file + " for sharing: " + e);
+ return null;
+ }
+ }
+
+ /**
+ * The two columns a share target actually reads.
+ *
+ * Without {@code _display_name} the receiving app shows the URI's last
+ * segment, and without {@code _size} a mail client cannot tell whether the
+ * attachment fits before it starts reading. Both are optional in the sense
+ * that the transfer still works; both are the difference between "DSC_4471
+ * final.jpg, 8.2 MB" and an unnamed blob.
+ */
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ File file = resolve(uri);
+ if (file == null) {
+ return null;
+ }
+ String[] columns = projection != null
+ ? projection
+ : new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
+ MatrixCursor cursor = new MatrixCursor(columns, 1);
+ MatrixCursor.RowBuilder row = cursor.newRow();
+ for (String column : columns) {
+ if (OpenableColumns.DISPLAY_NAME.equals(column)) {
+ row.add(file.getName());
+ } else if (OpenableColumns.SIZE.equals(column)) {
+ row.add(file.length());
+ } else {
+ // A column we do not have. Null rather than omitted: a cursor
+ // whose row is shorter than its projection throws in the
+ // caller, which is a crash in someone else's app.
+ row.add(null);
+ }
+ }
+ return cursor;
+ }
+
+ /**
+ * From the extension, because that is all there is.
+ *
+ * The type decides which apps the chooser offers, so guessing wrong
+ * narrows the sheet rather than breaking the transfer. Exports are JPEG,
+ * PNG or TIFF and {@code MimeTypeMap} knows all three.
+ */
+ @Override
+ public String getType(Uri uri) {
+ File file = resolve(uri);
+ if (file == null) {
+ return null;
+ }
+ String name = file.getName();
+ int dot = name.lastIndexOf('.');
+ if (dot >= 0 && dot < name.length() - 1) {
+ String extension = name.substring(dot + 1).toLowerCase(Locale.ROOT);
+ String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ if (type != null) {
+ return type;
+ }
+ }
+ return "application/octet-stream";
+ }
+
+ /**
+ * Read-only, always.
+ *
+ * A write mode is refused rather than quietly downgraded: a caller that
+ * asked for "rw" intends to save something back, and letting it open the
+ * file read-only would fail at its first write with an error about a
+ * descriptor rather than about permission. Nothing this app shares is meant
+ * to be edited in place by the app it was shared with.
+ */
+ @Override
+ public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+ if (!"r".equals(mode)) {
+ throw new SecurityException("this provider is read-only, asked for '" + mode + "'");
+ }
+ File file = resolve(uri);
+ if (file == null) {
+ throw new FileNotFoundException("no such export: " + uri);
+ }
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ throw new UnsupportedOperationException("exports are written by the app, not through it");
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException("exports are written by the app, not through it");
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException("exports are deleted by the app, not through it");
+ }
+
+ /** The served root, resolved through the filesystem so the check below is real. */
+ private static File root(Context context) throws IOException {
+ return context.getFilesDir().getCanonicalFile();
+ }
+
+ /** The file a request names, or null if it names anything else. */
+ private File resolve(Uri uri) {
+ Context context = getContext();
+ if (context == null) {
+ return null;
+ }
+ List Both sides are canonical by the time they get here, which is what
+ * makes one string comparison enough for {@code ../} and for a symlink
+ * alike. The trailing separator matters: without it a sibling directory
+ * whose name merely starts with the root's — {@code /data/.../files.old} —
+ * passes.
+ */
+ private static String within(File root, File target) {
+ String rootPath = root.getPath() + File.separator;
+ String targetPath = target.getPath();
+ if (!targetPath.startsWith(rootPath)) {
+ return null;
+ }
+ return targetPath.substring(rootPath.length());
+ }
+}
diff --git a/apps/darkroom-android/android/java/paris/tourolle/darkroom/Intents.java b/apps/darkroom-android/android/java/paris/tourolle/darkroom/Intents.java
new file mode 100644
index 0000000..0602a7c
--- /dev/null
+++ b/apps/darkroom-android/android/java/paris/tourolle/darkroom/Intents.java
@@ -0,0 +1,320 @@
+package paris.tourolle.darkroom;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.OpenableColumns;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The two directions of FR-PLAT-AND-6: what the app was opened *with*, and
+ * handing a finished export to somebody else.
+ *
+ * Every call below is reachable over JNI, and doing it that way would be
+ * roughly forty {@code call_method} invocations with their signatures written
+ * out as strings — each one a name Java checks at run time and nothing checks
+ * at build time. The Rust side would then hold the exact logic that is here,
+ * expressed less clearly, and a typo in {@code "()Landroid/content/Intent;"}
+ * would surface on a device as a {@code NoSuchMethodError} rather than at the
+ * compiler. So the platform work stays on the platform's side and the JNI
+ * surface is two calls, both taking and returning strings.
+ *
+ * The class is only reachable because the APK now compiles Java at all; see
+ * docker/android/assemble-apk.sh.
+ */
+public final class Intents {
+ private static final String TAG = "DarkRoom";
+
+ /**
+ * Where incoming images are copied, under {@code getCacheDir()}.
+ *
+ * The cache and not the data directory, deliberately: these are copies
+ * of somebody else's file, the app has no claim on them once the session
+ * ends, and the cache is the one place Android may reclaim under storage
+ * pressure without the user being asked. Putting them in the data
+ * directory would grow the app's footprint by a RAW file per share, for
+ * ever, with nothing that ever deletes them.
+ */
+ private static final String INBOX = "incoming";
+
+ private Intents() {
+ }
+
+ /**
+ * The images this launch was asked to open, as paths the decoder can read.
+ *
+ * Empty for an ordinary launch from the launcher, which is the common
+ * case and not a failure.
+ *
+ * A share arrives as a {@code content://} URI, which is a handle into
+ * another app's provider and not a path — there is no filename behind it to
+ * open, and the grant that makes it readable belongs to this task and dies
+ * with it. DarkRoom's decoders take paths (ARCH §6.9 is the note that
+ * Android has no paths to give), so the choice is to copy or to teach the
+ * whole read path about URIs, and the second is FR-PLAT-AND-1's SAF
+ * connector, which is not built.
+ *
+ * So it is a copy, and the cost is honest: a 60 MB raw file is written
+ * once, to the cache, before the viewer opens. It is bounded by the share
+ * being a deliberate act — a person picked these files — rather than by
+ * anything this code does.
+ *
+ * The inbox is emptied first. Without that, every share ever received
+ * accumulates until the platform decides the cache is too large, and the
+ * files are indistinguishable from each other by then.
+ */
+ public static String[] receive(Activity activity) {
+ List Returns false when there is nothing to offer it to, or when the file
+ * is not one {@link ExportProvider} may serve — both of which the caller
+ * has to be able to say out loud, because from the user's side a share
+ * button that does nothing is indistinguishable from one that failed.
+ *
+ * {@code FLAG_GRANT_READ_URI_PERMISSION} is the whole security model:
+ * the provider is not exported, so the receiving app can reach this one
+ * file, for as long as its task lives, and nothing else ever.
+ */
+ public static boolean share(Activity activity, String path, String mimeType) {
+ Uri uri = ExportProvider.uriFor(activity, new File(path));
+ if (uri == null) {
+ return false;
+ }
+
+ Intent send = new Intent(Intent.ACTION_SEND);
+ send.setType(mimeType != null && !mimeType.isEmpty() ? mimeType : "image/*");
+ send.putExtra(Intent.EXTRA_STREAM, uri);
+ send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ // Always a chooser, never a direct start. Android's "remembered
+ // default" for ACTION_SEND is a per-user setting this app has no
+ // business consuming: the app a photograph should go to differs every
+ // time, and the one time it does not, the sheet is one extra tap.
+ Intent chooser = Intent.createChooser(send, null);
+ try {
+ activity.startActivity(chooser);
+ return true;
+ } catch (ActivityNotFoundException e) {
+ Log.w(TAG, "nothing installed accepts " + mimeType + ": " + e);
+ return false;
+ }
+ }
+
+ /**
+ * The URIs an Intent carries, by the action that carried them.
+ *
+ * Only the actions the manifest registers for. An action we did not
+ * declare cannot arrive, so handling one here would be code that reads as
+ * support for something the launcher will never offer.
+ */
+ @SuppressWarnings("deprecation")
+ private static List The name is chosen by another application and lands in a path this one
+ * composes, so it is filtered rather than trusted: a name containing a
+ * separator would place the copy outside the inbox, and one beginning with
+ * a dot would hide it from everything that lists the directory. What
+ * survives is the part a photographer recognises — {@code DSC_4471.NEF} —
+ * which is the only reason to use the sender's name at all.
+ */
+ private static String displayName(Context context, Uri uri) {
+ String name = null;
+ Cursor cursor = null;
+ try {
+ cursor = context.getContentResolver().query(
+ uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null);
+ if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
+ name = cursor.getString(0);
+ }
+ } catch (Exception e) {
+ // Providers are other people's code and any of them may throw.
+ // A name is a convenience; failing the whole open over it is not.
+ Log.d(TAG, "no display name for " + uri + ": " + e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ if (name == null) {
+ name = uri.getLastPathSegment();
+ }
+ if (name == null) {
+ return "shared";
+ }
+ StringBuilder safe = new StringBuilder(name.length());
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+ || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_';
+ safe.append(ok ? c : '_');
+ }
+ while (safe.length() > 0 && safe.charAt(0) == '.') {
+ safe.deleteCharAt(0);
+ }
+ return safe.length() > 0 ? safe.toString() : "shared";
+ }
+
+ /**
+ * A name nothing in the inbox has yet.
+ *
+ * A multi-image share of a burst arrives as several files a camera named
+ * the same thing in different folders, and the second one silently
+ * overwriting the first would show the user one photograph where they
+ * picked four.
+ */
+ private static String unique(File inbox, String name, int index) {
+ if (!new File(inbox, name).exists()) {
+ return name;
+ }
+ return index + "-" + name;
+ }
+
+ private static void close(java.io.Closeable stream) {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (IOException e) {
+ Log.d(TAG, "close failed: " + e);
+ }
+ }
+ }
+
+ /** Delete the inbox's contents, one level deep, which is all it ever has. */
+ private static void empty(File inbox) {
+ File[] stale = inbox.listFiles();
+ if (stale == null) {
+ return;
+ }
+ for (File file : stale) {
+ if (!file.delete()) {
+ Log.d(TAG, "could not remove stale " + file);
+ }
+ }
+ }
+}
diff --git a/apps/darkroom-android/src/intents.rs b/apps/darkroom-android/src/intents.rs
new file mode 100644
index 0000000..24c55a9
--- /dev/null
+++ b/apps/darkroom-android/src/intents.rs
@@ -0,0 +1,192 @@
+//! What the app was launched with, and handing a finished export back out.
+//!
+//! FR-PLAT-AND-6's Rust side, which is deliberately the thin side. Both
+//! directions are implemented in `android/java/paris/tourolle/darkroom/` and
+//! everything here is the two calls that reach them; `Intents.java` carries the
+//! reasoning for the split. The short version is that a JNI method signature is
+//! a string Java resolves at run time and nothing checks at build time, so
+//! forty of them is forty ways for a rename to become a `NoSuchMethodError` on
+//! somebody's tablet. Two is two.
+//!
+//! # Nothing here fails loudly
+//!
+//! A class the loader cannot see, a pending Java exception, a shared URI whose
+//! grant died with the task that received it: each ends as a log line and an
+//! empty result. This runs on the way to [`dr_ui::run`], before a window
+//! exists, and the alternative to opening with an empty browsing list is not
+//! opening at all.
+
+use std::path::{Path, PathBuf};
+
+use jni::errors::Result as JniResult;
+use jni::objects::{JClass, JObject, JObjectArray, JString, JValue};
+use jni::{JNIEnv, JavaVM};
+
+/// The class both directions live in, named the way `loadClass` wants it —
+/// dots, not slashes. `find_class` takes the other form, and this code calls
+/// neither by accident; see [`load_class`].
+const INTENTS: &str = "paris.tourolle.darkroom.Intents";
+
+/// The images this launch was asked to open, already local and readable.
+///
+/// Empty for an ordinary launch from the launcher, which is the common case
+/// and not a failure. What comes back is passed to `dr_ui::run` exactly as
+/// command-line paths are on the desktop, so a shared photograph becomes the
+/// browsing list and `startup_action` shows it rather than the launch screen.
+pub fn launch_images(app: &slint::android::AndroidApp) -> VecThe one directory
+ *
+ * Why this is Java and not JNI in lib.rs
+ *
+ * Why the bytes are copied
+ *
+ *