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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user