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

Why this is not AndroidX's FileProvider

* *

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

The one directory

* *

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

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