authorclover caruso <git@paperclover.net> 2026-10-15 06:11:36-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 06:39:39-07:00
log4c06a93816860ce967019ff5bc0dd4b95ca90ccc
treef22d6406052067b6611841d1ff1385c56faca678
parentd8a3bb046d2bdf597b51e08a041375538e1aad16
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(android): keep edits across stop and cold start

The app now hears its section. A change made elsewhere reloads the page, and an edit the section refuses is kept as a conflict page under it, as on iOS, with the same alert. When saving stops or the notebook goes offline, a small status says so. Leaving the app ends composition and publishes every edit while Android lets it run, and once the page's last activity finishes, the notebook publishes what it can and is let go. The page takes focus only once touched, so opening the app no longer opens the keyboard. On the emulator, a page focused before its first frame drew grey, about three quarters of white. apps/android/check.py types a token on a disposable emulator, waits until it is saved, force-stops the app, turns the network off and starts it again, then reads the section file back to find the token. Refs #24 Assisted-by: claude-opus-5.5

5 files changed, 297 insertions(+), 24 deletions(-)

apps/android/check.py created+95
......@@ -0,0 +1,95 @@
1#!/usr/bin/env python3
2"""Checks that an edit outlives a force-stop and an offline cold start, on a disposable emulator
3the caller starts and stops:
4
5 python3 apps/android/check.py --adb ADB --serial emulator-5560 --apk Snowbound.apk --output DIR
6
7It types a token into the page, waits until the section reports it saved, force-stops the app,
8turns the network off, starts it again and screenshots the page, then reads the section file
9back with onestore's inspect example and looks for the token in it.
10"""
11import argparse
12import json
13from pathlib import Path
14import secrets
15import subprocess
16import time
17
18ROOT = Path(__file__).resolve().parents[2]
19PACKAGE = 'net.paperclover.snowbound'
20SECTION = 'files/Notebooks/Snowbound/New Section 1.one'
21
22
23def main():
24 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
25 parser.add_argument('--adb', type=Path, required=True)
26 parser.add_argument('--serial', required=True)
27 parser.add_argument('--apk', type=Path, required=True)
28 parser.add_argument('--output', type=Path, required=True)
29 args = parser.parse_args()
30 output = args.output.resolve()
31 output.mkdir(parents=True, exist_ok=False)
32
33 def adb(*command, binary=False, timeout=60):
34 result = subprocess.run([str(args.adb), '-s', args.serial, *map(str, command)], capture_output=True,
35 check=True, timeout=timeout)
36 return result.stdout if binary else result.stdout.decode()
37
38 def logged(after, line, timeout=60):
39 """Waits for `line` in the app's log after the last `after`, if any."""
40 deadline = time.monotonic() + timeout
41 while time.monotonic() < deadline:
42 lines = adb('logcat', '-d', '-s', 'snowbound:I').splitlines()
43 marks = [index for index, text in enumerate(lines) if text.endswith(after)] if after else [-1]
44 if marks and any(text.endswith(line) for text in lines[marks[-1] + 1:]):
45 return
46 time.sleep(0.5)
47 raise TimeoutError(f'No "{line}" in the log')
48
49 def launch():
50 adb('logcat', '-c')
51 adb('shell', 'am', 'start', '-W', '-n', f'{PACKAGE}/.PageActivity')
52 logged(None, 'Page view created')
53 time.sleep(2)
54
55 if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1':
56 raise SystemExit('This check runs only on an emulator')
57 token = 'check' + secrets.token_hex(4)
58 width, height = map(int, adb('shell', 'wm', 'size').split()[-1].split('x'))
59 adb('install', '-r', args.apk.resolve(), timeout=180)
60 adb('shell', 'am', 'force-stop', PACKAGE)
61 try:
62 launch()
63 adb('shell', 'input', 'tap', width // 4, height * 3 // 10)
64 time.sleep(1)
65 adb('shell', 'input', 'text', token)
66 adb('shell', 'input', 'keyevent', 'KEYCODE_ENTER')
67 logged('Section status 2', 'Section status 1')
68 (output / 'typed.png').write_bytes(adb('exec-out', 'screencap', '-p', binary=True))
69 adb('shell', 'am', 'force-stop', PACKAGE)
70 adb('shell', 'cmd', 'connectivity', 'airplane-mode', 'enable')
71 launch()
72 (output / 'reopened.png').write_bytes(adb('exec-out', 'screencap', '-p', binary=True))
73 section = output / 'section.one'
74 section.write_bytes(adb('exec-out', 'run-as', PACKAGE, 'cat', SECTION, binary=True))
75 (output / 'logcat.txt').write_text(adb('logcat', '-d'))
76 finally:
77 adb('shell', 'cmd', 'connectivity', 'airplane-mode', 'disable')
78 inspected = subprocess.run(['cargo', 'run', '-q', '-p', 'onestore', '--example', 'inspect', '--', section],
79 cwd=ROOT, capture_output=True, text=True, check=True).stdout.strip()
80 data = section.read_bytes()
81 result = {
82 'token': token,
83 'inspect': inspected,
84 'intact': inspected.endswith(' 0 checksum mismatches'),
85 # Text is stored as UTF-16, or as single bytes where it fits them.
86 'tokenInSection': token.encode('utf-16-le') in data or token.encode() in data,
87 }
88 (output / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
89 print(json.dumps(result, indent=2))
90 if not (result['intact'] and result['tokenInSection']):
91 raise SystemExit('The edit did not survive')
92
93
94if __name__ == '__main__':
95 main()
apps/android/jni/snowbound.c+73-3
......@@ -20,8 +20,21 @@ static void *log_stderr(void *pipe) {
2020 return NULL;
2121}
2222
23static JavaVM *machine;
24static jclass editor;
25static jmethodID woken_method;
26
2327JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
24 (void)vm; (void)reserved;
28 (void)reserved;
29 machine = vm;
30 JNIEnv *env;
31 if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) return JNI_ERR;
32 jclass local = (*env)->FindClass(env, "net/paperclover/snowbound/Editor");
33 if (!local) return JNI_ERR;
34 editor = (*env)->NewGlobalRef(env, local);
35 (*env)->DeleteLocalRef(env, local);
36 woken_method = (*env)->GetStaticMethodID(env, editor, "woken", "()V");
37 if (!woken_method) return JNI_ERR;
2538 int ends[2];
2639 pthread_t reader;
2740 if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 &&
......@@ -114,8 +127,16 @@ static jstring output(JNIEnv *env, char *text) {
114127 return result;
115128}
116129
117// Nothing reads the section's events yet; the page shows only its own edits.
118static void woken(uintptr_t token) { (void)token; }
130// Called on the section's own thread, which may not yet know the JVM.
131static void woken(uintptr_t token) {
132 (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;
136 (*env)->CallStaticVoidMethod(env, editor, woken_method);
137 if ((*env)->ExceptionCheck(env)) (*env)->ExceptionDescribe(env);
138 if (attached) (*machine)->DetachCurrentThread(machine);
139}
119140
120141JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) {
121142 (void)cls;
......@@ -298,3 +319,52 @@ JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat
298319 (void)env, (void)cls;
299320 return (jint)sb_closest((const View *)(intptr_t)view, x, y);
300321}
322
323// The section's events, then its saving status.
324JNIEXPORT jintArray JNICALL JNI(poll)(JNIEnv *env, jclass cls, jlong section) {
325 (void)cls;
326 uint8_t status = 0;
327 uint32_t polled[2] = {sb_section_poll((const Section *)(intptr_t)section, &status), 0};
328 polled[1] = status;
329 return range(env, polled);
330}
331
332JNIEXPORT jboolean JNICALL JNI(flush)(JNIEnv *env, jclass cls, jlong section, jdouble seconds) {
333 (void)env, (void)cls;
334 return sb_section_flush((const Section *)(intptr_t)section, seconds);
335}
336
337JNIEXPORT jboolean JNICALL JNI(keep)(JNIEnv *env, jclass cls, jlong view) {
338 (void)env, (void)cls;
339 return sb_view_keep((const View *)(intptr_t)view);
340}
341
342JNIEXPORT jboolean JNICALL JNI(reloadReady)(JNIEnv *env, jclass cls, jlong view) {
343 (void)env, (void)cls;
344 return sb_view_reload_ready((const View *)(intptr_t)view);
345}
346
347JNIEXPORT void JNICALL JNI(reload)(JNIEnv *env, jclass cls, jlong view, jboolean discard) {
348 (void)env, (void)cls;
349 sb_view_reload((View *)(intptr_t)view, discard, NULL);
350}
351
352JNIEXPORT void JNICALL JNI(freeView)(JNIEnv *env, jclass cls, jlong view) {
353 (void)env, (void)cls;
354 sb_view_free((View *)(intptr_t)view);
355}
356
357// Frees the section, then publishes the notebook's edits and lets go of it: false, the edits
358// kept to publish when it next opens, when they don't publish within `seconds`.
359JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jlong section, jdouble seconds) {
360 (void)env, (void)cls;
361 sb_section_free((Section *)(intptr_t)section);
362 char *error = NULL;
363 bool closed = sb_library_close((const Library *)(intptr_t)library, seconds, &error);
364 if (error) {
365 fprintf(stderr, "snowbound: %s\n", error);
366 sb_string_free(error);
367 }
368 sb_library_free((const Library *)(intptr_t)library);
369 return closed;
370}
apps/android/src/net/paperclover/snowbound/Editor.java+78-13
......@@ -8,6 +8,8 @@ import android.view.Surface;
88import java.io.File;
99import java.text.DateFormat;
1010import java.util.Date;
11import java.util.concurrent.ExecutorService;
12import java.util.concurrent.Executors;
1113import org.json.JSONArray;
1214import org.json.JSONException;
1315
......@@ -17,10 +19,18 @@ final class Editor {
1719 System.loadLibrary("snowbound");
1820 }
1921
20 static long section, view;
22 /** `poll`'s flags: a change made elsewhere arrived; the section refused an edit. */
23 private static final int CHANGED = 2, REJECTED = 4;
24 private static final Handler MAIN = new Handler(Looper.getMainLooper());
25 /** Opens, flushes and closes the notebook in turn, off the main thread. */
26 private static final ExecutorService WORK = Executors.newSingleThreadExecutor();
27
28 static long library, section, view;
2129 static String page;
22 /** The view to attach once the page is open. */
23 static PageView shown;
30 /** The activity to show the page once it is open. */
31 static PageActivity shown;
32 /** The section's saving status, as `sb_section_poll` gives it. */
33 static int status;
2434 private static boolean opening;
2535
2636 /** Opens the first page of the notebook in app storage, creating the notebook the first time. */
......@@ -33,25 +43,73 @@ final class Editor {
3343 Date now = new Date();
3444 String date = DateFormat.getDateInstance(DateFormat.FULL).format(now);
3545 String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now);
36 Handler main = new Handler(Looper.getMainLooper());
37 new Thread(() -> {
46 WORK.execute(() -> {
3847 if (!notebook.exists()) {
3948 notebooks.mkdirs();
4049 create(notebook.getPath(), cache.getPath(), date, time);
4150 }
42 long library = library(notebook.getPath(), cache.getPath());
51 long opened = library(notebook.getPath(), cache.getPath());
4352 try {
44 long opened = section(library, new JSONArray(sections(library)).getJSONObject(0).getString("path"));
45 String first = new JSONArray(pages(opened)).getJSONObject(0).getString("id");
46 main.post(() -> {
47 section = opened;
48 page = first;
49 if (shown != null) shown.attach();
53 long first = section(opened, new JSONArray(sections(opened)).getJSONObject(0).getString("path"));
54 String id = new JSONArray(pages(first)).getJSONObject(0).getString("id");
55 MAIN.post(() -> {
56 library = opened;
57 section = first;
58 page = id;
59 if (shown != null) shown.page.attach();
60 poll();
5061 });
5162 } catch (JSONException error) {
5263 throw new IllegalStateException(error);
5364 }
54 }, "Open notebook").start();
65 });
66 }
67
68 /** Called on the section's thread when it has events. */
69 static void woken() {
70 MAIN.post(Editor::poll);
71 }
72
73 /** Takes the section's events: the page shows changes made elsewhere and keeps a refused edit. */
74 static void poll() {
75 if (section == 0) return;
76 int[] polled = poll(section);
77 int flags = polled[0];
78 boolean kept = false;
79 if (view != 0) {
80 if ((flags & REJECTED) != 0) {
81 kept = keep(view);
82 reload(view, true);
83 } else if ((flags & CHANGED) != 0 || reloadReady(view)) {
84 reload(view, false);
85 }
86 }
87 if (polled[1] != status) {
88 status = polled[1];
89 Log.i("snowbound", "Section status " + status);
90 }
91 if (shown == null) return;
92 shown.showStatus();
93 if ((flags & REJECTED) != 0) shown.rejected(kept);
94 if ((flags & (CHANGED | REJECTED)) != 0) shown.page.restartInput();
95 }
96
97 /** Ends composition and stores and publishes every edit while Android lets the app run. */
98 static void stop() {
99 if (view != 0) unmark(view);
100 long flushed = section;
101 if (flushed != 0) WORK.execute(() -> flush(flushed, 20));
102 }
103
104 /** Lets go of the notebook once its last activity finishes, publishing what it can first. */
105 static void close() {
106 if (view != 0) freeView(view);
107 long closed = library, freed = section;
108 library = section = view = 0;
109 page = null;
110 status = 0;
111 opening = false;
112 if (closed != 0) WORK.execute(() -> close(closed, freed, 10));
55113 }
56114
57115 /**
......@@ -93,4 +151,11 @@ final class Editor {
93151 static native boolean deleteBackward(long view);
94152 static native float[] rects(long view, int start, int end);
95153 static native int closest(long view, float x, float y);
154 static native int[] poll(long section);
155 static native boolean flush(long section, double seconds);
156 static native boolean keep(long view);
157 static native boolean reloadReady(long view);
158 static native void reload(long view, boolean discard);
159 static native void freeView(long view);
160 static native boolean close(long library, long section, double seconds);
96161}
apps/android/src/net/paperclover/snowbound/PageActivity.java+37-3
......@@ -1,28 +1,62 @@
11package net.paperclover.snowbound;
22
33import android.app.Activity;
4import android.app.AlertDialog;
45import android.os.Bundle;
6import android.view.Gravity;
7import android.view.View;
58import android.widget.FrameLayout;
9import android.widget.TextView;
610
711public final class PageActivity extends Activity {
8 private PageView page;
12 PageView page;
13 private TextView status;
914
1015 @Override
1116 protected void onCreate(Bundle state) {
1217 super.onCreate(state);
1318 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);
1423 FrameLayout frame = new FrameLayout(this);
1524 frame.setFitsSystemWindows(true);
1625 frame.addView(page);
1726 frame.addView(page.caret);
27 frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END));
1828 setContentView(frame);
19 Editor.shown = page;
29 Editor.shown = this;
30 showStatus();
2031 Editor.open(this);
2132 }
2233
34 /** Only trouble shows: saving goes on quietly, as on iOS. */
35 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);
39 }
40
41 void rejected(boolean kept) {
42 new AlertDialog.Builder(this)
43 .setTitle("Change Not Saved")
44 .setMessage("The page shows what was last saved."
45 + (kept ? " Your version is kept as a separate page." : ""))
46 .setPositiveButton(android.R.string.ok, null)
47 .show();
48 }
49
50 @Override
51 protected void onStop() {
52 Editor.stop();
53 super.onStop();
54 }
55
2356 @Override
2457 protected void onDestroy() {
25 if (Editor.shown == page) Editor.shown = null;
58 if (Editor.shown == this) Editor.shown = null;
59 if (isFinishing()) Editor.close();
2660 super.onDestroy();
2761 }
2862}
apps/android/src/net/paperclover/snowbound/PageView.java+14-5
......@@ -27,7 +27,6 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
2727
2828 PageView(Context context) {
2929 super(context);
30 setFocusableInTouchMode(true);
3130 getHolder().addCallback(this);
3231 caret = new Caret(context);
3332 keyboard = context.getSystemService(InputMethodManager.class);
......@@ -80,8 +79,14 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
8079 Editor.render(Editor.view);
8180 }
8281
83 /** Draws the change and tells the keyboard where the selection and composition are now. */
82 /** Hears how the edit went, then shows it. */
8483 void edited() {
84 Editor.poll();
85 refresh();
86 }
87
88 /** Draws the page and tells the keyboard where the selection and composition are now. */
89 private void refresh() {
8590 dirty = true;
8691 caret.invalidate();
8792 if (input == null || input.batch > 0) return;
......@@ -90,10 +95,10 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
9095 marked == null ? -1 : marked[1]);
9196 }
9297
93 /** The keyboard reads the active outline afresh, as a tap or Tab may have changed it. */
94 private void restartInput() {
98 /** The keyboard reads the active outline afresh, as after a tap, Tab or reload. */
99 void restartInput() {
95100 keyboard.restartInput(this);
96 edited();
101 refresh();
97102 }
98103
99104 @Override
......@@ -102,6 +107,8 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
102107 float scale = scale(), x = event.getX() / scale, y = event.getY() / scale;
103108 switch (event.getActionMasked()) {
104109 case MotionEvent.ACTION_DOWN:
110 // Focusable only once touched: Android would focus it at launch and open the keyboard.
111 setFocusableInTouchMode(true);
105112 requestFocus();
106113 Editor.press(Editor.view, x, y);
107114 break;
......@@ -111,6 +118,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
111118 case MotionEvent.ACTION_UP:
112119 case MotionEvent.ACTION_CANCEL:
113120 Editor.release(Editor.view);
121 Editor.poll();
114122 restartInput();
115123 keyboard.showSoftInput(this, 0);
116124 break;
......@@ -156,6 +164,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
156164 break;
157165 case KeyEvent.KEYCODE_TAB:
158166 Editor.insert(view, "\t");
167 Editor.poll();
159168 restartInput();
160169 return true;
161170 case KeyEvent.KEYCODE_DPAD_LEFT: