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) {
2121}
2222
2323static JavaVM *machine;
24static jclass editor;
25static jmethodID woken_method;
24static jclass editor, provider;
25static jmethodID woken_method, document_method, children_method, open_method, close_method, rename_method;
26static const sb_provider documents;
2627
2728JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
2829 (void)reserved;
......@@ -34,7 +35,17 @@ JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
3435 editor = (*env)->NewGlobalRef(env, local);
3536 (*env)->DeleteLocalRef(env, local);
3637 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);
3849 int ends[2];
3950 pthread_t reader;
4051 if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 &&
......@@ -127,17 +138,129 @@ static jstring output(JNIEnv *env, char *text) {
127138 return result;
128139}
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
131148static void woken(uintptr_t token) {
132149 (void)token;
133 JNIEnv *env;
134 bool attached = (*machine)->GetEnv(machine, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED;
135 if (attached && (*machine)->AttachCurrentThread(machine, &env, NULL) != JNI_OK) return;
150 bool attached;
151 JNIEnv *env = jvm(&attached);
152 if (!env) return;
136153 (*env)->CallStaticVoidMethod(env, editor, woken_method);
137154 if ((*env)->ExceptionCheck(env)) (*env)->ExceptionDescribe(env);
138155 if (attached) (*machine)->DetachCurrentThread(machine);
139156}
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
141264JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) {
142265 (void)cls;
143266 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
362485 (void)env, (void)cls;
363486 char *error = NULL;
364487 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);
366489 if (error) {
367490 fprintf(stderr, "snowbound: %s\n", error);
368491 sb_string_free(error);
apps/android/res/values/themes.xml+2-1
......@@ -1,5 +1,6 @@
11<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>
34 <item name="android:windowLightStatusBar">true</item>
45 <item name="android:windowLightNavigationBar">true</item>
56 </style>
apps/android/src/net/paperclover/snowbound/Editor.java+89-13
......@@ -12,6 +12,7 @@ import java.util.concurrent.ExecutorService;
1212import java.util.concurrent.Executors;
1313import org.json.JSONArray;
1414import org.json.JSONException;
15import org.json.JSONObject;
1516
1617/** The page being edited, which outlives each activity showing it; touched only on the main thread. */
1718final class Editor {
......@@ -31,12 +32,23 @@ final class Editor {
3132 static PageActivity shown;
3233 /** The section's saving status, as `sb_section_poll` gives it. */
3334 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;
3440 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 */
3746 static void open(Context context) {
3847 if (opening) return;
3948 opening = true;
49 Provider.resolver = context.getContentResolver();
50 Picked picked = Picked.load(context);
51 boolean granted = picked != null && picked.granted(context);
4052 File notebooks = new File(context.getFilesDir(), "Notebooks");
4153 File notebook = new File(notebooks, "Snowbound");
4254 File cache = new File(context.getNoBackupFilesDir(), "Cache");
......@@ -44,19 +56,47 @@ final class Editor {
4456 String date = DateFormat.getDateInstance(DateFormat.FULL).format(now);
4557 String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now);
4658 WORK.execute(() -> {
47 if (!notebook.exists()) {
48 notebooks.mkdirs();
49 create(notebook.getPath(), cache.getPath(), date, time);
59 String trouble = picked != null && !granted ? "Access Lost" : null;
60 long opened = 0, first = 0;
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 }
5074 }
51 long opened = library(notebook.getPath(), cache.getPath());
75 String name = opened != 0 ? picked.name : null;
5276 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 }
5486 String id = new JSONArray(pages(first)).getJSONObject(0).getString("id");
87 long library = opened, section = first;
88 String problem = trouble, sectionPath = path;
5589 MAIN.post(() -> {
56 library = opened;
57 section = first;
90 Editor.library = library;
91 Editor.section = section;
92 Editor.sectionPath = sectionPath;
93 Editor.problem = problem;
5894 page = id;
59 if (shown != null) shown.page.attach();
95 title = name;
96 if (shown != null) {
97 shown.showTitle();
98 shown.page.attach();
99 }
60100 poll();
61101 });
62102 } catch (JSONException error) {
......@@ -65,6 +105,40 @@ final class Editor {
65105 });
66106 }
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
68142 /** Called on the section's thread when it has events. */
69143 static void woken() {
70144 MAIN.post(Editor::poll);
......@@ -101,15 +175,15 @@ final class Editor {
101175 if (flushed != 0) WORK.execute(() -> flush(flushed, 20));
102176 }
103177
104 /** Lets go of the notebook once its last activity finishes, publishing what it can first. */
105 static void close() {
178 /** Lets go of the notebook, publishing what it can within `seconds` first. */
179 static void close(double seconds) {
106180 if (view != 0) freeView(view);
107181 long closed = library, freed = section;
108182 library = section = view = 0;
109 page = null;
183 page = sectionPath = title = problem = null;
110184 status = 0;
111185 opening = false;
112 if (closed != 0) WORK.execute(() -> close(closed, freed, 10));
186 if (closed != 0) WORK.execute(() -> close(closed, freed, seconds));
113187 }
114188
115189 /**
......@@ -158,4 +232,6 @@ final class Editor {
158232 static native void reload(long view, boolean discard);
159233 static native void freeView(long view);
160234 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);
161237}
apps/android/src/net/paperclover/snowbound/PageActivity.java+59-15
......@@ -2,40 +2,42 @@ package net.paperclover.snowbound;
22
33import android.app.Activity;
44import android.app.AlertDialog;
5import android.content.Intent;
6import android.database.Cursor;
7import android.net.Uri;
58import android.os.Bundle;
6import android.view.Gravity;
7import android.view.View;
9import android.provider.DocumentsContract;
10import android.provider.OpenableColumns;
11import android.util.Log;
12import android.view.Menu;
13import android.view.MenuItem;
814import android.widget.FrameLayout;
9import android.widget.TextView;
1015
1116public final class PageActivity extends Activity {
17 private static final int FOLDER = 1, FILE = 2;
1218 PageView page;
13 private TextView status;
1419
1520 @Override
1621 protected void onCreate(Bundle state) {
1722 super.onCreate(state);
1823 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);
2324 FrameLayout frame = new FrameLayout(this);
2425 frame.setFitsSystemWindows(true);
2526 frame.addView(page);
2627 frame.addView(page.caret);
27 frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END));
2828 setContentView(frame);
2929 Editor.shown = this;
30 showStatus();
30 showTitle();
3131 Editor.open(this);
3232 }
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
3539 void showStatus() {
36 String text = Editor.status == 3 ? "Offline" : Editor.status == 4 ? "Not Saving" : null;
37 status.setText(text);
38 status.setVisibility(text == null ? View.GONE : View.VISIBLE);
40 getActionBar().setSubtitle(Editor.trouble());
3941 }
4042
4143 void rejected(boolean kept) {
......@@ -47,6 +49,48 @@ public final class PageActivity extends Activity {
4749 .show();
4850 }
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
5094 @Override
5195 protected void onStop() {
5296 Editor.stop();
......@@ -56,7 +100,7 @@ public final class PageActivity extends Activity {
56100 @Override
57101 protected void onDestroy() {
58102 if (Editor.shown == this) Editor.shown = null;
59 if (isFinishing()) Editor.close();
103 if (isFinishing()) Editor.close(10);
60104 super.onDestroy();
61105 }
62106}
apps/android/src/net/paperclover/snowbound/PageView.java+5
......@@ -64,6 +64,11 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
6464 /** Android destroys the surface once this returns, so the editor lets go of it first. */
6565 @Override
6666 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() {
6772 if (!attached) return;
6873 Choreographer.getInstance().removeFrameCallback(this);
6974 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
2222instrumentation, force-stops the client between phases, then uninstalls both.
2323The 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
2532A test-only provider Activity returns an Intent with prefix and persistable URI
2633grant flags; the client Activity persists only its actual returned read/write
2734flags. 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 {
2020 if (action.equals("reset")) LabProvider.reset();
2121 else if (action.equals("revoke")) {
2222 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);
2326 } else if (action.equals("offline")) LabProvider.offline = true;
2427 else if (action.equals("online")) LabProvider.offline = false;
2528 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 {
2626 static final String FAIL = "failed:opaque/ε";
2727 static final String WRITER = "view:writer/ζ";
2828 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:";
2931 static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME,
3032 Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED};
3133 static volatile boolean offline;
......@@ -42,6 +44,26 @@ public final class LabProvider extends DocumentsProvider {
4244
4345 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
4567 static void reset() throws IOException {
4668 offline = false;
4769 Files.deleteIfExists(file("renamed").toPath());
......@@ -56,6 +78,11 @@ public final class LabProvider extends DocumentsProvider {
5678 static boolean renamed() { return file("renamed").exists(); }
5779
5880 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 }
5986 if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE)
6087 || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) {
6188 throw new FileNotFoundException("Unknown opaque identity");
......@@ -86,17 +113,36 @@ public final class LabProvider extends DocumentsProvider {
86113 if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD;
87114 row.add(value);
88115 }
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 }
89125 return cursor;
90126 }
91127
92128 @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException {
93129 check(id);
94130 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);
96133 return cursor;
97134 }
98135
99136 @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 }
100146 if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory");
101147 MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS);
102148 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 {
104150 }
105151
106152 @Override public boolean isChildDocument(String parent, String child) {
153 if (parent.startsWith(NOTEBOOK)) return child.startsWith(parent) && !child.equals(parent);
107154 try { check(child); } catch (FileNotFoundException e) { return false; }
108155 return parent.equals(ROOT) && !child.equals(ROOT);
109156 }
......@@ -152,6 +199,7 @@ public final class LabProvider extends DocumentsProvider {
152199 }, "provider-lab-pipe").start();
153200 return ends[reading ? 0 : 1];
154201 }
202 if (id.startsWith(NOTEBOOK)) return ParcelFileDescriptor.open(notebook(id), ParcelFileDescriptor.parseMode(mode));
155203 if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream");
156204 File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local");
157205 return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode));