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