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>
321 lines
13 KiB
Java
321 lines
13 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|
|
}
|