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) {...@@ -20,8 +20,21 @@ static void *log_stderr(void *pipe) {
20 return NULL;20 return NULL;
21}21}
2222
23static JavaVM *machine;
24static jclass editor;
25static jmethodID woken_method;
26
23JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {27JNIEXPORT 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;
25 int ends[2];38 int ends[2];
26 pthread_t reader;39 pthread_t reader;
27 if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 &&40 if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 &&
...@@ -114,8 +127,16 @@ static jstring output(JNIEnv *env, char *text) {...@@ -114,8 +127,16 @@ static jstring output(JNIEnv *env, char *text) {
114 return result;127 return result;
115}128}
116129
117// Nothing reads the section's events yet; the page shows only its own edits.130// Called on the section's own thread, which may not yet know the JVM.
118static void woken(uintptr_t token) { (void)token; }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
120JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) {141JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) {
121 (void)cls;142 (void)cls;
...@@ -298,3 +319,52 @@ JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat...@@ -298,3 +319,52 @@ JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat
298 (void)env, (void)cls;319 (void)env, (void)cls;
299 return (jint)sb_closest((const View *)(intptr_t)view, x, y);320 return (jint)sb_closest((const View *)(intptr_t)view, x, y);
300}321}
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;...@@ -8,6 +8,8 @@ import android.view.Surface;
8import java.io.File;8import java.io.File;
9import java.text.DateFormat;9import java.text.DateFormat;
10import java.util.Date;10import java.util.Date;
11import java.util.concurrent.ExecutorService;
12import java.util.concurrent.Executors;
11import org.json.JSONArray;13import org.json.JSONArray;
12import org.json.JSONException;14import org.json.JSONException;
1315
...@@ -17,10 +19,18 @@ final class Editor {...@@ -17,10 +19,18 @@ final class Editor {
17 System.loadLibrary("snowbound");19 System.loadLibrary("snowbound");
18 }20 }
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;
21 static String page;29 static String page;
22 /** The view to attach once the page is open. */30 /** The activity to show the page once it is open. */
23 static PageView shown;31 static PageActivity shown;
32 /** The section's saving status, as `sb_section_poll` gives it. */
33 static int status;
24 private static boolean opening;34 private static boolean opening;
2535
26 /** Opens the first page of the notebook in app storage, creating the notebook the first time. */36 /** Opens the first page of the notebook in app storage, creating the notebook the first time. */
...@@ -33,25 +43,73 @@ final class Editor {...@@ -33,25 +43,73 @@ final class Editor {
33 Date now = new Date();43 Date now = new Date();
34 String date = DateFormat.getDateInstance(DateFormat.FULL).format(now);44 String date = DateFormat.getDateInstance(DateFormat.FULL).format(now);
35 String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now);45 String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now);
36 Handler main = new Handler(Looper.getMainLooper());46 WORK.execute(() -> {
37 new Thread(() -> {
38 if (!notebook.exists()) {47 if (!notebook.exists()) {
39 notebooks.mkdirs();48 notebooks.mkdirs();
40 create(notebook.getPath(), cache.getPath(), date, time);49 create(notebook.getPath(), cache.getPath(), date, time);
41 }50 }
42 long library = library(notebook.getPath(), cache.getPath());51 long opened = library(notebook.getPath(), cache.getPath());
43 try {52 try {
44 long opened = section(library, new JSONArray(sections(library)).getJSONObject(0).getString("path"));53 long first = section(opened, new JSONArray(sections(opened)).getJSONObject(0).getString("path"));
45 String first = new JSONArray(pages(opened)).getJSONObject(0).getString("id");54 String id = new JSONArray(pages(first)).getJSONObject(0).getString("id");
46 main.post(() -> {55 MAIN.post(() -> {
47 section = opened;56 library = opened;
48 page = first;57 section = first;
49 if (shown != null) shown.attach();58 page = id;
59 if (shown != null) shown.page.attach();
60 poll();
50 });61 });
51 } catch (JSONException error) {62 } catch (JSONException error) {
52 throw new IllegalStateException(error);63 throw new IllegalStateException(error);
53 }64 }
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));
55 }113 }
56114
57 /**115 /**
...@@ -93,4 +151,11 @@ final class Editor {...@@ -93,4 +151,11 @@ final class Editor {
93 static native boolean deleteBackward(long view);151 static native boolean deleteBackward(long view);
94 static native float[] rects(long view, int start, int end);152 static native float[] rects(long view, int start, int end);
95 static native int closest(long view, float x, float y);153 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);
96}161}
apps/android/src/net/paperclover/snowbound/PageActivity.java+37-3
...@@ -1,28 +1,62 @@...@@ -1,28 +1,62 @@
1package net.paperclover.snowbound;1package net.paperclover.snowbound;
22
3import android.app.Activity;3import android.app.Activity;
4import android.app.AlertDialog;
4import android.os.Bundle;5import android.os.Bundle;
6import android.view.Gravity;
7import android.view.View;
5import android.widget.FrameLayout;8import android.widget.FrameLayout;
9import android.widget.TextView;
610
7public final class PageActivity extends Activity {11public final class PageActivity extends Activity {
8 private PageView page;12 PageView page;
13 private TextView status;
914
10 @Override15 @Override
11 protected void onCreate(Bundle state) {16 protected void onCreate(Bundle state) {
12 super.onCreate(state);17 super.onCreate(state);
13 page = new PageView(this);18 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);
14 FrameLayout frame = new FrameLayout(this);23 FrameLayout frame = new FrameLayout(this);
15 frame.setFitsSystemWindows(true);24 frame.setFitsSystemWindows(true);
16 frame.addView(page);25 frame.addView(page);
17 frame.addView(page.caret);26 frame.addView(page.caret);
27 frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END));
18 setContentView(frame);28 setContentView(frame);
19 Editor.shown = page;29 Editor.shown = this;
30 showStatus();
20 Editor.open(this);31 Editor.open(this);
21 }32 }
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
23 @Override56 @Override
24 protected void onDestroy() {57 protected void onDestroy() {
25 if (Editor.shown == page) Editor.shown = null;58 if (Editor.shown == this) Editor.shown = null;
59 if (isFinishing()) Editor.close();
26 super.onDestroy();60 super.onDestroy();
27 }61 }
28}62}
apps/android/src/net/paperclover/snowbound/PageView.java+14-5
...@@ -27,7 +27,6 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor...@@ -27,7 +27,6 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
2727
28 PageView(Context context) {28 PageView(Context context) {
29 super(context);29 super(context);
30 setFocusableInTouchMode(true);
31 getHolder().addCallback(this);30 getHolder().addCallback(this);
32 caret = new Caret(context);31 caret = new Caret(context);
33 keyboard = context.getSystemService(InputMethodManager.class);32 keyboard = context.getSystemService(InputMethodManager.class);
...@@ -80,8 +79,14 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor...@@ -80,8 +79,14 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
80 Editor.render(Editor.view);79 Editor.render(Editor.view);
81 }80 }
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. */
84 void edited() {83 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() {
85 dirty = true;90 dirty = true;
86 caret.invalidate();91 caret.invalidate();
87 if (input == null || input.batch > 0) return;92 if (input == null || input.batch > 0) return;
...@@ -90,10 +95,10 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor...@@ -90,10 +95,10 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
90 marked == null ? -1 : marked[1]);95 marked == null ? -1 : marked[1]);
91 }96 }
9297
93 /** The keyboard reads the active outline afresh, as a tap or Tab may have changed it. */98 /** The keyboard reads the active outline afresh, as after a tap, Tab or reload. */
94 private void restartInput() {99 void restartInput() {
95 keyboard.restartInput(this);100 keyboard.restartInput(this);
96 edited();101 refresh();
97 }102 }
98103
99 @Override104 @Override
...@@ -102,6 +107,8 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor...@@ -102,6 +107,8 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
102 float scale = scale(), x = event.getX() / scale, y = event.getY() / scale;107 float scale = scale(), x = event.getX() / scale, y = event.getY() / scale;
103 switch (event.getActionMasked()) {108 switch (event.getActionMasked()) {
104 case MotionEvent.ACTION_DOWN:109 case MotionEvent.ACTION_DOWN:
110 // Focusable only once touched: Android would focus it at launch and open the keyboard.
111 setFocusableInTouchMode(true);
105 requestFocus();112 requestFocus();
106 Editor.press(Editor.view, x, y);113 Editor.press(Editor.view, x, y);
107 break;114 break;
...@@ -111,6 +118,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor...@@ -111,6 +118,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
111 case MotionEvent.ACTION_UP:118 case MotionEvent.ACTION_UP:
112 case MotionEvent.ACTION_CANCEL:119 case MotionEvent.ACTION_CANCEL:
113 Editor.release(Editor.view);120 Editor.release(Editor.view);
121 Editor.poll();
114 restartInput();122 restartInput();
115 keyboard.showSoftInput(this, 0);123 keyboard.showSoftInput(this, 0);
116 break;124 break;
...@@ -156,6 +164,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor...@@ -156,6 +164,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
156 break;164 break;
157 case KeyEvent.KEYCODE_TAB:165 case KeyEvent.KEYCODE_TAB:
158 Editor.insert(view, "\t");166 Editor.insert(view, "\t");
167 Editor.poll();
159 restartInput();168 restartInput();
160 return true;169 return true;
161 case KeyEvent.KEYCODE_DPAD_LEFT:170 case KeyEvent.KEYCODE_DPAD_LEFT: