Files
DarkRoom/apps/darkroom-android/android/java/paris/tourolle/darkroom/ExportProvider.java
T
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

261 lines
10 KiB
Java

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