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