Give an album a folder on the tablet, through Android's folder picker

Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.

The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.

Two things the first build on the tablet got wrong, recorded where they
are fixed:

- Our classes must be loaded through Context.getClassLoader(). The
  class of what ndk_context holds is a framework class from the boot
  loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
  and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.

Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
This commit is contained in:
2026-09-26 14:13:53 -04:00
parent 7cbcacc02e
commit 92d4b23bed
7 changed files with 502 additions and 0 deletions
@@ -0,0 +1,117 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
/**
* The system's folder picker, for an album's folder on this device (FR-EXP-10).
*
* <h2>Why an activity of its own</h2>
*
* <p>{@code ACTION_OPEN_DOCUMENT_TREE} answers through
* {@code onActivityResult}, and the main activity is {@code NativeActivity},
* whose result callback is not ours to override. So this one exists only to
* ask: it starts the picker, takes the answer, and finishes — no layout, a
* translucent theme, nothing on screen but the system's own chooser, which has
* its own "New folder".
*
* <p>The answer is left in a static for Rust to poll ({@link #poll}), rather
* than called back into native code: a callback would need a registered
* native method and a thread to deliver on, and a poll from the Slint timer
* that is already running is one static call.
*
* <h2>The grant</h2>
*
* <p>A tree URI is usable only while its permission is held, and a plain
* result grants it until the process dies. {@code takePersistableUriPermission}
* keeps it across restarts — an album's folder is chosen once and exported to
* for months.
*/
public final class FolderPicker extends Activity {
private static final String TAG = "DarkRoom";
private static final int REQUEST = 0x5AF;
/** The last answer: a tree URI, "" for a cancel, null while none has come. */
private static volatile String answer = null;
/**
* Start asking. Clears any answer left from before.
*
* <p>Takes a {@code Context} rather than an {@code Activity}, because what
* native code holds (ndk_context's handle) is the application context,
* and starting an activity from one that is not an activity needs
* {@code FLAG_ACTIVITY_NEW_TASK} — without it the call throws. The picker
* shares the app's task affinity, so it still opens over the app and Back
* still returns to it.
*/
public static void start(Context from) {
answer = null;
Intent intent = new Intent(from, FolderPicker.class);
if (!(from instanceof Activity)) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
from.startActivity(intent);
}
/**
* The answer, once: a tree URI, "" if the user backed out, or null while
* the picker is still open. Reading it clears it, so a second poll after a
* cancel does not see the cancel again.
*/
public static String poll() {
String a = answer;
if (a != null) {
answer = null;
}
return a;
}
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
// Recreated after a rotation with the picker already up: asking again
// would stack a second chooser over the first.
if (state != null) {
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
try {
startActivityForResult(pick, REQUEST);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "no folder picker on this device", e);
answer = "";
finish();
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
if (request != REQUEST) {
return;
}
Uri tree = (result == RESULT_OK && data != null) ? data.getData() : null;
if (tree == null) {
answer = "";
} else {
try {
getContentResolver().takePersistableUriPermission(tree,
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} catch (SecurityException e) {
// Still usable this session; said in the log so a folder that
// stops working after a restart has an explanation.
Log.w(TAG, "the folder grant could not be kept: " + tree, e);
}
answer = tree.toString();
}
finish();
}
}
@@ -0,0 +1,114 @@
package paris.tourolle.darkroom;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.util.Log;
import java.io.IOException;
import java.io.OutputStream;
/**
* Writing an export into a folder the user granted through
* {@link FolderPicker} — the Storage Access Framework, which is the only way
* this app reaches a folder on the device (FR-PLAT-AND-1).
*
* <p>A tree URI is not a path: a child is found by listing the folder and
* matching its display name, and created through the provider, which may
* rename it on a collision. So the name that was actually written is handed
* back, and the album records that one.
*
* <p>Two static calls, strings and a byte array in, a string out, for the
* reason {@link Intents} gives: every call here would be a signature typed as
* a string on the Rust side, and the fewer of those the better.
*/
public final class Saf {
private static final String TAG = "DarkRoom";
private Saf() {
}
/** Whether {@code name} already exists in the folder. False on any error. */
public static boolean exists(Context context, String tree, String name) {
try {
return find(context.getContentResolver(), Uri.parse(tree), name) != null;
} catch (RuntimeException e) {
Log.w(TAG, "checking " + name + " in " + tree, e);
return false;
}
}
/**
* Write {@code bytes} as {@code name} in the folder, replacing a file of
* that name when {@code replace} is set.
*
* @return the name the file has in the folder — the provider may have
* added " (1)" — or null on failure, with the reason in the log.
*/
public static String write(Context context, String tree, String name, String mime,
byte[] bytes, boolean replace) {
ContentResolver resolver = context.getContentResolver();
Uri treeUri = Uri.parse(tree);
try {
Uri target = replace ? find(resolver, treeUri, name) : null;
if (target == null) {
Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri,
DocumentsContract.getTreeDocumentId(treeUri));
target = DocumentsContract.createDocument(resolver, folder, mime, name);
}
if (target == null) {
Log.w(TAG, "the folder refused to create " + name + " in " + tree);
return null;
}
// "wt": truncate. A replacement shorter than what it replaces
// must not keep the old file's tail.
try (OutputStream out = resolver.openOutputStream(target, "wt")) {
if (out == null) {
Log.w(TAG, "no stream for " + target);
return null;
}
out.write(bytes);
}
String written = displayName(resolver, target);
return written != null ? written : name;
} catch (IOException | RuntimeException e) {
Log.w(TAG, "writing " + name + " to " + tree, e);
return null;
}
}
/** The document for {@code name} directly in the tree's folder, or null. */
private static Uri find(ContentResolver resolver, Uri tree, String name) {
String folderId = DocumentsContract.getTreeDocumentId(tree);
Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId);
String[] columns = {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
};
try (Cursor c = resolver.query(children, columns, null, null, null)) {
if (c == null) {
return null;
}
while (c.moveToNext()) {
if (name.equals(c.getString(1))) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
}
}
}
return null;
}
private static String displayName(ContentResolver resolver, Uri document) {
String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
try (Cursor c = resolver.query(document, columns, null, null, null)) {
if (c != null && c.moveToFirst()) {
return c.getString(0);
}
} catch (RuntimeException e) {
Log.w(TAG, "reading the name of " + document, e);
}
return null;
}
}