From 196e41e3847eea042b716aae2225149d9790d27b Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 11:47:53 -0700 Subject: [PATCH] feat(android): open notebooks from document providers The menu opens a notebook folder or a lone section file that another app keeps, through Android's document picker. The app keeps the grant and what was picked, and opens it again at every start, from the copies on this device while the provider can't be reached. Without a pick, or when its grant is gone, the notebook in app storage opens as before. The bar names the notebook and, when saving is in trouble, says whether it is offline, read only, not saving, or no longer allowed. The provider table snowbound.h describes is answered through ContentResolver and DocumentsContract, each document named by its URI. A descriptor goes to Rust as a duplicate while the original stays open, so that closing it can report the provider's error. Leaving the app or switching notebooks publishes what it can and keeps the offline copies. The provider lab serves a second root, a real notebook folder in its own storage, which its offline and revoke controls cover too, for trying all this by hand. Refs #24 Assisted-by: claude-opus-5.5 --- apps/android/jni/snowbound.c | 139 +++++++++++++++++- apps/android/res/values/themes.xml | 3 +- .../src/net/paperclover/snowbound/Editor.java | 102 +++++++++++-- .../paperclover/snowbound/PageActivity.java | 74 ++++++++-- .../net/paperclover/snowbound/PageView.java | 5 + .../src/net/paperclover/snowbound/Picked.java | 72 +++++++++ .../net/paperclover/snowbound/Provider.java | 116 +++++++++++++++ tools/android/provider-lab/README.md | 7 + .../provider-lab/provider/Control.java | 3 + .../provider-lab/provider/LabProvider.java | 50 ++++++- 10 files changed, 533 insertions(+), 38 deletions(-) create mode 100644 apps/android/src/net/paperclover/snowbound/Picked.java create mode 100644 apps/android/src/net/paperclover/snowbound/Provider.java diff --git a/apps/android/jni/snowbound.c b/apps/android/jni/snowbound.c index e4c343b88a4b5c53e12e67ee5a0562ef9901bc55..44608bf65debe41dfe154883eceef90ea50ad3fa 100644 --- a/apps/android/jni/snowbound.c +++ b/apps/android/jni/snowbound.c @@ -21,8 +21,9 @@ static void *log_stderr(void *pipe) { } static JavaVM *machine; -static jclass editor; -static jmethodID woken_method; +static jclass editor, provider; +static jmethodID woken_method, document_method, children_method, open_method, close_method, rename_method; +static const sb_provider documents; JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { (void)reserved; @@ -34,7 +35,17 @@ JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { editor = (*env)->NewGlobalRef(env, local); (*env)->DeleteLocalRef(env, local); woken_method = (*env)->GetStaticMethodID(env, editor, "woken", "()V"); - if (!woken_method) return JNI_ERR; + local = (*env)->FindClass(env, "net/paperclover/snowbound/Provider"); + if (!woken_method || !local) return JNI_ERR; + provider = (*env)->NewGlobalRef(env, local); + (*env)->DeleteLocalRef(env, local); + document_method = (*env)->GetStaticMethodID(env, provider, "document", "(Ljava/lang/String;JJ)I"); + children_method = (*env)->GetStaticMethodID(env, provider, "children", "(Ljava/lang/String;JJ)I"); + open_method = (*env)->GetStaticMethodID(env, provider, "open", "(Ljava/lang/String;Ljava/lang/String;[I[J)I"); + close_method = (*env)->GetStaticMethodID(env, provider, "close", "(J)I"); + rename_method = (*env)->GetStaticMethodID(env, provider, "rename", "(Ljava/lang/String;Ljava/lang/String;JJ)I"); + if (!document_method || !children_method || !open_method || !close_method || !rename_method) return JNI_ERR; + sb_set_provider(&documents); int ends[2]; pthread_t reader; if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 && @@ -127,17 +138,129 @@ static jstring output(JNIEnv *env, char *text) { return result; } -// Called on the section's own thread, which may not yet know the JVM. +// The JVM for a thread Rust may have started; `attached` says to detach once done. +static JNIEnv *jvm(bool *attached) { + JNIEnv *env; + *attached = (*machine)->GetEnv(machine, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED; + return *attached && (*machine)->AttachCurrentThread(machine, &env, NULL) != JNI_OK ? NULL : env; +} + static void woken(uintptr_t token) { (void)token; - JNIEnv *env; - bool attached = (*machine)->GetEnv(machine, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED; - if (attached && (*machine)->AttachCurrentThread(machine, &env, NULL) != JNI_OK) return; + bool attached; + JNIEnv *env = jvm(&attached); + if (!env) return; (*env)->CallStaticVoidMethod(env, editor, woken_method); if ((*env)->ExceptionCheck(env)) (*env)->ExceptionDescribe(env); if (attached) (*machine)->DetachCurrentThread(machine); } +// sb_provider, through Provider.java's methods of the same names; 4 where Java throws. +static int32_t provided(JNIEnv *env, bool attached, jint status) { + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); + status = 4; + } + if (attached) (*machine)->DetachCurrentThread(machine); + return status; +} + +static int32_t listed(jmethodID method, const char *id, sb_document_found found, void *context) { + bool attached; + JNIEnv *env = jvm(&attached); + if (!env) return 4; + jstring i = java_text(env, id); + jint status = i ? (*env)->CallStaticIntMethod(env, provider, method, i, (jlong)(intptr_t)found, + (jlong)(intptr_t)context) : 4; + if (i) (*env)->DeleteLocalRef(env, i); + return provided(env, attached, status); +} + +static int32_t provider_document(const char *id, sb_document_found found, void *context) { + return listed(document_method, id, found, context); +} + +static int32_t provider_children(const char *id, sb_document_found found, void *context) { + return listed(children_method, id, found, context); +} + +static int32_t provider_open(const char *id, const char *mode, int32_t *fd, uint64_t *handle) { + bool attached; + JNIEnv *env = jvm(&attached); + if (!env) return 4; + jstring i = java_text(env, id), m = i ? java_text(env, mode) : NULL; + jintArray fds = m ? (*env)->NewIntArray(env, 1) : NULL; + jlongArray handles = fds ? (*env)->NewLongArray(env, 1) : NULL; + jint status = handles ? (*env)->CallStaticIntMethod(env, provider, open_method, i, m, fds, handles) : 4; + if (!status && !(*env)->ExceptionCheck(env)) { + jint f; + jlong h; + (*env)->GetIntArrayRegion(env, fds, 0, 1, &f); + (*env)->GetLongArrayRegion(env, handles, 0, 1, &h); + *fd = f; + *handle = (uint64_t)h; + } + if (i) (*env)->DeleteLocalRef(env, i); + if (m) (*env)->DeleteLocalRef(env, m); + if (fds) (*env)->DeleteLocalRef(env, fds); + if (handles) (*env)->DeleteLocalRef(env, handles); + return provided(env, attached, status); +} + +static int32_t provider_close(uint64_t handle) { + bool attached; + JNIEnv *env = jvm(&attached); + if (!env) return 4; + return provided(env, attached, (*env)->CallStaticIntMethod(env, provider, close_method, (jlong)handle)); +} + +static int32_t provider_rename(const char *id, const char *name, void (*renamed)(void *, const char *), void *context) { + bool attached; + JNIEnv *env = jvm(&attached); + if (!env) return 4; + jstring i = java_text(env, id), n = i ? java_text(env, name) : NULL; + jint status = n ? (*env)->CallStaticIntMethod(env, provider, rename_method, i, n, (jlong)(intptr_t)renamed, + (jlong)(intptr_t)context) : 4; + if (i) (*env)->DeleteLocalRef(env, i); + if (n) (*env)->DeleteLocalRef(env, n); + return provided(env, attached, status); +} + +static const sb_provider documents = {provider_document, provider_children, provider_open, provider_close, + provider_rename}; + +#define PROVIDER(name) Java_net_paperclover_snowbound_Provider_##name + +JNIEXPORT void JNICALL PROVIDER(found)(JNIEnv *env, jclass cls, jlong found, jlong context, jstring id, jstring name, + jboolean directory, jlong size, jlong modified, jboolean writable) { + (void)cls; + char *i = utf8(env, id), *n = i ? utf8(env, name) : NULL; + if (n) ((sb_document_found)(intptr_t)found)((void *)(intptr_t)context, i, n, directory, size, modified, writable); + free(i), free(n); +} + +JNIEXPORT void JNICALL PROVIDER(renamed)(JNIEnv *env, jclass cls, jlong renamed, jlong context, jstring id) { + (void)cls; + char *i = utf8(env, id); + if (i) ((void (*)(void *, const char *))(intptr_t)renamed)((void *)(intptr_t)context, i); + free(i); +} + +JNIEXPORT jlong JNICALL JNI(provided)(JNIEnv *env, jclass cls, jstring root, jboolean folder, jboolean local, + jstring cache) { + (void)cls; + char *r = utf8(env, root), *c = r ? utf8(env, cache) : NULL, *error = NULL; + const Library *library = c ? sb_library_provider(r, folder, local, c, &error) : NULL; + if (c && !library) fail(env, error, "The notebook can't be opened"); + free(r), free(c); + return (jlong)(intptr_t)library; +} + +JNIEXPORT jstring JNICALL JNI(syncStatus)(JNIEnv *env, jclass cls, jlong library) { + (void)cls; + return output(env, sb_library_sync_status((const Library *)(intptr_t)library)); +} + JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) { (void)cls; char *n = utf8(env, notebook), *c = n ? utf8(env, cache) : NULL, *d = c ? utf8(env, date) : NULL, @@ -362,7 +485,7 @@ JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jl (void)env, (void)cls; char *error = NULL; bool closed = sb_library_flush((const Library *)(intptr_t)library, seconds, &error); - sb_section_free((Section *)(intptr_t)section); + if (section) sb_section_free((Section *)(intptr_t)section); if (error) { fprintf(stderr, "snowbound: %s\n", error); sb_string_free(error); diff --git a/apps/android/res/values/themes.xml b/apps/android/res/values/themes.xml index cd0a060536f096c4b3d63f1a5dce90e376120d91..bc21a0eb7ac733c6896611b3db783c979474821b 100644 --- a/apps/android/res/values/themes.xml +++ b/apps/android/res/values/themes.xml @@ -1,5 +1,6 @@ - diff --git a/apps/android/src/net/paperclover/snowbound/Editor.java b/apps/android/src/net/paperclover/snowbound/Editor.java index 4c26e79c2dcce54ea9e8ee6ccb032e8db66dbffe..e42b903e12f19e985e6ee9bef3afaba520c2f26d 100644 --- a/apps/android/src/net/paperclover/snowbound/Editor.java +++ b/apps/android/src/net/paperclover/snowbound/Editor.java @@ -12,6 +12,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; /** The page being edited, which outlives each activity showing it; touched only on the main thread. */ final class Editor { @@ -31,12 +32,23 @@ final class Editor { static PageActivity shown; /** The section's saving status, as `sb_section_poll` gives it. */ static int status; + /** The picked notebook's name, or null for the one in app storage. */ + static String title; + /** Why the picked notebook didn't open, shown over the one in app storage instead. */ + private static String problem; + private static String sectionPath; private static boolean opening; - /** Opens the first page of the notebook in app storage, creating the notebook the first time. */ + /** + * Opens the first page of the notebook picked from a document provider, else of the one in + * app storage, creating that the first time. + */ static void open(Context context) { if (opening) return; opening = true; + Provider.resolver = context.getContentResolver(); + Picked picked = Picked.load(context); + boolean granted = picked != null && picked.granted(context); File notebooks = new File(context.getFilesDir(), "Notebooks"); File notebook = new File(notebooks, "Snowbound"); File cache = new File(context.getNoBackupFilesDir(), "Cache"); @@ -44,19 +56,47 @@ final class Editor { String date = DateFormat.getDateInstance(DateFormat.FULL).format(now); String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now); WORK.execute(() -> { - if (!notebook.exists()) { - notebooks.mkdirs(); - create(notebook.getPath(), cache.getPath(), date, time); + String trouble = picked != null && !granted ? "Access Lost" : null; + long opened = 0, first = 0; + String path = null; + if (picked != null && granted) { + Provider.writable = picked.writable(); + try { + opened = provided(picked.root(), picked.folder, picked.local(), cache.getPath()); + path = firstSection(opened); + first = section(opened, path); + } catch (IllegalStateException | JSONException error) { + Log.w("snowbound", "Opening " + picked.name + ": " + error.getMessage()); + if (opened != 0) close(opened, 0, 0); + opened = 0; + trouble = "Offline"; + } } - long opened = library(notebook.getPath(), cache.getPath()); + String name = opened != 0 ? picked.name : null; try { - long first = section(opened, new JSONArray(sections(opened)).getJSONObject(0).getString("path")); + if (opened == 0) { + if (!notebook.exists()) { + notebooks.mkdirs(); + create(notebook.getPath(), cache.getPath(), date, time); + } + opened = library(notebook.getPath(), cache.getPath()); + path = firstSection(opened); + first = section(opened, path); + } String id = new JSONArray(pages(first)).getJSONObject(0).getString("id"); + long library = opened, section = first; + String problem = trouble, sectionPath = path; MAIN.post(() -> { - library = opened; - section = first; + Editor.library = library; + Editor.section = section; + Editor.sectionPath = sectionPath; + Editor.problem = problem; page = id; - if (shown != null) shown.page.attach(); + title = name; + if (shown != null) { + shown.showTitle(); + shown.page.attach(); + } poll(); }); } catch (JSONException error) { @@ -65,6 +105,40 @@ final class Editor { }); } + private static String firstSection(long library) throws JSONException { + return new JSONArray(sections(library)).getJSONObject(0).getString("path"); + } + + /** Opens the notebook picked since, once the one shown has let go. */ + static void reopen(Context context) { + if (shown != null) shown.page.release(); + // Its edits wait in the cache meanwhile, so the switch needn't wait for them to publish. + close(0); + open(context); + } + + /** What keeps the notebook from saving, if anything: only trouble shows, as on iOS. */ + static String trouble() { + if (problem != null) return problem; + if (status == 3 || status == 4) return readOnly() ? "Read Only" : status == 3 ? "Offline" : "Not Saving"; + return title != null && !Provider.writable ? "Read Only" : null; + } + + /** Whether the section's storage refuses writing, as a revoked or read-only grant does. */ + private static boolean readOnly() { + try { + JSONArray sections = new JSONArray(syncStatus(library)); + for (int i = 0; i < sections.length(); i++) { + JSONObject entry = sections.getJSONObject(i); + // SyncState by its place: ReadOnly is the fifth. + if (entry.getString("path").equals(sectionPath)) return entry.getInt("state") == 4; + } + } catch (JSONException | IllegalStateException error) { + Log.w("snowbound", "Reading the sync status: " + error.getMessage()); + } + return false; + } + /** Called on the section's thread when it has events. */ static void woken() { MAIN.post(Editor::poll); @@ -101,15 +175,15 @@ final class Editor { if (flushed != 0) WORK.execute(() -> flush(flushed, 20)); } - /** Lets go of the notebook once its last activity finishes, publishing what it can first. */ - static void close() { + /** Lets go of the notebook, publishing what it can within `seconds` first. */ + static void close(double seconds) { if (view != 0) freeView(view); long closed = library, freed = section; library = section = view = 0; - page = null; + page = sectionPath = title = problem = null; status = 0; opening = false; - if (closed != 0) WORK.execute(() -> close(closed, freed, 10)); + if (closed != 0) WORK.execute(() -> close(closed, freed, seconds)); } /** @@ -158,4 +232,6 @@ final class Editor { static native void reload(long view, boolean discard); static native void freeView(long view); static native boolean close(long library, long section, double seconds); + static native long provided(String root, boolean folder, boolean local, String cache); + static native String syncStatus(long library); } diff --git a/apps/android/src/net/paperclover/snowbound/PageActivity.java b/apps/android/src/net/paperclover/snowbound/PageActivity.java index f07469c1ef326c7ca06dae81ff181158103c6473..5ffc19c9647518ae473efed5ed1c60bc9bbe8be8 100644 --- a/apps/android/src/net/paperclover/snowbound/PageActivity.java +++ b/apps/android/src/net/paperclover/snowbound/PageActivity.java @@ -2,40 +2,42 @@ package net.paperclover.snowbound; import android.app.Activity; import android.app.AlertDialog; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; -import android.view.Gravity; -import android.view.View; +import android.provider.DocumentsContract; +import android.provider.OpenableColumns; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; import android.widget.FrameLayout; -import android.widget.TextView; public final class PageActivity extends Activity { + private static final int FOLDER = 1, FILE = 2; PageView page; - private TextView status; @Override protected void onCreate(Bundle state) { super.onCreate(state); page = new PageView(this); - status = new TextView(this); - status.setTextAppearance(android.R.style.TextAppearance_Material_Caption); - int margin = Math.round(8 * getResources().getDisplayMetrics().density); - status.setPadding(margin, margin, margin, margin); FrameLayout frame = new FrameLayout(this); frame.setFitsSystemWindows(true); frame.addView(page); frame.addView(page.caret); - frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END)); setContentView(frame); Editor.shown = this; - showStatus(); + showTitle(); Editor.open(this); } - /** Only trouble shows: saving goes on quietly, as on iOS. */ + void showTitle() { + setTitle(Editor.title != null ? Editor.title : getApplicationInfo().loadLabel(getPackageManager())); + showStatus(); + } + void showStatus() { - String text = Editor.status == 3 ? "Offline" : Editor.status == 4 ? "Not Saving" : null; - status.setText(text); - status.setVisibility(text == null ? View.GONE : View.VISIBLE); + getActionBar().setSubtitle(Editor.trouble()); } void rejected(boolean kept) { @@ -47,6 +49,48 @@ public final class PageActivity extends Activity { .show(); } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.add(Menu.NONE, FOLDER, Menu.NONE, "Open Notebook Folder…"); + menu.add(Menu.NONE, FILE, Menu.NONE, "Open Section File…"); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + Intent pick = item.getItemId() == FOLDER ? new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + : new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"); + startActivityForResult(pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION), item.getItemId()); + return true; + } + + @Override + protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + if (result != RESULT_OK || data == null || data.getData() == null) return; + Uri uri = data.getData(); + int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + boolean folder = request == FOLDER; + try { + getContentResolver().takePersistableUriPermission(uri, flags); + new Picked(uri, flags, name(uri, folder), folder).save(this); + } catch (Exception error) { + Log.w("snowbound", "Keeping the pick: " + error); + return; + } + Editor.reopen(this); + } + + /** The picked document's name, as its provider lists it. */ + private String name(Uri uri, boolean folder) { + Uri document = folder ? DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) : uri; + try (Cursor cursor = getContentResolver().query(document, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) return cursor.getString(0); + } + return uri.getLastPathSegment(); + } + @Override protected void onStop() { Editor.stop(); @@ -56,7 +100,7 @@ public final class PageActivity extends Activity { @Override protected void onDestroy() { if (Editor.shown == this) Editor.shown = null; - if (isFinishing()) Editor.close(); + if (isFinishing()) Editor.close(10); super.onDestroy(); } } diff --git a/apps/android/src/net/paperclover/snowbound/PageView.java b/apps/android/src/net/paperclover/snowbound/PageView.java index 0026c7eda3d2f1d0207a485ba5e460c1da0adca6..d501192c88f6859dea79c8b033b670e6f146d1f0 100644 --- a/apps/android/src/net/paperclover/snowbound/PageView.java +++ b/apps/android/src/net/paperclover/snowbound/PageView.java @@ -64,6 +64,11 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor /** Android destroys the surface once this returns, so the editor lets go of it first. */ @Override public void surfaceDestroyed(SurfaceHolder holder) { + release(); + } + + /** Lets go of the editor's view, before the surface goes or the editor closes. */ + void release() { if (!attached) return; Choreographer.getInstance().removeFrameCallback(this); Editor.detach(Editor.view); diff --git a/apps/android/src/net/paperclover/snowbound/Picked.java b/apps/android/src/net/paperclover/snowbound/Picked.java new file mode 100644 index 0000000000000000000000000000000000000000..67152b344a00644a90f3ff45d116990484da9814 --- /dev/null +++ b/apps/android/src/net/paperclover/snowbound/Picked.java @@ -0,0 +1,72 @@ +package net.paperclover.snowbound; + +import android.content.Context; +import android.content.Intent; +import android.content.UriPermission; +import android.net.Uri; +import android.provider.DocumentsContract; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.json.JSONException; +import org.json.JSONObject; + +/** The notebook folder or section file last picked from a document provider, kept in app storage. */ +final class Picked { + final Uri uri; + /** The read and write flags the grant actually gave. */ + final int flags; + final String name; + final boolean folder; + + Picked(Uri uri, int flags, String name, boolean folder) { + this.uri = uri; + this.flags = flags; + this.name = name; + this.folder = folder; + } + + private static File file(Context context) { + return new File(context.getFilesDir(), "picked.json"); + } + + static Picked load(Context context) { + try { + JSONObject json = new JSONObject(new String(Files.readAllBytes(file(context).toPath()), StandardCharsets.UTF_8)); + return new Picked(Uri.parse(json.getString("uri")), json.getInt("flags"), json.getString("name"), + json.getBoolean("folder")); + } catch (IOException | JSONException error) { + return null; + } + } + + void save(Context context) throws IOException, JSONException { + String json = new JSONObject().put("uri", uri.toString()).put("flags", flags).put("name", name) + .put("folder", folder).toString(); + Files.write(file(context).toPath(), json.getBytes(StandardCharsets.UTF_8)); + } + + /** The document `sb_library_provider` opens: the picked file, or the picked folder's own document. */ + String root() { + return (folder ? DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) : uri) + .toString(); + } + + /** Whether the provider keeps the documents on this device, as Android's own storage does. */ + boolean local() { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + boolean writable() { + return (flags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0; + } + + /** Whether the persisted grant survives, as revoking it in the provider or in Settings removes it. */ + boolean granted(Context context) { + for (UriPermission permission : context.getContentResolver().getPersistedUriPermissions()) { + if (permission.getUri().equals(uri) && permission.isReadPermission()) return true; + } + return false; + } +} diff --git a/apps/android/src/net/paperclover/snowbound/Provider.java b/apps/android/src/net/paperclover/snowbound/Provider.java new file mode 100644 index 0000000000000000000000000000000000000000..ff983035175c6c6f256c4b584915613e9f96355a --- /dev/null +++ b/apps/android/src/net/paperclover/snowbound/Provider.java @@ -0,0 +1,116 @@ +package net.paperclover.snowbound; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * snowbound.h's sb_provider: the documents a picked folder or file grants, each named by its + * URI. Rust calls in from any thread, and each call answers before it returns. + */ +final class Provider { + /** sb_provider's statuses. */ + private static final int UNREACHABLE = 1, REVOKED = 2, GONE = 3; + private static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; + + static ContentResolver resolver; + /** The pick's grant allows writing. */ + static volatile boolean writable; + /** Each open descriptor's original, kept until `close` for its error. */ + private static final Map opened = new ConcurrentHashMap<>(); + private static final AtomicLong handles = new AtomicLong(); + + private static int failed(Exception error) { + return error instanceof SecurityException ? REVOKED : UNREACHABLE; + } + + /** Lists `query`'s documents to `found`, naming each through `id`. */ + private static int list(Uri query, Uri tree, long found, long context, boolean one) { + try (Cursor cursor = resolver.query(query, COLUMNS, null, null, null)) { + // A provider that fails a query answers null, gone or not. + if (cursor == null) return UNREACHABLE; + if (one && !cursor.moveToFirst()) return GONE; + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + String document = cursor.getString(0); + Uri id = tree == null ? query : DocumentsContract.buildDocumentUriUsingTree(tree, document); + found(found, context, id.toString(), cursor.getString(1), + Document.MIME_TYPE_DIR.equals(cursor.getString(2)), + cursor.isNull(4) ? -1 : cursor.getLong(4), cursor.isNull(5) ? -1 : cursor.getLong(5), + writable && (cursor.getInt(3) & Document.FLAG_SUPPORTS_WRITE) != 0); + } + return 0; + } catch (Exception error) { + return failed(error); + } + } + + static int document(String id, long found, long context) { + return list(Uri.parse(id), null, found, context, true); + } + + static int children(String id, long found, long context) { + Uri folder = Uri.parse(id); + Uri query = DocumentsContract.buildChildDocumentsUriUsingTree(folder, DocumentsContract.getDocumentId(folder)); + return list(query, folder, found, context, false); + } + + /** Hands Rust a duplicate descriptor and keeps the original, whose error `close` reads. */ + static int open(String id, String mode, int[] fd, long[] handle) { + try { + ParcelFileDescriptor descriptor = resolver.openFileDescriptor(Uri.parse(id), mode); + if (descriptor == null) return GONE; + try { + fd[0] = descriptor.dup().detachFd(); + } catch (IOException error) { + descriptor.close(); + throw error; + } + handle[0] = handles.incrementAndGet(); + opened.put(handle[0], descriptor); + return 0; + } catch (Exception error) { + return failed(error); + } + } + + static int close(long handle) { + ParcelFileDescriptor descriptor = opened.remove(handle); + if (descriptor == null) return 0; + try { + descriptor.checkError(); + return 0; + } catch (Exception error) { + return failed(error); + } finally { + try { + descriptor.close(); + } catch (IOException ignored) { + // The verdict above is the provider's; closing the kept end has none to add. + } + } + } + + static int rename(String id, String name, long renamed, long context) { + try { + Uri moved = DocumentsContract.renameDocument(resolver, Uri.parse(id), name); + if (moved == null) return GONE; + renamed(renamed, context, moved.toString()); + return 0; + } catch (Exception error) { + return failed(error); + } + } + + private static native void found(long found, long context, String id, String name, boolean directory, + long size, long modified, boolean writable); + private static native void renamed(long renamed, long context, String id); +} diff --git a/tools/android/provider-lab/README.md b/tools/android/provider-lab/README.md index ad41eca538ecd18d99c65c29289a43f649261c7c..6edc5c09c1f0b23b31bb8474ff33132876669afc 100644 --- a/tools/android/provider-lab/README.md +++ b/tools/android/provider-lab/README.md @@ -22,6 +22,13 @@ The runner refuses physical devices, installs the two packages, runs bounded instrumentation, force-stops the client between phases, then uninstalls both. The caller owns emulator creation, launch, and cleanup. Evidence stays private. +The provider also serves a second root, "Disposable lab notebook": the folder +`files/notebook` in its own storage, documents named by their paths, for trying the +Snowbound app against a provider by hand. Fill it with a disposable notebook pushed to +`/data/local/tmp` and copied in with `adb shell run-as net.paperclover.snowbound.providerlab`. +The controller's `offline` fails its queries and opens too, and `revoke-notebook` revokes +grants to it. + A test-only provider Activity returns an Intent with prefix and persistable URI grant flags; the client Activity persists only its actual returned read/write flags. This substitutes for a picker result. This does **not** test diff --git a/tools/android/provider-lab/provider/Control.java b/tools/android/provider-lab/provider/Control.java index 6f4392c467143703fdab103816c03a334f1079af..9d4dfd45247af2f3d32b890ec317fe45c919e32c 100644 --- a/tools/android/provider-lab/provider/Control.java +++ b/tools/android/provider-lab/provider/Control.java @@ -20,6 +20,9 @@ public final class Control extends BroadcastReceiver { if (action.equals("reset")) LabProvider.reset(); else if (action.equals("revoke")) { context.revokeUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } else if (action.equals("revoke-notebook")) { + context.revokeUriPermission(DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.NOTEBOOK), + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); } else if (action.equals("offline")) LabProvider.offline = true; else if (action.equals("online")) LabProvider.offline = false; else if (action.equals("publish")) Files.write(LabProvider.file("observer").toPath(), Files.readAllBytes(LabProvider.file("writer").toPath())); diff --git a/tools/android/provider-lab/provider/LabProvider.java b/tools/android/provider-lab/provider/LabProvider.java index d836eb8c51d2e72530d5ad930da6ba9e2e82bf5b..3eac78247c92c24ddbaa7ce1d2833483b7e296bc 100644 --- a/tools/android/provider-lab/provider/LabProvider.java +++ b/tools/android/provider-lab/provider/LabProvider.java @@ -26,6 +26,8 @@ public final class LabProvider extends DocumentsProvider { static final String FAIL = "failed:opaque/ε"; static final String WRITER = "view:writer/ζ"; static final String OBSERVER = "view:observer/η"; + /** A second root: a real notebook folder, its documents named by path below it. */ + static final String NOTEBOOK = "notebook:"; static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; static volatile boolean offline; @@ -42,6 +44,26 @@ public final class LabProvider extends DocumentsProvider { static File file(String name) { return new File(directory, name); } + /** The notebook folder, which `adb shell run-as` fills with a disposable notebook. */ + static File notebook(String id) { + return new File(new File(directory.getParentFile(), "notebook"), id.substring(NOTEBOOK.length())); + } + + private static void notebookRow(MatrixCursor cursor, String id) { + File file = notebook(id); + MatrixCursor.RowBuilder row = cursor.newRow(); + for (String column : cursor.getColumnNames()) { + Object value = null; + if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id; + if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(NOTEBOOK) ? "Lab notebook" : file.getName(); + if (column.equals(Document.COLUMN_MIME_TYPE)) value = file.isDirectory() ? Document.MIME_TYPE_DIR : "application/octet-stream"; + if (column.equals(Document.COLUMN_FLAGS)) value = file.isDirectory() ? 0 : Document.FLAG_SUPPORTS_WRITE; + if (column.equals(Document.COLUMN_SIZE)) value = file.isDirectory() ? null : file.length(); + if (column.equals(Document.COLUMN_LAST_MODIFIED)) value = file.lastModified(); + row.add(value); + } + } + static void reset() throws IOException { offline = false; Files.deleteIfExists(file("renamed").toPath()); @@ -56,6 +78,11 @@ public final class LabProvider extends DocumentsProvider { static boolean renamed() { return file("renamed").exists(); } static void check(String id) throws FileNotFoundException { + if (id.startsWith(NOTEBOOK)) { + if (offline) throw new FileNotFoundException("Injected provider unavailable"); + if (!notebook(id).exists()) throw new FileNotFoundException("No such notebook document"); + return; + } if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE) || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) { throw new FileNotFoundException("Unknown opaque identity"); @@ -86,17 +113,36 @@ public final class LabProvider extends DocumentsProvider { if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; row.add(value); } + row = cursor.newRow(); + for (String column : cursor.getColumnNames()) { + Object value = null; + if (column.equals(Root.COLUMN_ROOT_ID)) value = "notebook"; + if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = NOTEBOOK; + if (column.equals(Root.COLUMN_TITLE)) value = "Disposable lab notebook"; + if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; + row.add(value); + } return cursor; } @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException { check(id); MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); - row(cursor, id); + if (id.startsWith(NOTEBOOK)) notebookRow(cursor, id); + else row(cursor, id); return cursor; } @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException { + if (parent.startsWith(NOTEBOOK)) { + check(parent); + File[] files = notebook(parent).listFiles(); + if (files == null) throw new FileNotFoundException("Not a directory"); + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); + String prefix = parent.equals(NOTEBOOK) ? NOTEBOOK : parent + "/"; + for (File file : files) notebookRow(cursor, prefix + file.getName()); + return cursor; + } if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory"); MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); for (String id : Arrays.asList(renamed() ? RENAMED : LOCAL, PIPE, FAIL, WRITER, OBSERVER)) row(cursor, id); @@ -104,6 +150,7 @@ public final class LabProvider extends DocumentsProvider { } @Override public boolean isChildDocument(String parent, String child) { + if (parent.startsWith(NOTEBOOK)) return child.startsWith(parent) && !child.equals(parent); try { check(child); } catch (FileNotFoundException e) { return false; } return parent.equals(ROOT) && !child.equals(ROOT); } @@ -152,6 +199,7 @@ public final class LabProvider extends DocumentsProvider { }, "provider-lab-pipe").start(); return ends[reading ? 0 : 1]; } + if (id.startsWith(NOTEBOOK)) return ParcelFileDescriptor.open(notebook(id), ParcelFileDescriptor.parseMode(mode)); if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream"); File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local"); return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode)); -- 2.54.0