| author | |
| committer | |
| log | 196e41e3847eea042b716aae2225149d9790d27b |
| tree | 568d6c7a6ea6b7ad0d238a2a19ff03a48e86aa16 |
| parent | 201763c9e037596207365ab10fd3ed5a6b9a6ff4 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.510 files changed, 533 insertions(+), 38 deletions(-)
apps/android/jni/snowbound.c+131-8| ... | @@ -21,8 +21,9 @@ static void *log_stderr(void *pipe) { | ... | @@ -21,8 +21,9 @@ static void *log_stderr(void *pipe) { |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | static JavaVM *machine; | 23 | static JavaVM *machine; |
| 24 | static jclass editor; | 24 | static jclass editor, provider; |
| 25 | static jmethodID woken_method; | 25 | static jmethodID woken_method, document_method, children_method, open_method, close_method, rename_method; |
| 26 | static const sb_provider documents; | ||
| 26 | 27 | ||
| 27 | JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { | 28 | JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { |
| 28 | (void)reserved; | 29 | (void)reserved; |
| ... | @@ -34,7 +35,17 @@ JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { | ... | @@ -34,7 +35,17 @@ JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { |
| 34 | editor = (*env)->NewGlobalRef(env, local); | 35 | editor = (*env)->NewGlobalRef(env, local); |
| 35 | (*env)->DeleteLocalRef(env, local); | 36 | (*env)->DeleteLocalRef(env, local); |
| 36 | woken_method = (*env)->GetStaticMethodID(env, editor, "woken", "()V"); | 37 | woken_method = (*env)->GetStaticMethodID(env, editor, "woken", "()V"); |
| 37 | if (!woken_method) return JNI_ERR; | 38 | local = (*env)->FindClass(env, "net/paperclover/snowbound/Provider"); |
| 39 | if (!woken_method || !local) return JNI_ERR; | ||
| 40 | provider = (*env)->NewGlobalRef(env, local); | ||
| 41 | (*env)->DeleteLocalRef(env, local); | ||
| 42 | document_method = (*env)->GetStaticMethodID(env, provider, "document", "(Ljava/lang/String;JJ)I"); | ||
| 43 | children_method = (*env)->GetStaticMethodID(env, provider, "children", "(Ljava/lang/String;JJ)I"); | ||
| 44 | open_method = (*env)->GetStaticMethodID(env, provider, "open", "(Ljava/lang/String;Ljava/lang/String;[I[J)I"); | ||
| 45 | close_method = (*env)->GetStaticMethodID(env, provider, "close", "(J)I"); | ||
| 46 | rename_method = (*env)->GetStaticMethodID(env, provider, "rename", "(Ljava/lang/String;Ljava/lang/String;JJ)I"); | ||
| 47 | if (!document_method || !children_method || !open_method || !close_method || !rename_method) return JNI_ERR; | ||
| 48 | sb_set_provider(&documents); | ||
| 38 | int ends[2]; | 49 | int ends[2]; |
| 39 | pthread_t reader; | 50 | pthread_t reader; |
| 40 | if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 && | 51 | if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 && |
| ... | @@ -127,17 +138,129 @@ static jstring output(JNIEnv *env, char *text) { | ... | @@ -127,17 +138,129 @@ static jstring output(JNIEnv *env, char *text) { |
| 127 | return result; | 138 | return result; |
| 128 | } | 139 | } |
| 129 | 140 | ||
| 130 | // Called on the section's own thread, which may not yet know the JVM. | 141 | // The JVM for a thread Rust may have started; `attached` says to detach once done. |
| 142 | static JNIEnv *jvm(bool *attached) { | ||
| 143 | JNIEnv *env; | ||
| 144 | *attached = (*machine)->GetEnv(machine, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED; | ||
| 145 | return *attached && (*machine)->AttachCurrentThread(machine, &env, NULL) != JNI_OK ? NULL : env; | ||
| 146 | } | ||
| 147 | |||
| 131 | static void woken(uintptr_t token) { | 148 | static void woken(uintptr_t token) { |
| 132 | (void)token; | 149 | (void)token; |
| 133 | JNIEnv *env; | 150 | bool attached; |
| 134 | bool attached = (*machine)->GetEnv(machine, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED; | 151 | JNIEnv *env = jvm(&attached); |
| 135 | if (attached && (*machine)->AttachCurrentThread(machine, &env, NULL) != JNI_OK) return; | 152 | if (!env) return; |
| 136 | (*env)->CallStaticVoidMethod(env, editor, woken_method); | 153 | (*env)->CallStaticVoidMethod(env, editor, woken_method); |
| 137 | if ((*env)->ExceptionCheck(env)) (*env)->ExceptionDescribe(env); | 154 | if ((*env)->ExceptionCheck(env)) (*env)->ExceptionDescribe(env); |
| 138 | if (attached) (*machine)->DetachCurrentThread(machine); | 155 | if (attached) (*machine)->DetachCurrentThread(machine); |
| 139 | } | 156 | } |
| 140 | 157 | ||
| 158 | // sb_provider, through Provider.java's methods of the same names; 4 where Java throws. | ||
| 159 | static int32_t provided(JNIEnv *env, bool attached, jint status) { | ||
| 160 | if ((*env)->ExceptionCheck(env)) { | ||
| 161 | (*env)->ExceptionDescribe(env); | ||
| 162 | status = 4; | ||
| 163 | } | ||
| 164 | if (attached) (*machine)->DetachCurrentThread(machine); | ||
| 165 | return status; | ||
| 166 | } | ||
| 167 | |||
| 168 | static int32_t listed(jmethodID method, const char *id, sb_document_found found, void *context) { | ||
| 169 | bool attached; | ||
| 170 | JNIEnv *env = jvm(&attached); | ||
| 171 | if (!env) return 4; | ||
| 172 | jstring i = java_text(env, id); | ||
| 173 | jint status = i ? (*env)->CallStaticIntMethod(env, provider, method, i, (jlong)(intptr_t)found, | ||
| 174 | (jlong)(intptr_t)context) : 4; | ||
| 175 | if (i) (*env)->DeleteLocalRef(env, i); | ||
| 176 | return provided(env, attached, status); | ||
| 177 | } | ||
| 178 | |||
| 179 | static int32_t provider_document(const char *id, sb_document_found found, void *context) { | ||
| 180 | return listed(document_method, id, found, context); | ||
| 181 | } | ||
| 182 | |||
| 183 | static int32_t provider_children(const char *id, sb_document_found found, void *context) { | ||
| 184 | return listed(children_method, id, found, context); | ||
| 185 | } | ||
| 186 | |||
| 187 | static int32_t provider_open(const char *id, const char *mode, int32_t *fd, uint64_t *handle) { | ||
| 188 | bool attached; | ||
| 189 | JNIEnv *env = jvm(&attached); | ||
| 190 | if (!env) return 4; | ||
| 191 | jstring i = java_text(env, id), m = i ? java_text(env, mode) : NULL; | ||
| 192 | jintArray fds = m ? (*env)->NewIntArray(env, 1) : NULL; | ||
| 193 | jlongArray handles = fds ? (*env)->NewLongArray(env, 1) : NULL; | ||
| 194 | jint status = handles ? (*env)->CallStaticIntMethod(env, provider, open_method, i, m, fds, handles) : 4; | ||
| 195 | if (!status && !(*env)->ExceptionCheck(env)) { | ||
| 196 | jint f; | ||
| 197 | jlong h; | ||
| 198 | (*env)->GetIntArrayRegion(env, fds, 0, 1, &f); | ||
| 199 | (*env)->GetLongArrayRegion(env, handles, 0, 1, &h); | ||
| 200 | *fd = f; | ||
| 201 | *handle = (uint64_t)h; | ||
| 202 | } | ||
| 203 | if (i) (*env)->DeleteLocalRef(env, i); | ||
| 204 | if (m) (*env)->DeleteLocalRef(env, m); | ||
| 205 | if (fds) (*env)->DeleteLocalRef(env, fds); | ||
| 206 | if (handles) (*env)->DeleteLocalRef(env, handles); | ||
| 207 | return provided(env, attached, status); | ||
| 208 | } | ||
| 209 | |||
| 210 | static int32_t provider_close(uint64_t handle) { | ||
| 211 | bool attached; | ||
| 212 | JNIEnv *env = jvm(&attached); | ||
| 213 | if (!env) return 4; | ||
| 214 | return provided(env, attached, (*env)->CallStaticIntMethod(env, provider, close_method, (jlong)handle)); | ||
| 215 | } | ||
| 216 | |||
| 217 | static int32_t provider_rename(const char *id, const char *name, void (*renamed)(void *, const char *), void *context) { | ||
| 218 | bool attached; | ||
| 219 | JNIEnv *env = jvm(&attached); | ||
| 220 | if (!env) return 4; | ||
| 221 | jstring i = java_text(env, id), n = i ? java_text(env, name) : NULL; | ||
| 222 | jint status = n ? (*env)->CallStaticIntMethod(env, provider, rename_method, i, n, (jlong)(intptr_t)renamed, | ||
| 223 | (jlong)(intptr_t)context) : 4; | ||
| 224 | if (i) (*env)->DeleteLocalRef(env, i); | ||
| 225 | if (n) (*env)->DeleteLocalRef(env, n); | ||
| 226 | return provided(env, attached, status); | ||
| 227 | } | ||
| 228 | |||
| 229 | static const sb_provider documents = {provider_document, provider_children, provider_open, provider_close, | ||
| 230 | provider_rename}; | ||
| 231 | |||
| 232 | #define PROVIDER(name) Java_net_paperclover_snowbound_Provider_##name | ||
| 233 | |||
| 234 | JNIEXPORT void JNICALL PROVIDER(found)(JNIEnv *env, jclass cls, jlong found, jlong context, jstring id, jstring name, | ||
| 235 | jboolean directory, jlong size, jlong modified, jboolean writable) { | ||
| 236 | (void)cls; | ||
| 237 | char *i = utf8(env, id), *n = i ? utf8(env, name) : NULL; | ||
| 238 | if (n) ((sb_document_found)(intptr_t)found)((void *)(intptr_t)context, i, n, directory, size, modified, writable); | ||
| 239 | free(i), free(n); | ||
| 240 | } | ||
| 241 | |||
| 242 | JNIEXPORT void JNICALL PROVIDER(renamed)(JNIEnv *env, jclass cls, jlong renamed, jlong context, jstring id) { | ||
| 243 | (void)cls; | ||
| 244 | char *i = utf8(env, id); | ||
| 245 | if (i) ((void (*)(void *, const char *))(intptr_t)renamed)((void *)(intptr_t)context, i); | ||
| 246 | free(i); | ||
| 247 | } | ||
| 248 | |||
| 249 | JNIEXPORT jlong JNICALL JNI(provided)(JNIEnv *env, jclass cls, jstring root, jboolean folder, jboolean local, | ||
| 250 | jstring cache) { | ||
| 251 | (void)cls; | ||
| 252 | char *r = utf8(env, root), *c = r ? utf8(env, cache) : NULL, *error = NULL; | ||
| 253 | const Library *library = c ? sb_library_provider(r, folder, local, c, &error) : NULL; | ||
| 254 | if (c && !library) fail(env, error, "The notebook can't be opened"); | ||
| 255 | free(r), free(c); | ||
| 256 | return (jlong)(intptr_t)library; | ||
| 257 | } | ||
| 258 | |||
| 259 | JNIEXPORT jstring JNICALL JNI(syncStatus)(JNIEnv *env, jclass cls, jlong library) { | ||
| 260 | (void)cls; | ||
| 261 | return output(env, sb_library_sync_status((const Library *)(intptr_t)library)); | ||
| 262 | } | ||
| 263 | |||
| 141 | JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) { | 264 | JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) { |
| 142 | (void)cls; | 265 | (void)cls; |
| 143 | char *n = utf8(env, notebook), *c = n ? utf8(env, cache) : NULL, *d = c ? utf8(env, date) : NULL, | 266 | 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 | ... | @@ -362,7 +485,7 @@ JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jl |
| 362 | (void)env, (void)cls; | 485 | (void)env, (void)cls; |
| 363 | char *error = NULL; | 486 | char *error = NULL; |
| 364 | bool closed = sb_library_flush((const Library *)(intptr_t)library, seconds, &error); | 487 | bool closed = sb_library_flush((const Library *)(intptr_t)library, seconds, &error); |
| 365 | sb_section_free((Section *)(intptr_t)section); | 488 | if (section) sb_section_free((Section *)(intptr_t)section); |
| 366 | if (error) { | 489 | if (error) { |
| 367 | fprintf(stderr, "snowbound: %s\n", error); | 490 | fprintf(stderr, "snowbound: %s\n", error); |
| 368 | sb_string_free(error); | 491 | sb_string_free(error); |
apps/android/res/values/themes.xml+2-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | <resources> | 1 | <resources> |
| 2 | <style name="Page" parent="@android:style/Theme.Material.Light.NoActionBar"> | 2 | <style name="Page" parent="@android:style/Theme.Material.Light"> |
| 3 | <item name="android:colorPrimary">#ffffffff</item> | ||
| 3 | <item name="android:windowLightStatusBar">true</item> | 4 | <item name="android:windowLightStatusBar">true</item> |
| 4 | <item name="android:windowLightNavigationBar">true</item> | 5 | <item name="android:windowLightNavigationBar">true</item> |
| 5 | </style> | 6 | </style> |
apps/android/src/net/paperclover/snowbound/Editor.java+89-13| ... | @@ -12,6 +12,7 @@ import java.util.concurrent.ExecutorService; | ... | @@ -12,6 +12,7 @@ import java.util.concurrent.ExecutorService; |
| 12 | import java.util.concurrent.Executors; | 12 | import java.util.concurrent.Executors; |
| 13 | import org.json.JSONArray; | 13 | import org.json.JSONArray; |
| 14 | import org.json.JSONException; | 14 | import org.json.JSONException; |
| 15 | import org.json.JSONObject; | ||
| 15 | 16 | ||
| 16 | /** The page being edited, which outlives each activity showing it; touched only on the main thread. */ | 17 | /** The page being edited, which outlives each activity showing it; touched only on the main thread. */ |
| 17 | final class Editor { | 18 | final class Editor { |
| ... | @@ -31,12 +32,23 @@ final class Editor { | ... | @@ -31,12 +32,23 @@ final class Editor { |
| 31 | static PageActivity shown; | 32 | static PageActivity shown; |
| 32 | /** The section's saving status, as `sb_section_poll` gives it. */ | 33 | /** The section's saving status, as `sb_section_poll` gives it. */ |
| 33 | static int status; | 34 | static int status; |
| 35 | /** The picked notebook's name, or null for the one in app storage. */ | ||
| 36 | static String title; | ||
| 37 | /** Why the picked notebook didn't open, shown over the one in app storage instead. */ | ||
| 38 | private static String problem; | ||
| 39 | private static String sectionPath; | ||
| 34 | private static boolean opening; | 40 | private static boolean opening; |
| 35 | 41 | ||
| 36 | /** Opens the first page of the notebook in app storage, creating the notebook the first time. */ | 42 | /** |
| 43 | * Opens the first page of the notebook picked from a document provider, else of the one in | ||
| 44 | * app storage, creating that the first time. | ||
| 45 | */ | ||
| 37 | static void open(Context context) { | 46 | static void open(Context context) { |
| 38 | if (opening) return; | 47 | if (opening) return; |
| 39 | opening = true; | 48 | opening = true; |
| 49 | Provider.resolver = context.getContentResolver(); | ||
| 50 | Picked picked = Picked.load(context); | ||
| 51 | boolean granted = picked != null && picked.granted(context); | ||
| 40 | File notebooks = new File(context.getFilesDir(), "Notebooks"); | 52 | File notebooks = new File(context.getFilesDir(), "Notebooks"); |
| 41 | File notebook = new File(notebooks, "Snowbound"); | 53 | File notebook = new File(notebooks, "Snowbound"); |
| 42 | File cache = new File(context.getNoBackupFilesDir(), "Cache"); | 54 | File cache = new File(context.getNoBackupFilesDir(), "Cache"); |
| ... | @@ -44,19 +56,47 @@ final class Editor { | ... | @@ -44,19 +56,47 @@ final class Editor { |
| 44 | String date = DateFormat.getDateInstance(DateFormat.FULL).format(now); | 56 | String date = DateFormat.getDateInstance(DateFormat.FULL).format(now); |
| 45 | String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now); | 57 | String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now); |
| 46 | WORK.execute(() -> { | 58 | WORK.execute(() -> { |
| 47 | if (!notebook.exists()) { | 59 | String trouble = picked != null && !granted ? "Access Lost" : null; |
| 48 | notebooks.mkdirs(); | 60 | long opened = 0, first = 0; |
| 49 | create(notebook.getPath(), cache.getPath(), date, time); | 61 | String path = null; |
| 62 | if (picked != null && granted) { | ||
| 63 | Provider.writable = picked.writable(); | ||
| 64 | try { | ||
| 65 | opened = provided(picked.root(), picked.folder, picked.local(), cache.getPath()); | ||
| 66 | path = firstSection(opened); | ||
| 67 | first = section(opened, path); | ||
| 68 | } catch (IllegalStateException | JSONException error) { | ||
| 69 | Log.w("snowbound", "Opening " + picked.name + ": " + error.getMessage()); | ||
| 70 | if (opened != 0) close(opened, 0, 0); | ||
| 71 | opened = 0; | ||
| 72 | trouble = "Offline"; | ||
| 73 | } | ||
| 50 | } | 74 | } |
| 51 | long opened = library(notebook.getPath(), cache.getPath()); | 75 | String name = opened != 0 ? picked.name : null; |
| 52 | try { | 76 | try { |
| 53 | long first = section(opened, new JSONArray(sections(opened)).getJSONObject(0).getString("path")); | 77 | if (opened == 0) { |
| 78 | if (!notebook.exists()) { | ||
| 79 | notebooks.mkdirs(); | ||
| 80 | create(notebook.getPath(), cache.getPath(), date, time); | ||
| 81 | } | ||
| 82 | opened = library(notebook.getPath(), cache.getPath()); | ||
| 83 | path = firstSection(opened); | ||
| 84 | first = section(opened, path); | ||
| 85 | } | ||
| 54 | String id = new JSONArray(pages(first)).getJSONObject(0).getString("id"); | 86 | String id = new JSONArray(pages(first)).getJSONObject(0).getString("id"); |
| 87 | long library = opened, section = first; | ||
| 88 | String problem = trouble, sectionPath = path; | ||
| 55 | MAIN.post(() -> { | 89 | MAIN.post(() -> { |
| 56 | library = opened; | 90 | Editor.library = library; |
| 57 | section = first; | 91 | Editor.section = section; |
| 92 | Editor.sectionPath = sectionPath; | ||
| 93 | Editor.problem = problem; | ||
| 58 | page = id; | 94 | page = id; |
| 59 | if (shown != null) shown.page.attach(); | 95 | title = name; |
| 96 | if (shown != null) { | ||
| 97 | shown.showTitle(); | ||
| 98 | shown.page.attach(); | ||
| 99 | } | ||
| 60 | poll(); | 100 | poll(); |
| 61 | }); | 101 | }); |
| 62 | } catch (JSONException error) { | 102 | } catch (JSONException error) { |
| ... | @@ -65,6 +105,40 @@ final class Editor { | ... | @@ -65,6 +105,40 @@ final class Editor { |
| 65 | }); | 105 | }); |
| 66 | } | 106 | } |
| 67 | 107 | ||
| 108 | private static String firstSection(long library) throws JSONException { | ||
| 109 | return new JSONArray(sections(library)).getJSONObject(0).getString("path"); | ||
| 110 | } | ||
| 111 | |||
| 112 | /** Opens the notebook picked since, once the one shown has let go. */ | ||
| 113 | static void reopen(Context context) { | ||
| 114 | if (shown != null) shown.page.release(); | ||
| 115 | // Its edits wait in the cache meanwhile, so the switch needn't wait for them to publish. | ||
| 116 | close(0); | ||
| 117 | open(context); | ||
| 118 | } | ||
| 119 | |||
| 120 | /** What keeps the notebook from saving, if anything: only trouble shows, as on iOS. */ | ||
| 121 | static String trouble() { | ||
| 122 | if (problem != null) return problem; | ||
| 123 | if (status == 3 || status == 4) return readOnly() ? "Read Only" : status == 3 ? "Offline" : "Not Saving"; | ||
| 124 | return title != null && !Provider.writable ? "Read Only" : null; | ||
| 125 | } | ||
| 126 | |||
| 127 | /** Whether the section's storage refuses writing, as a revoked or read-only grant does. */ | ||
| 128 | private static boolean readOnly() { | ||
| 129 | try { | ||
| 130 | JSONArray sections = new JSONArray(syncStatus(library)); | ||
| 131 | for (int i = 0; i < sections.length(); i++) { | ||
| 132 | JSONObject entry = sections.getJSONObject(i); | ||
| 133 | // SyncState by its place: ReadOnly is the fifth. | ||
| 134 | if (entry.getString("path").equals(sectionPath)) return entry.getInt("state") == 4; | ||
| 135 | } | ||
| 136 | } catch (JSONException | IllegalStateException error) { | ||
| 137 | Log.w("snowbound", "Reading the sync status: " + error.getMessage()); | ||
| 138 | } | ||
| 139 | return false; | ||
| 140 | } | ||
| 141 | |||
| 68 | /** Called on the section's thread when it has events. */ | 142 | /** Called on the section's thread when it has events. */ |
| 69 | static void woken() { | 143 | static void woken() { |
| 70 | MAIN.post(Editor::poll); | 144 | MAIN.post(Editor::poll); |
| ... | @@ -101,15 +175,15 @@ final class Editor { | ... | @@ -101,15 +175,15 @@ final class Editor { |
| 101 | if (flushed != 0) WORK.execute(() -> flush(flushed, 20)); | 175 | if (flushed != 0) WORK.execute(() -> flush(flushed, 20)); |
| 102 | } | 176 | } |
| 103 | 177 | ||
| 104 | /** Lets go of the notebook once its last activity finishes, publishing what it can first. */ | 178 | /** Lets go of the notebook, publishing what it can within `seconds` first. */ |
| 105 | static void close() { | 179 | static void close(double seconds) { |
| 106 | if (view != 0) freeView(view); | 180 | if (view != 0) freeView(view); |
| 107 | long closed = library, freed = section; | 181 | long closed = library, freed = section; |
| 108 | library = section = view = 0; | 182 | library = section = view = 0; |
| 109 | page = null; | 183 | page = sectionPath = title = problem = null; |
| 110 | status = 0; | 184 | status = 0; |
| 111 | opening = false; | 185 | opening = false; |
| 112 | if (closed != 0) WORK.execute(() -> close(closed, freed, 10)); | 186 | if (closed != 0) WORK.execute(() -> close(closed, freed, seconds)); |
| 113 | } | 187 | } |
| 114 | 188 | ||
| 115 | /** | 189 | /** |
| ... | @@ -158,4 +232,6 @@ final class Editor { | ... | @@ -158,4 +232,6 @@ final class Editor { |
| 158 | static native void reload(long view, boolean discard); | 232 | static native void reload(long view, boolean discard); |
| 159 | static native void freeView(long view); | 233 | static native void freeView(long view); |
| 160 | static native boolean close(long library, long section, double seconds); | 234 | static native boolean close(long library, long section, double seconds); |
| 235 | static native long provided(String root, boolean folder, boolean local, String cache); | ||
| 236 | static native String syncStatus(long library); | ||
| 161 | } | 237 | } |
apps/android/src/net/paperclover/snowbound/PageActivity.java+59-15| ... | @@ -2,40 +2,42 @@ package net.paperclover.snowbound; | ... | @@ -2,40 +2,42 @@ package net.paperclover.snowbound; |
| 2 | 2 | ||
| 3 | import android.app.Activity; | 3 | import android.app.Activity; |
| 4 | import android.app.AlertDialog; | 4 | import android.app.AlertDialog; |
| 5 | import android.content.Intent; | ||
| 6 | import android.database.Cursor; | ||
| 7 | import android.net.Uri; | ||
| 5 | import android.os.Bundle; | 8 | import android.os.Bundle; |
| 6 | import android.view.Gravity; | 9 | import android.provider.DocumentsContract; |
| 7 | import android.view.View; | 10 | import android.provider.OpenableColumns; |
| 11 | import android.util.Log; | ||
| 12 | import android.view.Menu; | ||
| 13 | import android.view.MenuItem; | ||
| 8 | import android.widget.FrameLayout; | 14 | import android.widget.FrameLayout; |
| 9 | import android.widget.TextView; | ||
| 10 | 15 | ||
| 11 | public final class PageActivity extends Activity { | 16 | public final class PageActivity extends Activity { |
| 17 | private static final int FOLDER = 1, FILE = 2; | ||
| 12 | PageView page; | 18 | PageView page; |
| 13 | private TextView status; | ||
| 14 | 19 | ||
| 15 | @Override | 20 | @Override |
| 16 | protected void onCreate(Bundle state) { | 21 | protected void onCreate(Bundle state) { |
| 17 | super.onCreate(state); | 22 | super.onCreate(state); |
| 18 | page = new PageView(this); | 23 | page = new PageView(this); |
| 19 | status = new TextView(this); | ||
| 20 | status.setTextAppearance(android.R.style.TextAppearance_Material_Caption); | ||
| 21 | int margin = Math.round(8 * getResources().getDisplayMetrics().density); | ||
| 22 | status.setPadding(margin, margin, margin, margin); | ||
| 23 | FrameLayout frame = new FrameLayout(this); | 24 | FrameLayout frame = new FrameLayout(this); |
| 24 | frame.setFitsSystemWindows(true); | 25 | frame.setFitsSystemWindows(true); |
| 25 | frame.addView(page); | 26 | frame.addView(page); |
| 26 | frame.addView(page.caret); | 27 | frame.addView(page.caret); |
| 27 | frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END)); | ||
| 28 | setContentView(frame); | 28 | setContentView(frame); |
| 29 | Editor.shown = this; | 29 | Editor.shown = this; |
| 30 | showStatus(); | 30 | showTitle(); |
| 31 | Editor.open(this); | 31 | Editor.open(this); |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | /** Only trouble shows: saving goes on quietly, as on iOS. */ | 34 | void showTitle() { |
| 35 | setTitle(Editor.title != null ? Editor.title : getApplicationInfo().loadLabel(getPackageManager())); | ||
| 36 | showStatus(); | ||
| 37 | } | ||
| 38 | |||
| 35 | void showStatus() { | 39 | void showStatus() { |
| 36 | String text = Editor.status == 3 ? "Offline" : Editor.status == 4 ? "Not Saving" : null; | 40 | getActionBar().setSubtitle(Editor.trouble()); |
| 37 | status.setText(text); | ||
| 38 | status.setVisibility(text == null ? View.GONE : View.VISIBLE); | ||
| 39 | } | 41 | } |
| 40 | 42 | ||
| 41 | void rejected(boolean kept) { | 43 | void rejected(boolean kept) { |
| ... | @@ -47,6 +49,48 @@ public final class PageActivity extends Activity { | ... | @@ -47,6 +49,48 @@ public final class PageActivity extends Activity { |
| 47 | .show(); | 49 | .show(); |
| 48 | } | 50 | } |
| 49 | 51 | ||
| 52 | @Override | ||
| 53 | public boolean onCreateOptionsMenu(Menu menu) { | ||
| 54 | menu.add(Menu.NONE, FOLDER, Menu.NONE, "Open Notebook Folder…"); | ||
| 55 | menu.add(Menu.NONE, FILE, Menu.NONE, "Open Section File…"); | ||
| 56 | return true; | ||
| 57 | } | ||
| 58 | |||
| 59 | @Override | ||
| 60 | public boolean onOptionsItemSelected(MenuItem item) { | ||
| 61 | Intent pick = item.getItemId() == FOLDER ? new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) | ||
| 62 | : new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"); | ||
| 63 | startActivityForResult(pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | ||
| 64 | | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION), item.getItemId()); | ||
| 65 | return true; | ||
| 66 | } | ||
| 67 | |||
| 68 | @Override | ||
| 69 | protected void onActivityResult(int request, int result, Intent data) { | ||
| 70 | super.onActivityResult(request, result, data); | ||
| 71 | if (result != RESULT_OK || data == null || data.getData() == null) return; | ||
| 72 | Uri uri = data.getData(); | ||
| 73 | int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | ||
| 74 | boolean folder = request == FOLDER; | ||
| 75 | try { | ||
| 76 | getContentResolver().takePersistableUriPermission(uri, flags); | ||
| 77 | new Picked(uri, flags, name(uri, folder), folder).save(this); | ||
| 78 | } catch (Exception error) { | ||
| 79 | Log.w("snowbound", "Keeping the pick: " + error); | ||
| 80 | return; | ||
| 81 | } | ||
| 82 | Editor.reopen(this); | ||
| 83 | } | ||
| 84 | |||
| 85 | /** The picked document's name, as its provider lists it. */ | ||
| 86 | private String name(Uri uri, boolean folder) { | ||
| 87 | Uri document = folder ? DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) : uri; | ||
| 88 | try (Cursor cursor = getContentResolver().query(document, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) { | ||
| 89 | if (cursor != null && cursor.moveToFirst()) return cursor.getString(0); | ||
| 90 | } | ||
| 91 | return uri.getLastPathSegment(); | ||
| 92 | } | ||
| 93 | |||
| 50 | @Override | 94 | @Override |
| 51 | protected void onStop() { | 95 | protected void onStop() { |
| 52 | Editor.stop(); | 96 | Editor.stop(); |
| ... | @@ -56,7 +100,7 @@ public final class PageActivity extends Activity { | ... | @@ -56,7 +100,7 @@ public final class PageActivity extends Activity { |
| 56 | @Override | 100 | @Override |
| 57 | protected void onDestroy() { | 101 | protected void onDestroy() { |
| 58 | if (Editor.shown == this) Editor.shown = null; | 102 | if (Editor.shown == this) Editor.shown = null; |
| 59 | if (isFinishing()) Editor.close(); | 103 | if (isFinishing()) Editor.close(10); |
| 60 | super.onDestroy(); | 104 | super.onDestroy(); |
| 61 | } | 105 | } |
| 62 | } | 106 | } |
apps/android/src/net/paperclover/snowbound/PageView.java+5| ... | @@ -64,6 +64,11 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor | ... | @@ -64,6 +64,11 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor |
| 64 | /** Android destroys the surface once this returns, so the editor lets go of it first. */ | 64 | /** Android destroys the surface once this returns, so the editor lets go of it first. */ |
| 65 | @Override | 65 | @Override |
| 66 | public void surfaceDestroyed(SurfaceHolder holder) { | 66 | public void surfaceDestroyed(SurfaceHolder holder) { |
| 67 | release(); | ||
| 68 | } | ||
| 69 | |||
| 70 | /** Lets go of the editor's view, before the surface goes or the editor closes. */ | ||
| 71 | void release() { | ||
| 67 | if (!attached) return; | 72 | if (!attached) return; |
| 68 | Choreographer.getInstance().removeFrameCallback(this); | 73 | Choreographer.getInstance().removeFrameCallback(this); |
| 69 | Editor.detach(Editor.view); | 74 | Editor.detach(Editor.view); |
apps/android/src/net/paperclover/snowbound/Picked.java created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | package net.paperclover.snowbound; | ||
| 2 | |||
| 3 | import android.content.Context; | ||
| 4 | import android.content.Intent; | ||
| 5 | import android.content.UriPermission; | ||
| 6 | import android.net.Uri; | ||
| 7 | import android.provider.DocumentsContract; | ||
| 8 | import java.io.File; | ||
| 9 | import java.io.IOException; | ||
| 10 | import java.nio.charset.StandardCharsets; | ||
| 11 | import java.nio.file.Files; | ||
| 12 | import org.json.JSONException; | ||
| 13 | import org.json.JSONObject; | ||
| 14 | |||
| 15 | /** The notebook folder or section file last picked from a document provider, kept in app storage. */ | ||
| 16 | final class Picked { | ||
| 17 | final Uri uri; | ||
| 18 | /** The read and write flags the grant actually gave. */ | ||
| 19 | final int flags; | ||
| 20 | final String name; | ||
| 21 | final boolean folder; | ||
| 22 | |||
| 23 | Picked(Uri uri, int flags, String name, boolean folder) { | ||
| 24 | this.uri = uri; | ||
| 25 | this.flags = flags; | ||
| 26 | this.name = name; | ||
| 27 | this.folder = folder; | ||
| 28 | } | ||
| 29 | |||
| 30 | private static File file(Context context) { | ||
| 31 | return new File(context.getFilesDir(), "picked.json"); | ||
| 32 | } | ||
| 33 | |||
| 34 | static Picked load(Context context) { | ||
| 35 | try { | ||
| 36 | JSONObject json = new JSONObject(new String(Files.readAllBytes(file(context).toPath()), StandardCharsets.UTF_8)); | ||
| 37 | return new Picked(Uri.parse(json.getString("uri")), json.getInt("flags"), json.getString("name"), | ||
| 38 | json.getBoolean("folder")); | ||
| 39 | } catch (IOException | JSONException error) { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | void save(Context context) throws IOException, JSONException { | ||
| 45 | String json = new JSONObject().put("uri", uri.toString()).put("flags", flags).put("name", name) | ||
| 46 | .put("folder", folder).toString(); | ||
| 47 | Files.write(file(context).toPath(), json.getBytes(StandardCharsets.UTF_8)); | ||
| 48 | } | ||
| 49 | |||
| 50 | /** The document `sb_library_provider` opens: the picked file, or the picked folder's own document. */ | ||
| 51 | String root() { | ||
| 52 | return (folder ? DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) : uri) | ||
| 53 | .toString(); | ||
| 54 | } | ||
| 55 | |||
| 56 | /** Whether the provider keeps the documents on this device, as Android's own storage does. */ | ||
| 57 | boolean local() { | ||
| 58 | return "com.android.externalstorage.documents".equals(uri.getAuthority()); | ||
| 59 | } | ||
| 60 | |||
| 61 | boolean writable() { | ||
| 62 | return (flags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0; | ||
| 63 | } | ||
| 64 | |||
| 65 | /** Whether the persisted grant survives, as revoking it in the provider or in Settings removes it. */ | ||
| 66 | boolean granted(Context context) { | ||
| 67 | for (UriPermission permission : context.getContentResolver().getPersistedUriPermissions()) { | ||
| 68 | if (permission.getUri().equals(uri) && permission.isReadPermission()) return true; | ||
| 69 | } | ||
| 70 | return false; | ||
| 71 | } | ||
| 72 | } | ||
apps/android/src/net/paperclover/snowbound/Provider.java created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | package net.paperclover.snowbound; | ||
| 2 | |||
| 3 | import android.content.ContentResolver; | ||
| 4 | import android.database.Cursor; | ||
| 5 | import android.net.Uri; | ||
| 6 | import android.os.ParcelFileDescriptor; | ||
| 7 | import android.provider.DocumentsContract; | ||
| 8 | import android.provider.DocumentsContract.Document; | ||
| 9 | import java.io.IOException; | ||
| 10 | import java.util.Map; | ||
| 11 | import java.util.concurrent.ConcurrentHashMap; | ||
| 12 | import java.util.concurrent.atomic.AtomicLong; | ||
| 13 | |||
| 14 | /** | ||
| 15 | * snowbound.h's sb_provider: the documents a picked folder or file grants, each named by its | ||
| 16 | * URI. Rust calls in from any thread, and each call answers before it returns. | ||
| 17 | */ | ||
| 18 | final class Provider { | ||
| 19 | /** sb_provider's statuses. */ | ||
| 20 | private static final int UNREACHABLE = 1, REVOKED = 2, GONE = 3; | ||
| 21 | private static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, | ||
| 22 | Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; | ||
| 23 | |||
| 24 | static ContentResolver resolver; | ||
| 25 | /** The pick's grant allows writing. */ | ||
| 26 | static volatile boolean writable; | ||
| 27 | /** Each open descriptor's original, kept until `close` for its error. */ | ||
| 28 | private static final Map<Long, ParcelFileDescriptor> opened = new ConcurrentHashMap<>(); | ||
| 29 | private static final AtomicLong handles = new AtomicLong(); | ||
| 30 | |||
| 31 | private static int failed(Exception error) { | ||
| 32 | return error instanceof SecurityException ? REVOKED : UNREACHABLE; | ||
| 33 | } | ||
| 34 | |||
| 35 | /** Lists `query`'s documents to `found`, naming each through `id`. */ | ||
| 36 | private static int list(Uri query, Uri tree, long found, long context, boolean one) { | ||
| 37 | try (Cursor cursor = resolver.query(query, COLUMNS, null, null, null)) { | ||
| 38 | // A provider that fails a query answers null, gone or not. | ||
| 39 | if (cursor == null) return UNREACHABLE; | ||
| 40 | if (one && !cursor.moveToFirst()) return GONE; | ||
| 41 | cursor.moveToPosition(-1); | ||
| 42 | while (cursor.moveToNext()) { | ||
| 43 | String document = cursor.getString(0); | ||
| 44 | Uri id = tree == null ? query : DocumentsContract.buildDocumentUriUsingTree(tree, document); | ||
| 45 | found(found, context, id.toString(), cursor.getString(1), | ||
| 46 | Document.MIME_TYPE_DIR.equals(cursor.getString(2)), | ||
| 47 | cursor.isNull(4) ? -1 : cursor.getLong(4), cursor.isNull(5) ? -1 : cursor.getLong(5), | ||
| 48 | writable && (cursor.getInt(3) & Document.FLAG_SUPPORTS_WRITE) != 0); | ||
| 49 | } | ||
| 50 | return 0; | ||
| 51 | } catch (Exception error) { | ||
| 52 | return failed(error); | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | static int document(String id, long found, long context) { | ||
| 57 | return list(Uri.parse(id), null, found, context, true); | ||
| 58 | } | ||
| 59 | |||
| 60 | static int children(String id, long found, long context) { | ||
| 61 | Uri folder = Uri.parse(id); | ||
| 62 | Uri query = DocumentsContract.buildChildDocumentsUriUsingTree(folder, DocumentsContract.getDocumentId(folder)); | ||
| 63 | return list(query, folder, found, context, false); | ||
| 64 | } | ||
| 65 | |||
| 66 | /** Hands Rust a duplicate descriptor and keeps the original, whose error `close` reads. */ | ||
| 67 | static int open(String id, String mode, int[] fd, long[] handle) { | ||
| 68 | try { | ||
| 69 | ParcelFileDescriptor descriptor = resolver.openFileDescriptor(Uri.parse(id), mode); | ||
| 70 | if (descriptor == null) return GONE; | ||
| 71 | try { | ||
| 72 | fd[0] = descriptor.dup().detachFd(); | ||
| 73 | } catch (IOException error) { | ||
| 74 | descriptor.close(); | ||
| 75 | throw error; | ||
| 76 | } | ||
| 77 | handle[0] = handles.incrementAndGet(); | ||
| 78 | opened.put(handle[0], descriptor); | ||
| 79 | return 0; | ||
| 80 | } catch (Exception error) { | ||
| 81 | return failed(error); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | static int close(long handle) { | ||
| 86 | ParcelFileDescriptor descriptor = opened.remove(handle); | ||
| 87 | if (descriptor == null) return 0; | ||
| 88 | try { | ||
| 89 | descriptor.checkError(); | ||
| 90 | return 0; | ||
| 91 | } catch (Exception error) { | ||
| 92 | return failed(error); | ||
| 93 | } finally { | ||
| 94 | try { | ||
| 95 | descriptor.close(); | ||
| 96 | } catch (IOException ignored) { | ||
| 97 | // The verdict above is the provider's; closing the kept end has none to add. | ||
| 98 | } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | static int rename(String id, String name, long renamed, long context) { | ||
| 103 | try { | ||
| 104 | Uri moved = DocumentsContract.renameDocument(resolver, Uri.parse(id), name); | ||
| 105 | if (moved == null) return GONE; | ||
| 106 | renamed(renamed, context, moved.toString()); | ||
| 107 | return 0; | ||
| 108 | } catch (Exception error) { | ||
| 109 | return failed(error); | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | private static native void found(long found, long context, String id, String name, boolean directory, | ||
| 114 | long size, long modified, boolean writable); | ||
| 115 | private static native void renamed(long renamed, long context, String id); | ||
| 116 | } | ||
tools/android/provider-lab/README.md+7| ... | @@ -22,6 +22,13 @@ The runner refuses physical devices, installs the two packages, runs bounded | ... | @@ -22,6 +22,13 @@ The runner refuses physical devices, installs the two packages, runs bounded |
| 22 | instrumentation, force-stops the client between phases, then uninstalls both. | 22 | instrumentation, force-stops the client between phases, then uninstalls both. |
| 23 | The caller owns emulator creation, launch, and cleanup. Evidence stays private. | 23 | The caller owns emulator creation, launch, and cleanup. Evidence stays private. |
| 24 | 24 | ||
| 25 | The provider also serves a second root, "Disposable lab notebook": the folder | ||
| 26 | `files/notebook` in its own storage, documents named by their paths, for trying the | ||
| 27 | Snowbound app against a provider by hand. Fill it with a disposable notebook pushed to | ||
| 28 | `/data/local/tmp` and copied in with `adb shell run-as net.paperclover.snowbound.providerlab`. | ||
| 29 | The controller's `offline` fails its queries and opens too, and `revoke-notebook` revokes | ||
| 30 | grants to it. | ||
| 31 | |||
| 25 | A test-only provider Activity returns an Intent with prefix and persistable URI | 32 | A test-only provider Activity returns an Intent with prefix and persistable URI |
| 26 | grant flags; the client Activity persists only its actual returned read/write | 33 | grant flags; the client Activity persists only its actual returned read/write |
| 27 | flags. This substitutes for a picker result. This does **not** test | 34 | flags. This substitutes for a picker result. This does **not** test |
tools/android/provider-lab/provider/Control.java+3| ... | @@ -20,6 +20,9 @@ public final class Control extends BroadcastReceiver { | ... | @@ -20,6 +20,9 @@ public final class Control extends BroadcastReceiver { |
| 20 | if (action.equals("reset")) LabProvider.reset(); | 20 | if (action.equals("reset")) LabProvider.reset(); |
| 21 | else if (action.equals("revoke")) { | 21 | else if (action.equals("revoke")) { |
| 22 | context.revokeUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | 22 | context.revokeUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); |
| 23 | } else if (action.equals("revoke-notebook")) { | ||
| 24 | context.revokeUriPermission(DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.NOTEBOOK), | ||
| 25 | Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | ||
| 23 | } else if (action.equals("offline")) LabProvider.offline = true; | 26 | } else if (action.equals("offline")) LabProvider.offline = true; |
| 24 | else if (action.equals("online")) LabProvider.offline = false; | 27 | else if (action.equals("online")) LabProvider.offline = false; |
| 25 | else if (action.equals("publish")) Files.write(LabProvider.file("observer").toPath(), Files.readAllBytes(LabProvider.file("writer").toPath())); | 28 | else if (action.equals("publish")) Files.write(LabProvider.file("observer").toPath(), Files.readAllBytes(LabProvider.file("writer").toPath())); |
tools/android/provider-lab/provider/LabProvider.java+49-1| ... | @@ -26,6 +26,8 @@ public final class LabProvider extends DocumentsProvider { | ... | @@ -26,6 +26,8 @@ public final class LabProvider extends DocumentsProvider { |
| 26 | static final String FAIL = "failed:opaque/ε"; | 26 | static final String FAIL = "failed:opaque/ε"; |
| 27 | static final String WRITER = "view:writer/ζ"; | 27 | static final String WRITER = "view:writer/ζ"; |
| 28 | static final String OBSERVER = "view:observer/η"; | 28 | static final String OBSERVER = "view:observer/η"; |
| 29 | /** A second root: a real notebook folder, its documents named by path below it. */ | ||
| 30 | static final String NOTEBOOK = "notebook:"; | ||
| 29 | static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, | 31 | static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, |
| 30 | Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; | 32 | Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; |
| 31 | static volatile boolean offline; | 33 | static volatile boolean offline; |
| ... | @@ -42,6 +44,26 @@ public final class LabProvider extends DocumentsProvider { | ... | @@ -42,6 +44,26 @@ public final class LabProvider extends DocumentsProvider { |
| 42 | 44 | ||
| 43 | static File file(String name) { return new File(directory, name); } | 45 | static File file(String name) { return new File(directory, name); } |
| 44 | 46 | ||
| 47 | /** The notebook folder, which `adb shell run-as` fills with a disposable notebook. */ | ||
| 48 | static File notebook(String id) { | ||
| 49 | return new File(new File(directory.getParentFile(), "notebook"), id.substring(NOTEBOOK.length())); | ||
| 50 | } | ||
| 51 | |||
| 52 | private static void notebookRow(MatrixCursor cursor, String id) { | ||
| 53 | File file = notebook(id); | ||
| 54 | MatrixCursor.RowBuilder row = cursor.newRow(); | ||
| 55 | for (String column : cursor.getColumnNames()) { | ||
| 56 | Object value = null; | ||
| 57 | if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id; | ||
| 58 | if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(NOTEBOOK) ? "Lab notebook" : file.getName(); | ||
| 59 | if (column.equals(Document.COLUMN_MIME_TYPE)) value = file.isDirectory() ? Document.MIME_TYPE_DIR : "application/octet-stream"; | ||
| 60 | if (column.equals(Document.COLUMN_FLAGS)) value = file.isDirectory() ? 0 : Document.FLAG_SUPPORTS_WRITE; | ||
| 61 | if (column.equals(Document.COLUMN_SIZE)) value = file.isDirectory() ? null : file.length(); | ||
| 62 | if (column.equals(Document.COLUMN_LAST_MODIFIED)) value = file.lastModified(); | ||
| 63 | row.add(value); | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 45 | static void reset() throws IOException { | 67 | static void reset() throws IOException { |
| 46 | offline = false; | 68 | offline = false; |
| 47 | Files.deleteIfExists(file("renamed").toPath()); | 69 | Files.deleteIfExists(file("renamed").toPath()); |
| ... | @@ -56,6 +78,11 @@ public final class LabProvider extends DocumentsProvider { | ... | @@ -56,6 +78,11 @@ public final class LabProvider extends DocumentsProvider { |
| 56 | static boolean renamed() { return file("renamed").exists(); } | 78 | static boolean renamed() { return file("renamed").exists(); } |
| 57 | 79 | ||
| 58 | static void check(String id) throws FileNotFoundException { | 80 | static void check(String id) throws FileNotFoundException { |
| 81 | if (id.startsWith(NOTEBOOK)) { | ||
| 82 | if (offline) throw new FileNotFoundException("Injected provider unavailable"); | ||
| 83 | if (!notebook(id).exists()) throw new FileNotFoundException("No such notebook document"); | ||
| 84 | return; | ||
| 85 | } | ||
| 59 | if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE) | 86 | if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE) |
| 60 | || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) { | 87 | || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) { |
| 61 | throw new FileNotFoundException("Unknown opaque identity"); | 88 | throw new FileNotFoundException("Unknown opaque identity"); |
| ... | @@ -86,17 +113,36 @@ public final class LabProvider extends DocumentsProvider { | ... | @@ -86,17 +113,36 @@ public final class LabProvider extends DocumentsProvider { |
| 86 | if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; | 113 | if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; |
| 87 | row.add(value); | 114 | row.add(value); |
| 88 | } | 115 | } |
| 116 | row = cursor.newRow(); | ||
| 117 | for (String column : cursor.getColumnNames()) { | ||
| 118 | Object value = null; | ||
| 119 | if (column.equals(Root.COLUMN_ROOT_ID)) value = "notebook"; | ||
| 120 | if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = NOTEBOOK; | ||
| 121 | if (column.equals(Root.COLUMN_TITLE)) value = "Disposable lab notebook"; | ||
| 122 | if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; | ||
| 123 | row.add(value); | ||
| 124 | } | ||
| 89 | return cursor; | 125 | return cursor; |
| 90 | } | 126 | } |
| 91 | 127 | ||
| 92 | @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException { | 128 | @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException { |
| 93 | check(id); | 129 | check(id); |
| 94 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); | 130 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); |
| 95 | row(cursor, id); | 131 | if (id.startsWith(NOTEBOOK)) notebookRow(cursor, id); |
| 132 | else row(cursor, id); | ||
| 96 | return cursor; | 133 | return cursor; |
| 97 | } | 134 | } |
| 98 | 135 | ||
| 99 | @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException { | 136 | @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException { |
| 137 | if (parent.startsWith(NOTEBOOK)) { | ||
| 138 | check(parent); | ||
| 139 | File[] files = notebook(parent).listFiles(); | ||
| 140 | if (files == null) throw new FileNotFoundException("Not a directory"); | ||
| 141 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); | ||
| 142 | String prefix = parent.equals(NOTEBOOK) ? NOTEBOOK : parent + "/"; | ||
| 143 | for (File file : files) notebookRow(cursor, prefix + file.getName()); | ||
| 144 | return cursor; | ||
| 145 | } | ||
| 100 | if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory"); | 146 | if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory"); |
| 101 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); | 147 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); |
| 102 | for (String id : Arrays.asList(renamed() ? RENAMED : LOCAL, PIPE, FAIL, WRITER, OBSERVER)) row(cursor, id); | 148 | 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 { | ... | @@ -104,6 +150,7 @@ public final class LabProvider extends DocumentsProvider { |
| 104 | } | 150 | } |
| 105 | 151 | ||
| 106 | @Override public boolean isChildDocument(String parent, String child) { | 152 | @Override public boolean isChildDocument(String parent, String child) { |
| 153 | if (parent.startsWith(NOTEBOOK)) return child.startsWith(parent) && !child.equals(parent); | ||
| 107 | try { check(child); } catch (FileNotFoundException e) { return false; } | 154 | try { check(child); } catch (FileNotFoundException e) { return false; } |
| 108 | return parent.equals(ROOT) && !child.equals(ROOT); | 155 | return parent.equals(ROOT) && !child.equals(ROOT); |
| 109 | } | 156 | } |
| ... | @@ -152,6 +199,7 @@ public final class LabProvider extends DocumentsProvider { | ... | @@ -152,6 +199,7 @@ public final class LabProvider extends DocumentsProvider { |
| 152 | }, "provider-lab-pipe").start(); | 199 | }, "provider-lab-pipe").start(); |
| 153 | return ends[reading ? 0 : 1]; | 200 | return ends[reading ? 0 : 1]; |
| 154 | } | 201 | } |
| 202 | if (id.startsWith(NOTEBOOK)) return ParcelFileDescriptor.open(notebook(id), ParcelFileDescriptor.parseMode(mode)); | ||
| 155 | if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream"); | 203 | if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream"); |
| 156 | File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local"); | 204 | File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local"); |
| 157 | return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode)); | 205 | return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode)); |