From 4c06a93816860ce967019ff5bc0dd4b95ca90ccc Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 06:11:36 -0700 Subject: [PATCH] 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 --- apps/android/check.py | 95 +++++++++++++++++++ apps/android/jni/snowbound.c | 76 ++++++++++++++- .../src/net/paperclover/snowbound/Editor.java | 91 +++++++++++++++--- .../paperclover/snowbound/PageActivity.java | 40 +++++++- .../net/paperclover/snowbound/PageView.java | 19 +++- 5 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 apps/android/check.py diff --git a/apps/android/check.py b/apps/android/check.py new file mode 100644 index 0000000000000000000000000000000000000000..65a6fecb80d5dcf8b18d96be8d47b9e91e9d37f2 --- /dev/null +++ b/apps/android/check.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Checks that an edit outlives a force-stop and an offline cold start, on a disposable emulator +the caller starts and stops: + + python3 apps/android/check.py --adb ADB --serial emulator-5560 --apk Snowbound.apk --output DIR + +It types a token into the page, waits until the section reports it saved, force-stops the app, +turns the network off, starts it again and screenshots the page, then reads the section file +back with onestore's inspect example and looks for the token in it. +""" +import argparse +import json +from pathlib import Path +import secrets +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE = 'net.paperclover.snowbound' +SECTION = 'files/Notebooks/Snowbound/New Section 1.one' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--adb', type=Path, required=True) + parser.add_argument('--serial', required=True) + parser.add_argument('--apk', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + + def adb(*command, binary=False, timeout=60): + result = subprocess.run([str(args.adb), '-s', args.serial, *map(str, command)], capture_output=True, + check=True, timeout=timeout) + return result.stdout if binary else result.stdout.decode() + + def logged(after, line, timeout=60): + """Waits for `line` in the app's log after the last `after`, if any.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + lines = adb('logcat', '-d', '-s', 'snowbound:I').splitlines() + marks = [index for index, text in enumerate(lines) if text.endswith(after)] if after else [-1] + if marks and any(text.endswith(line) for text in lines[marks[-1] + 1:]): + return + time.sleep(0.5) + raise TimeoutError(f'No "{line}" in the log') + + def launch(): + adb('logcat', '-c') + adb('shell', 'am', 'start', '-W', '-n', f'{PACKAGE}/.PageActivity') + logged(None, 'Page view created') + time.sleep(2) + + if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1': + raise SystemExit('This check runs only on an emulator') + token = 'check' + secrets.token_hex(4) + width, height = map(int, adb('shell', 'wm', 'size').split()[-1].split('x')) + adb('install', '-r', args.apk.resolve(), timeout=180) + adb('shell', 'am', 'force-stop', PACKAGE) + try: + launch() + adb('shell', 'input', 'tap', width // 4, height * 3 // 10) + time.sleep(1) + adb('shell', 'input', 'text', token) + adb('shell', 'input', 'keyevent', 'KEYCODE_ENTER') + logged('Section status 2', 'Section status 1') + (output / 'typed.png').write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) + adb('shell', 'am', 'force-stop', PACKAGE) + adb('shell', 'cmd', 'connectivity', 'airplane-mode', 'enable') + launch() + (output / 'reopened.png').write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) + section = output / 'section.one' + section.write_bytes(adb('exec-out', 'run-as', PACKAGE, 'cat', SECTION, binary=True)) + (output / 'logcat.txt').write_text(adb('logcat', '-d')) + finally: + adb('shell', 'cmd', 'connectivity', 'airplane-mode', 'disable') + inspected = subprocess.run(['cargo', 'run', '-q', '-p', 'onestore', '--example', 'inspect', '--', section], + cwd=ROOT, capture_output=True, text=True, check=True).stdout.strip() + data = section.read_bytes() + result = { + 'token': token, + 'inspect': inspected, + 'intact': inspected.endswith(' 0 checksum mismatches'), + # Text is stored as UTF-16, or as single bytes where it fits them. + 'tokenInSection': token.encode('utf-16-le') in data or token.encode() in data, + } + (output / 'result.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + if not (result['intact'] and result['tokenInSection']): + raise SystemExit('The edit did not survive') + + +if __name__ == '__main__': + main() diff --git a/apps/android/jni/snowbound.c b/apps/android/jni/snowbound.c index 3c46ce604a2e10f3def4960f50dbf2de27e862d1..db412449d54eab81ea6b9c9f6a904de4c7d67643 100644 --- a/apps/android/jni/snowbound.c +++ b/apps/android/jni/snowbound.c @@ -20,8 +20,21 @@ static void *log_stderr(void *pipe) { return NULL; } +static JavaVM *machine; +static jclass editor; +static jmethodID woken_method; + JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { - (void)vm; (void)reserved; + (void)reserved; + machine = vm; + JNIEnv *env; + if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) return JNI_ERR; + jclass local = (*env)->FindClass(env, "net/paperclover/snowbound/Editor"); + if (!local) return JNI_ERR; + editor = (*env)->NewGlobalRef(env, local); + (*env)->DeleteLocalRef(env, local); + woken_method = (*env)->GetStaticMethodID(env, editor, "woken", "()V"); + if (!woken_method) return JNI_ERR; int ends[2]; pthread_t reader; if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 && @@ -114,8 +127,16 @@ static jstring output(JNIEnv *env, char *text) { return result; } -// Nothing reads the section's events yet; the page shows only its own edits. -static void woken(uintptr_t token) { (void)token; } +// Called on the section's own thread, which may not yet know the JVM. +static void woken(uintptr_t token) { + (void)token; + JNIEnv *env; + bool attached = (*machine)->GetEnv(machine, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED; + if (attached && (*machine)->AttachCurrentThread(machine, &env, NULL) != JNI_OK) return; + (*env)->CallStaticVoidMethod(env, editor, woken_method); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionDescribe(env); + if (attached) (*machine)->DetachCurrentThread(machine); +} JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) { (void)cls; @@ -298,3 +319,52 @@ JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat (void)env, (void)cls; return (jint)sb_closest((const View *)(intptr_t)view, x, y); } + +// The section's events, then its saving status. +JNIEXPORT jintArray JNICALL JNI(poll)(JNIEnv *env, jclass cls, jlong section) { + (void)cls; + uint8_t status = 0; + uint32_t polled[2] = {sb_section_poll((const Section *)(intptr_t)section, &status), 0}; + polled[1] = status; + return range(env, polled); +} + +JNIEXPORT jboolean JNICALL JNI(flush)(JNIEnv *env, jclass cls, jlong section, jdouble seconds) { + (void)env, (void)cls; + return sb_section_flush((const Section *)(intptr_t)section, seconds); +} + +JNIEXPORT jboolean JNICALL JNI(keep)(JNIEnv *env, jclass cls, jlong view) { + (void)env, (void)cls; + return sb_view_keep((const View *)(intptr_t)view); +} + +JNIEXPORT jboolean JNICALL JNI(reloadReady)(JNIEnv *env, jclass cls, jlong view) { + (void)env, (void)cls; + return sb_view_reload_ready((const View *)(intptr_t)view); +} + +JNIEXPORT void JNICALL JNI(reload)(JNIEnv *env, jclass cls, jlong view, jboolean discard) { + (void)env, (void)cls; + sb_view_reload((View *)(intptr_t)view, discard, NULL); +} + +JNIEXPORT void JNICALL JNI(freeView)(JNIEnv *env, jclass cls, jlong view) { + (void)env, (void)cls; + sb_view_free((View *)(intptr_t)view); +} + +// Frees the section, then publishes the notebook's edits and lets go of it: false, the edits +// kept to publish when it next opens, when they don't publish within `seconds`. +JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jlong section, jdouble seconds) { + (void)env, (void)cls; + sb_section_free((Section *)(intptr_t)section); + char *error = NULL; + bool closed = sb_library_close((const Library *)(intptr_t)library, seconds, &error); + if (error) { + fprintf(stderr, "snowbound: %s\n", error); + sb_string_free(error); + } + sb_library_free((const Library *)(intptr_t)library); + return closed; +} diff --git a/apps/android/src/net/paperclover/snowbound/Editor.java b/apps/android/src/net/paperclover/snowbound/Editor.java index 8a27b8b40b9994ccfbcb4e2236c68b87ec1ef31a..4c26e79c2dcce54ea9e8ee6ccb032e8db66dbffe 100644 --- a/apps/android/src/net/paperclover/snowbound/Editor.java +++ b/apps/android/src/net/paperclover/snowbound/Editor.java @@ -8,6 +8,8 @@ import android.view.Surface; import java.io.File; import java.text.DateFormat; import java.util.Date; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.json.JSONArray; import org.json.JSONException; @@ -17,10 +19,18 @@ final class Editor { System.loadLibrary("snowbound"); } - static long section, view; + /** `poll`'s flags: a change made elsewhere arrived; the section refused an edit. */ + private static final int CHANGED = 2, REJECTED = 4; + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + /** Opens, flushes and closes the notebook in turn, off the main thread. */ + private static final ExecutorService WORK = Executors.newSingleThreadExecutor(); + + static long library, section, view; static String page; - /** The view to attach once the page is open. */ - static PageView shown; + /** The activity to show the page once it is open. */ + static PageActivity shown; + /** The section's saving status, as `sb_section_poll` gives it. */ + static int status; private static boolean opening; /** Opens the first page of the notebook in app storage, creating the notebook the first time. */ @@ -33,25 +43,73 @@ final class Editor { Date now = new Date(); String date = DateFormat.getDateInstance(DateFormat.FULL).format(now); String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now); - Handler main = new Handler(Looper.getMainLooper()); - new Thread(() -> { + WORK.execute(() -> { if (!notebook.exists()) { notebooks.mkdirs(); create(notebook.getPath(), cache.getPath(), date, time); } - long library = library(notebook.getPath(), cache.getPath()); + long opened = library(notebook.getPath(), cache.getPath()); try { - long opened = section(library, new JSONArray(sections(library)).getJSONObject(0).getString("path")); - String first = new JSONArray(pages(opened)).getJSONObject(0).getString("id"); - main.post(() -> { - section = opened; - page = first; - if (shown != null) shown.attach(); + long first = section(opened, new JSONArray(sections(opened)).getJSONObject(0).getString("path")); + String id = new JSONArray(pages(first)).getJSONObject(0).getString("id"); + MAIN.post(() -> { + library = opened; + section = first; + page = id; + if (shown != null) shown.page.attach(); + poll(); }); } catch (JSONException error) { throw new IllegalStateException(error); } - }, "Open notebook").start(); + }); + } + + /** Called on the section's thread when it has events. */ + static void woken() { + MAIN.post(Editor::poll); + } + + /** Takes the section's events: the page shows changes made elsewhere and keeps a refused edit. */ + static void poll() { + if (section == 0) return; + int[] polled = poll(section); + int flags = polled[0]; + boolean kept = false; + if (view != 0) { + if ((flags & REJECTED) != 0) { + kept = keep(view); + reload(view, true); + } else if ((flags & CHANGED) != 0 || reloadReady(view)) { + reload(view, false); + } + } + if (polled[1] != status) { + status = polled[1]; + Log.i("snowbound", "Section status " + status); + } + if (shown == null) return; + shown.showStatus(); + if ((flags & REJECTED) != 0) shown.rejected(kept); + if ((flags & (CHANGED | REJECTED)) != 0) shown.page.restartInput(); + } + + /** Ends composition and stores and publishes every edit while Android lets the app run. */ + static void stop() { + if (view != 0) unmark(view); + long flushed = section; + if (flushed != 0) WORK.execute(() -> flush(flushed, 20)); + } + + /** Lets go of the notebook once its last activity finishes, publishing what it can first. */ + static void close() { + if (view != 0) freeView(view); + long closed = library, freed = section; + library = section = view = 0; + page = null; + status = 0; + opening = false; + if (closed != 0) WORK.execute(() -> close(closed, freed, 10)); } /** @@ -93,4 +151,11 @@ final class Editor { static native boolean deleteBackward(long view); static native float[] rects(long view, int start, int end); static native int closest(long view, float x, float y); + static native int[] poll(long section); + static native boolean flush(long section, double seconds); + static native boolean keep(long view); + static native boolean reloadReady(long view); + static native void reload(long view, boolean discard); + static native void freeView(long view); + static native boolean close(long library, long section, double seconds); } diff --git a/apps/android/src/net/paperclover/snowbound/PageActivity.java b/apps/android/src/net/paperclover/snowbound/PageActivity.java index 903f0629ea2758c72873f7fdbe5831e7906f358a..f07469c1ef326c7ca06dae81ff181158103c6473 100644 --- a/apps/android/src/net/paperclover/snowbound/PageActivity.java +++ b/apps/android/src/net/paperclover/snowbound/PageActivity.java @@ -1,28 +1,62 @@ package net.paperclover.snowbound; import android.app.Activity; +import android.app.AlertDialog; import android.os.Bundle; +import android.view.Gravity; +import android.view.View; import android.widget.FrameLayout; +import android.widget.TextView; public final class PageActivity extends Activity { - private PageView page; + PageView page; + private TextView status; @Override protected void onCreate(Bundle state) { super.onCreate(state); page = new PageView(this); + status = new TextView(this); + status.setTextAppearance(android.R.style.TextAppearance_Material_Caption); + int margin = Math.round(8 * getResources().getDisplayMetrics().density); + status.setPadding(margin, margin, margin, margin); FrameLayout frame = new FrameLayout(this); frame.setFitsSystemWindows(true); frame.addView(page); frame.addView(page.caret); + frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END)); setContentView(frame); - Editor.shown = page; + Editor.shown = this; + showStatus(); Editor.open(this); } + /** Only trouble shows: saving goes on quietly, as on iOS. */ + void showStatus() { + String text = Editor.status == 3 ? "Offline" : Editor.status == 4 ? "Not Saving" : null; + status.setText(text); + status.setVisibility(text == null ? View.GONE : View.VISIBLE); + } + + void rejected(boolean kept) { + new AlertDialog.Builder(this) + .setTitle("Change Not Saved") + .setMessage("The page shows what was last saved." + + (kept ? " Your version is kept as a separate page." : "")) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + @Override + protected void onStop() { + Editor.stop(); + super.onStop(); + } + @Override protected void onDestroy() { - if (Editor.shown == page) Editor.shown = null; + if (Editor.shown == this) Editor.shown = null; + if (isFinishing()) Editor.close(); super.onDestroy(); } } diff --git a/apps/android/src/net/paperclover/snowbound/PageView.java b/apps/android/src/net/paperclover/snowbound/PageView.java index 18d2bd5dd8b2c3e3603c1a065b9c847fa8530f44..0026c7eda3d2f1d0207a485ba5e460c1da0adca6 100644 --- a/apps/android/src/net/paperclover/snowbound/PageView.java +++ b/apps/android/src/net/paperclover/snowbound/PageView.java @@ -27,7 +27,6 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor PageView(Context context) { super(context); - setFocusableInTouchMode(true); getHolder().addCallback(this); caret = new Caret(context); keyboard = context.getSystemService(InputMethodManager.class); @@ -80,8 +79,14 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor Editor.render(Editor.view); } - /** Draws the change and tells the keyboard where the selection and composition are now. */ + /** Hears how the edit went, then shows it. */ void edited() { + Editor.poll(); + refresh(); + } + + /** Draws the page and tells the keyboard where the selection and composition are now. */ + private void refresh() { dirty = true; caret.invalidate(); if (input == null || input.batch > 0) return; @@ -90,10 +95,10 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor marked == null ? -1 : marked[1]); } - /** The keyboard reads the active outline afresh, as a tap or Tab may have changed it. */ - private void restartInput() { + /** The keyboard reads the active outline afresh, as after a tap, Tab or reload. */ + void restartInput() { keyboard.restartInput(this); - edited(); + refresh(); } @Override @@ -102,6 +107,8 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor float scale = scale(), x = event.getX() / scale, y = event.getY() / scale; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: + // Focusable only once touched: Android would focus it at launch and open the keyboard. + setFocusableInTouchMode(true); requestFocus(); Editor.press(Editor.view, x, y); break; @@ -111,6 +118,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: Editor.release(Editor.view); + Editor.poll(); restartInput(); keyboard.showSoftInput(this, 0); break; @@ -156,6 +164,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor break; case KeyEvent.KEYCODE_TAB: Editor.insert(view, "\t"); + Editor.poll(); restartInput(); return true; case KeyEvent.KEYCODE_DPAD_LEFT: -- 2.54.0