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>
This commit is contained in:
2026-08-29 23:32:48 +02:00
co-authored by Claude Opus 5
parent 24bf5be574
commit 09dddde594
7 changed files with 1075 additions and 3 deletions
Generated
+1
View File
@@ -1226,6 +1226,7 @@ dependencies = [
"android_logger", "android_logger",
"dr-sync", "dr-sync",
"dr-ui", "dr-ui",
"jni 0.21.1",
"log", "log",
"slint", "slint",
] ]
+15
View File
@@ -26,6 +26,21 @@ slint.workspace = true
log.workspace = true log.workspace = true
android_logger = "0.15" 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] [features]
# Mirrors darkroom-desktop: the CPU readback path is gone since S1 landed # 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 # zero-copy. It mattered more here than on desktop — the same wrong path with
@@ -7,6 +7,12 @@
here is a distribution manifest yet. Only network access is declared: file here is a distribution manifest yet. Only network access is declared: file
access needs no manifest permission because the library grid reads through access needs no manifest permission because the library grid reads through
SAF, which grants per-tree at runtime (ARCH §6.9). 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).
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="paris.tourolle.darkroom"> package="paris.tourolle.darkroom">
@@ -51,10 +57,30 @@
<!-- NativeActivity rather than a Kotlin Activity: android-activity's <!-- NativeActivity rather than a Kotlin Activity: android-activity's
glue loads libdarkroom.so and calls android_main. `android.app.lib_name` glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
is how it learns which library to load, and must match [lib].name. --> is how it learns which library to load, and must match [lib].name.
`singleTask` because a second instance of this activity is not
survivable. The intent filters below mean another app can now
launch it while it is already running, and under the default
launch mode that starts a *second* NativeActivity — in the
caller's task, in this same process, calling android_main again.
Two Slint backends and two wgpu devices in one process is not a
degraded experience, it is a failed second launch on top of a
working first one.
What it costs, stated plainly: a share that arrives while DarkRoom
is already running brings it forward without opening the image.
The Intent goes to `onNewIntent`, and android-activity's event
stream has no variant for it (MainEvent in 0.6 stops at Destroy),
so nothing native ever sees it. Reading it would mean a Java
Activity subclass forwarding it across JNI — the same shape of
change FR-PLAT-AND-5 declined for onTrimMemory, and for the same
reason. Launched from cold, which is the ordinary case for "open
this photograph", the Intent is on getIntent() and is read. -->
<activity <activity
android:name="android.app.NativeActivity" android:name="android.app.NativeActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@@ -66,6 +92,80 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- FR-PLAT-AND-6, inbound. The traceability tool reads .rs,
.slint, .wgsl and .yaml, so this is a reference and not a
tag; the tag that counts is on the test in lib.rs that
asserts these declarations are still here.
Opening a photograph from a gallery, a file manager or a
download. `android_main` reads the launch Intent through
`Intents.receive` and the named images become the browsing
list, exactly as paths on the desktop command line do.
`image/*` and not a wider match, even though it misses raws:
a provider that does not recognise `.CR3` reports it as
`application/octet-stream`, and claiming that type would put
DarkRoom in the chooser for every unidentified binary on the
device — an APK, a database, a partial download. Being absent
from one gallery's menu is a smaller failure than being
present in all of them. DNG, which providers do know as
`image/x-adobe-dng`, matches here already.
BROWSABLE is what lets a browser's finished download and a
link hand the file over; without it those routes silently do
not list the app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- The share sheet, one photograph or a selection of them.
SEND_MULTIPLE is declared because the sheet offers this app
for a multi-selection only if it says it accepts one, and a
culling tool that can be sent a single frame and not a burst
is the wrong way round.
ACTION_EDIT is deliberately not here. It is a promise to write
the result back to the URI it was handed, and nothing in this
app does: an edit lands in a sidecar beside the original
(FR-CAT-8). Registering for it would put DarkRoom in the "edit
with" menu and lose the user's work every time. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity> </activity>
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
between apps since API 24 — handing one out raises
FileUriExposedException in *this* process — so an exported JPEG
reaches the share sheet as a content:// URI or not at all.
Not AndroidX's FileProvider: that is a Maven artefact, and this
build has no Gradle and no dependency resolver (docker/android/
README.md). ExportProvider does the same hundred lines against one
fixed root.
`exported="false"` with `grantUriPermissions="true"` is the whole
security model, and the two halves are not redundant. Exported
false means no app may address the provider on its own account;
the grant flag means a URI this app puts in an Intent carries a
read permission for that one file, for the lifetime of the
receiving task. Without the grant flag the share sheet opens and
every target fails with SecurityException; with `exported="true"`
instead, every app on the device could read the app's private
directory. The authority must equal ExportProvider.AUTHORITY — a
mismatch is a SecurityException in somebody else's app, so a test
in lib.rs compares the two strings. -->
<provider
android:name="paris.tourolle.darkroom.ExportProvider"
android:authorities="paris.tourolle.darkroom.exports"
android:exported="false"
android:grantUriPermissions="true" />
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,260 @@
package paris.tourolle.darkroom;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import android.util.Log;
import android.webkit.MimeTypeMap;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
/**
* Hands an exported file to another app, and hands out nothing else.
*
* <p>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.
*
* <h2>Why this is not AndroidX's FileProvider</h2>
*
* <p>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 <meta-data>} resource is a constant here instead, because there is
* exactly one directory worth serving and a second place to state it is a
* second place for it to be wrong.
*
* <h2>The one directory</h2>
*
* <p>{@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.
*
* <p>{@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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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<String> segments = uri.getPathSegments();
if (segments.isEmpty()) {
return null;
}
try {
File root = root(context);
File candidate = root;
for (String segment : segments) {
candidate = new File(candidate, segment);
}
candidate = candidate.getCanonicalFile();
if (within(root, candidate) == null || !candidate.isFile()) {
Log.w(TAG, "refused " + uri);
return null;
}
return candidate;
} catch (IOException e) {
Log.w(TAG, "refused " + uri + ": " + e);
return null;
}
}
/**
* {@code target}'s path relative to {@code root}, or null if it is not
* under it.
*
* <p>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());
}
}
@@ -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.
*
* <h2>Why this is Java and not JNI in lib.rs</h2>
*
* <p>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.
*
* <p>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()}.
*
* <p>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.
*
* <p>Empty for an ordinary launch from the launcher, which is the common
* case and not a failure.
*
* <h3>Why the bytes are copied</h3>
*
* <p>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.
*
* <p>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.
*
* <p>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<Uri> uris = incoming(activity.getIntent());
if (uris.isEmpty()) {
return new String[0];
}
File inbox = new File(activity.getCacheDir(), INBOX);
empty(inbox);
if (!inbox.mkdirs() && !inbox.isDirectory()) {
Log.e(TAG, "cannot create " + inbox + "; the launch intent is dropped");
return new String[0];
}
List<String> paths = new ArrayList<String>();
for (Uri uri : uris) {
String path = localise(activity, uri, inbox, paths.size());
if (path != null) {
paths.add(path);
}
}
Log.i(TAG, "launch intent carried " + paths.size() + " of " + uris.size() + " image(s)");
return paths.toArray(new String[0]);
}
/**
* Offer a file this app produced to whatever else is installed.
*
* <p>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.
*
* <p>{@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.
*
* <p>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<Uri> incoming(Intent intent) {
List<Uri> uris = new ArrayList<Uri>();
if (intent == null) {
return uris;
}
String action = intent.getAction();
if (Intent.ACTION_VIEW.equals(action)) {
add(uris, intent.getData());
} else if (Intent.ACTION_SEND.equals(action)) {
// The typed getParcelableExtra(String, Class) overload is API 33,
// and minSdk is 28. The deprecated form is the only one that exists
// on every device this APK installs on.
add(uris, (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM));
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> many = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (many != null) {
for (Uri uri : many) {
add(uris, uri);
}
}
}
return uris;
}
private static void add(List<Uri> uris, Uri uri) {
if (uri != null) {
uris.add(uri);
}
}
/** A URI as a readable path, copying it into the inbox if it is not one already. */
private static String localise(Context context, Uri uri, File inbox, int index) {
// A file:// URI is already a path, and copying it would double a raw
// file on disk to no end. Rare — the platform has refused file:// URIs
// between apps since API 24 — but it is what a shell `am start -d
// file:///sdcard/…` produces, which is how this path gets tested
// without a second app installed.
if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
String path = uri.getPath();
if (path != null && new File(path).canRead()) {
return path;
}
Log.w(TAG, "cannot read " + uri);
return null;
}
File dest = new File(inbox, unique(inbox, displayName(context, uri), index));
InputStream in = null;
OutputStream out = null;
try {
in = context.getContentResolver().openInputStream(uri);
if (in == null) {
Log.w(TAG, "no stream behind " + uri);
return null;
}
out = new FileOutputStream(dest);
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = in.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
out.flush();
return dest.getAbsolutePath();
} catch (IOException e) {
Log.w(TAG, "cannot copy " + uri + ": " + e);
// The partial copy is removed rather than left: it has the name and
// the extension of a photograph and none of the bytes, and the
// decoder would report it as a corrupt file rather than a failed
// transfer.
dest.delete();
return null;
} catch (SecurityException e) {
// The grant on a shared URI dies with the task that received it.
// A process resumed from a saved state can find itself holding a
// URI it may no longer read (FR-PLAT-AND-3), and that is a lost
// permission rather than a broken file.
Log.w(TAG, "no longer permitted to read " + uri + ": " + e);
dest.delete();
return null;
} finally {
close(in);
close(out);
}
}
/**
* What the sending app calls the file, reduced to something safe to write.
*
* <p>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.
*
* <p>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);
}
}
}
}
+192
View File
@@ -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) -> Vec<PathBuf> {
with_activity(app, "reading the launch intent", |env, activity| {
let class = load_class(env, activity, INTENTS)?;
let returned = env
.call_static_method(
&class,
"receive",
"(Landroid/app/Activity;)[Ljava/lang/String;",
&[JValue::Object(activity)],
)?
.l()?;
let array = JObjectArray::from(returned);
let count = env.get_array_length(&array)?;
let mut paths = Vec::with_capacity(count as usize);
for i in 0..count {
let element = env.get_object_array_element(&array, i)?;
let text: String = env.get_string(&JString::from(element))?.into();
paths.push(PathBuf::from(text));
}
Ok(paths)
})
.unwrap_or_default()
}
/// Offer a file this app produced to whatever else is installed.
///
/// `false` means the sheet did not open — the file is not under the directory
/// [`ExportProvider`] serves, or nothing installed accepts the type. Both are
/// answers a caller has to be able to give the user, because a share control
/// that silently does nothing is indistinguishable from one that failed.
///
/// **This half has no caller yet, and that is the honest state of it.** The
/// provider, the URI grant and the chooser are all here and are what
/// FR-PLAT-AND-6 asks for; what is missing is a share control in the interface,
/// which lives in `ui/dr-ui` and needs one thing this signature shows: an
/// `AndroidApp` to call through. Wiring it means keeping a clone of the app —
/// it is `Clone` and cheap — somewhere `ui/` can reach, which is a change to
/// how the platform entry point talks to the interface rather than a change
/// here. Until that exists this function is reachable and untested, and it is
/// deliberately not tagged as covering the requirement.
///
/// `mime` decides which applications the chooser offers; the empty string
/// falls back to `image/*` on the Java side.
pub fn share(app: &slint::android::AndroidApp, file: &Path, mime: &str) -> bool {
with_activity(app, "opening the share sheet", |env, activity| {
let class = load_class(env, activity, INTENTS)?;
let path = env.new_string(file.to_string_lossy().as_ref())?;
let mime = env.new_string(mime)?;
env.call_static_method(
&class,
"share",
"(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
&[
JValue::Object(activity),
JValue::Object(&path),
JValue::Object(&mime),
],
)?
.z()
})
.unwrap_or(false)
}
/// Attach to the JVM, borrow the activity, and run `body` against both.
///
/// Shared by the two entry points because the three steps before the
/// interesting one are identical and each has its own way of failing. `body`
/// returning `Err` is reported here, once, in the one place that can also clear
/// a pending Java exception — see [`report`].
fn with_activity<T>(
app: &slint::android::AndroidApp,
doing: &str,
body: impl FnOnce(&mut JNIEnv, &JObject) -> JniResult<T>,
) -> Option<T> {
let vm = match unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) } {
Ok(vm) => vm,
Err(e) => {
log::error!("no JVM handle, so {doing} is skipped: {e}");
return None;
}
};
// Cheap when the thread is already attached, which it is: the glue
// attached it before it called `android_main`. The guard exists for the
// case where it is not, and costs a lookup where it is.
let mut env = match vm.attach_current_thread() {
Ok(env) => env,
Err(e) => {
log::error!("cannot attach to the JVM, so {doing} is skipped: {e}");
return None;
}
};
// SAFETY: `activity_as_ptr` documents this as an unowned JNI *global*
// reference to the Activity, valid for as long as the `AndroidApp` it came
// from. `JObject` in jni 0.21 is a plain wrapper with no `Drop`, so
// borrowing it here cannot delete a reference this code does not own — the
// one way to get this wrong is `AutoLocal` or a `GlobalRef`, both of which
// would free it out from under android-activity.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
match body(&mut env, &activity) {
Ok(value) => Some(value),
Err(e) => {
report(&mut env, doing, &e);
None
}
}
}
/// Look an app class up through the *activity's* class loader.
///
/// `find_class` is the obvious call and the wrong one. JNI resolves a class
/// against the loader belonging to the Java frame beneath the call, and on this
/// thread there is no such frame: `android_main` runs on a thread the native
/// glue created and attached itself, so the loader in scope is the system one.
/// It knows every class in the platform and nothing at all from this APK, and
/// says so as a `ClassNotFoundException` naming a class that is plainly in the
/// dex — which reads as a broken build rather than as the wrong loader.
///
/// The activity is a Java object, so its loader is the app's.
fn load_class<'local>(
env: &mut JNIEnv<'local>,
activity: &JObject,
name: &str,
) -> JniResult<JClass<'local>> {
let loader = env
.call_method(activity, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])?
.l()?;
let name = env.new_string(name)?;
let class = env
.call_method(
&loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&name)],
)?
.l()?;
Ok(JClass::from(class))
}
/// Log a JNI failure, and clear the exception behind it if there is one.
///
/// The clearing is not tidiness. A Java exception raised through JNI stays
/// *pending* on the thread, and the next JNI call made while one is pending
/// aborts the process — so a swallowed exception here would come back as a
/// crash somewhere unrelated, most likely inside Slint. `exception_describe`
/// first, because the trace it prints to logcat is the only place the Java
/// class and line survive; `jni::errors::Error::JavaException` on its own says
/// neither.
fn report(env: &mut JNIEnv, doing: &str, e: &jni::errors::Error) {
log::error!("{doing} failed: {e}");
if let Ok(true) = env.exception_check() {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
}
+186 -2
View File
@@ -8,6 +8,17 @@
//! browsing list and the library grid is the only way in. //! browsing list and the library grid is the only way in.
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android //! * Logging goes to logcat. `env_logger` writes to stderr, which Android
//! discards. //! discards.
//!
//! The first of those has one exception, and it is the launch `Intent`: a
//! gallery, a file manager or the share sheet can name images to open, and
//! those arrive as URIs on an `Intent` rather than as words on a command line.
//! [`intents`] turns them into paths, and from there they are the same list
//! the desktop builds from `argv` (FR-PLAT-AND-6).
// The whole module is JNI against classes that exist only in the APK, so it
// is gated with everything else that cannot compile off-device.
#[cfg(target_os = "android")]
mod intents;
// `slint::android` exists only when compiling for Android, so the whole entry // `slint::android` exists only when compiling for Android, so the whole entry
// point is gated on the target rather than on a feature. Without this the // point is gated on the target rather than on a feature. Without this the
@@ -51,6 +62,12 @@ fn android_main(app: slint::android::AndroidApp) {
// After the data dir and before anything asks whether a model is present. // After the data dir and before anything asks whether a model is present.
install_bundled_face_models(&app); install_bundled_face_models(&app);
// Before `init_with_event_listener`, which takes `app` by value and is the
// last moment anything can ask the activity a question. Not an ordering
// preference — after that line there is no `app` left to read the Intent
// through.
let opened_with = intents::launch_images(&app);
// TRACES: FR-PLAT-AND-5 // TRACES: FR-PLAT-AND-5
// The listener is the whole reason this is not the one-line // The listener is the whole reason this is not the one-line
// `slint::android::init(app)`. Slint owns the event loop on Android, so // `slint::android::init(app)`. Slint owns the event loop on Android, so
@@ -96,11 +113,15 @@ fn android_main(app: slint::android::AndroidApp) {
return; return;
} }
// Empty rather than the desktop's argv: see the module note above. // The launch Intent's images, where there were any, standing in for the
// desktop's argv — `launch::startup_action` treats a non-empty list as
// "the user asked for these specifically", which is exactly what a share
// or a tap in a gallery is. Empty for an ordinary launch, and the library
// opens as before.
// //
// Returning from `android_main` ends the process, so a failure here is // Returning from `android_main` ends the process, so a failure here is
// logged rather than propagated — there is no shell to show `Err` to. // logged rather than propagated — there is no shell to show `Err` to.
if let Err(e) = dr_ui::run(Vec::new()) { if let Err(e) = dr_ui::run(opened_with) {
log::error!("DarkRoom exited with error: {e:#}"); log::error!("DarkRoom exited with error: {e:#}");
} }
} }
@@ -179,3 +200,166 @@ fn install_bundled_face_models(app: &slint::android::AndroidApp) {
} }
} }
} }
/// TRACES: FR-PLAT-AND-6
/// The declarations that make this app a receiver, held to on the host.
///
/// Everything FR-PLAT-AND-6 does on a device is unreachable from `cargo test`:
/// there is no `Intent` off-device and no `ContentProvider` to instantiate. But
/// the requirement is not only behaviour — half of it is *declaration*, and a
/// declaration can be wrong in ways that compile perfectly and fail silently.
/// An intent filter that is deleted takes the app out of every gallery's "open
/// with" menu with nothing to notice; an authority that stops matching the
/// class it names raises a `SecurityException` in whichever other app opened
/// the share sheet, which is the last place anybody would look for it.
///
/// The manifest is read by aapt2 and the Java by javac, so a Rust build sees
/// neither. `include_str!` is what puts them where a test can reach them, and
/// this is the only place in the workspace that does.
#[cfg(test)]
mod tests {
/// The manifest with its comments removed and its whitespace flattened, so
/// a match is about the declaration and not about how it is indented.
fn manifest() -> String {
let xml = include_str!("../android/AndroidManifest.xml");
let mut out = String::with_capacity(xml.len());
let mut rest = xml;
// Comments first, and not by regex over the whole file: several of them
// quote the very attribute names the assertions below look for, so a
// test that read them would pass on the strength of the prose
// explaining an entry that had been deleted.
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The body of each `<intent-filter>`, so an action and a MIME type are
/// checked to be in the *same* filter. Two filters, one naming the action
/// and one naming the type, register for neither.
fn intent_filters(manifest: &str) -> Vec<&str> {
manifest
.split("<intent-filter>")
.skip(1)
.filter_map(|filter| filter.split("</intent-filter>").next())
.collect()
}
/// The single `<provider>` element, attributes and all.
fn provider(manifest: &str) -> String {
let start = manifest
.find("<provider")
.expect("no <provider> in the manifest");
let rest = &manifest[start..];
let end = rest.find("/>").expect("unterminated <provider> element");
rest[..end + 2].to_string()
}
fn attribute(element: &str, name: &str) -> Option<String> {
let key = format!("{name}=\"");
let start = element.find(&key)? + key.len();
let value = element[start..].split('"').next()?;
Some(value.to_string())
}
#[test]
fn a_gallery_can_open_a_photograph_in_this_app() {
let manifest = manifest();
let registered = intent_filters(&manifest).iter().any(|filter| {
filter.contains("android.intent.action.VIEW")
&& filter.contains("android.intent.category.DEFAULT")
&& filter.contains(r#"android:mimeType="image/*""#)
});
assert!(
registered,
"no VIEW filter for image/*: nothing will offer DarkRoom for a photograph"
);
}
#[test]
fn the_share_sheet_can_send_one_image_or_several() {
let manifest = manifest();
let registered = intent_filters(&manifest).iter().any(|filter| {
// The closing quote matters: SEND is a prefix of SEND_MULTIPLE, so
// a bare substring test passes on a filter that declares only the
// second and would not be offered for a single photograph.
filter.contains(r#"android.intent.action.SEND""#)
&& filter.contains(r#"android.intent.action.SEND_MULTIPLE""#)
&& filter.contains("android.intent.category.DEFAULT")
&& filter.contains(r#"android:mimeType="image/*""#)
});
assert!(
registered,
"no SEND/SEND_MULTIPLE filter for image/*, so the share sheet will not list DarkRoom"
);
}
#[test]
fn one_activity_ever_so_a_second_launch_cannot_start_a_second_one() {
// Not style. Another app can now launch this activity while it is
// already running, and the default launch mode answers that by
// creating a second NativeActivity in this process — a second
// android_main, a second Slint backend, a second wgpu device.
assert!(
manifest().contains(r#"android:launchMode="singleTask""#),
"the activity must be singleTask; see the manifest comment"
);
}
#[test]
fn the_provider_authority_is_the_one_the_class_answers_to() {
let manifest = manifest();
let element = provider(&manifest);
let declared =
attribute(&element, "android:authorities").expect("the provider declares no authority");
let java = include_str!("../android/java/paris/tourolle/darkroom/ExportProvider.java");
let constant = java
.split("AUTHORITY = \"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("ExportProvider declares no AUTHORITY constant");
assert_eq!(
declared, constant,
"the manifest and ExportProvider disagree about the authority; \
a share would fail as a SecurityException inside the receiving app"
);
let class = attribute(&element, "android:name").expect("the provider declares no class");
let (package, _) = class
.rsplit_once('.')
.expect("the provider class is unqualified");
assert!(
java.contains(&format!("package {package};")),
"the manifest names {class}, which is not the class in ExportProvider.java"
);
}
#[test]
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
let manifest = manifest();
let element = provider(&manifest);
// The two halves are not redundant. Without the grant, every share
// target fails; exported, every app on the device could read this
// app's private directory.
assert_eq!(
attribute(&element, "android:exported").as_deref(),
Some("false"),
"an exported provider would serve the app's private directory to anything installed"
);
assert_eq!(
attribute(&element, "android:grantUriPermissions").as_deref(),
Some("true"),
"without URI grants the share sheet opens and every target fails to read the file"
);
}
}