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:
@@ -158,6 +158,18 @@
|
||||
android:theme="@style/ManualTheme"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
|
||||
this device. NativeActivity's onActivityResult is not ours, so
|
||||
this activity exists only to ask and hand the answer back (see
|
||||
FolderPicker.java). Translucent and without a title so nothing
|
||||
of it shows but the system chooser; not exported, and started by
|
||||
class name from dr_ui::saf. -->
|
||||
<activity
|
||||
android:name="paris.tourolle.darkroom.FolderPicker"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||
between apps since API 24 — handing one out raises
|
||||
FileUriExposedException in *this* process — so an exported JPEG
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user