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: