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. * *

Why this is Java and not JNI in lib.rs

* *

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. * *

Why the bytes are copied

* *

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 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 paths = new ArrayList(); 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. * *

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 incoming(Intent intent) { List uris = new ArrayList(); 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 many = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); if (many != null) { for (Uri uri : many) { add(uris, uri); } } } return uris; } private static void add(List 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. * *

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