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. * *
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. * *
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 {@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.
*
* {@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.
*
* 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.
*
* 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.
*
* 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.
*
* 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.
*
* 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 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());
}
}
The one directory
*
*