authorclover caruso <git@paperclover.net> 2026-10-15 11:47:53-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 12:21:25-07:00
log196e41e3847eea042b716aae2225149d9790d27b
tree568d6c7a6ea6b7ad0d238a2a19ff03a48e86aa16
parent201763c9e037596207365ab10fd3ed5a6b9a6ff4
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

10 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}
2222
23static JavaVM *machine;23static JavaVM *machine;
24static jclass editor;24static jclass editor, provider;
25static jmethodID woken_method;25static jmethodID woken_method, document_method, children_method, open_method, close_method, rename_method;
26static const sb_provider documents;
2627
27JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {28JNIEXPORT 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}
129140
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.
142static 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
131static void woken(uintptr_t token) {148static 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}
140157
158// sb_provider, through Provider.java's methods of the same names; 4 where Java throws.
159static 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
168static 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
179static int32_t provider_document(const char *id, sb_document_found found, void *context) {
180 return listed(document_method, id, found, context);
181}
182
183static int32_t provider_children(const char *id, sb_document_found found, void *context) {
184 return listed(children_method, id, found, context);
185}
186
187static 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
210static 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
217static 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
229static 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
234JNIEXPORT 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
242JNIEXPORT 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
249JNIEXPORT 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
259JNIEXPORT 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
141JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) {264JNIEXPORT 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;
12import java.util.concurrent.Executors;12import java.util.concurrent.Executors;
13import org.json.JSONArray;13import org.json.JSONArray;
14import org.json.JSONException;14import org.json.JSONException;
15import org.json.JSONObject;
1516
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. */
17final class Editor {18final 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;
3541
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 }
67107
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 }
103177
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 }
114188
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;
22
3import android.app.Activity;3import android.app.Activity;
4import android.app.AlertDialog;4import android.app.AlertDialog;
5import android.content.Intent;
6import android.database.Cursor;
7import android.net.Uri;
5import android.os.Bundle;8import android.os.Bundle;
6import android.view.Gravity;9import android.provider.DocumentsContract;
7import android.view.View;10import android.provider.OpenableColumns;
11import android.util.Log;
12import android.view.Menu;
13import android.view.MenuItem;
8import android.widget.FrameLayout;14import android.widget.FrameLayout;
9import android.widget.TextView;
1015
11public final class PageActivity extends Activity {16public final class PageActivity extends Activity {
17 private static final int FOLDER = 1, FILE = 2;
12 PageView page;18 PageView page;
13 private TextView status;
1419
15 @Override20 @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 }
3333
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 }
4042
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 }
4951
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 @Override94 @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 @Override100 @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 @Override65 @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 @@
1package net.paperclover.snowbound;
2
3import android.content.Context;
4import android.content.Intent;
5import android.content.UriPermission;
6import android.net.Uri;
7import android.provider.DocumentsContract;
8import java.io.File;
9import java.io.IOException;
10import java.nio.charset.StandardCharsets;
11import java.nio.file.Files;
12import org.json.JSONException;
13import org.json.JSONObject;
14
15/** The notebook folder or section file last picked from a document provider, kept in app storage. */
16final 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 @@
1package net.paperclover.snowbound;
2
3import android.content.ContentResolver;
4import android.database.Cursor;
5import android.net.Uri;
6import android.os.ParcelFileDescriptor;
7import android.provider.DocumentsContract;
8import android.provider.DocumentsContract.Document;
9import java.io.IOException;
10import java.util.Map;
11import java.util.concurrent.ConcurrentHashMap;
12import 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 */
18final 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
22instrumentation, force-stops the client between phases, then uninstalls both.22instrumentation, force-stops the client between phases, then uninstalls both.
23The caller owns emulator creation, launch, and cleanup. Evidence stays private.23The caller owns emulator creation, launch, and cleanup. Evidence stays private.
2424
25The 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
27Snowbound 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`.
29The controller's `offline` fails its queries and opens too, and `revoke-notebook` revokes
30grants to it.
31
25A test-only provider Activity returns an Intent with prefix and persistable URI32A test-only provider Activity returns an Intent with prefix and persistable URI
26grant flags; the client Activity persists only its actual returned read/write33grant flags; the client Activity persists only its actual returned read/write
27flags. This substitutes for a picker result. This does **not** test34flags. 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 {
4244
43 static File file(String name) { return new File(directory, name); }45 static File file(String name) { return new File(directory, name); }
4446
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(); }
5779
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 }
91127
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 }
98135
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 }
105151
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));