| author | |
| committer | |
| log | 4c06a93816860ce967019ff5bc0dd4b95ca90ccc |
| tree | f22d6406052067b6611841d1ff1385c56faca678 |
| parent | d8a3bb046d2bdf597b51e08a041375538e1aad16 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.55 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 | |
| 3 | the caller starts and stops: | |
| 4 | ||
| 5 | python3 apps/android/check.py --adb ADB --serial emulator-5560 --apk Snowbound.apk --output DIR | |
| 6 | ||
| 7 | It types a token into the page, waits until the section reports it saved, force-stops the app, | |
| 8 | turns the network off, starts it again and screenshots the page, then reads the section file | |
| 9 | back with onestore's inspect example and looks for the token in it. | |
| 10 | """ | |
| 11 | import argparse | |
| 12 | import json | |
| 13 | from pathlib import Path | |
| 14 | import secrets | |
| 15 | import subprocess | |
| 16 | import time | |
| 17 | ||
| 18 | ROOT = Path(__file__).resolve().parents[2] | |
| 19 | PACKAGE = 'net.paperclover.snowbound' | |
| 20 | SECTION = 'files/Notebooks/Snowbound/New Section 1.one' | |
| 21 | ||
| 22 | ||
| 23 | def 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 | ||
| 94 | if __name__ == '__main__': | |
| 95 | main() |
apps/android/jni/snowbound.c+73-3| ... | ... | @@ -20,8 +20,21 @@ static void *log_stderr(void *pipe) { |
| 20 | 20 | return NULL; |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | static JavaVM *machine; | |
| 24 | static jclass editor; | |
| 25 | static jmethodID woken_method; | |
| 26 | ||
| 23 | 27 | JNIEXPORT 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 | 38 | int ends[2]; |
| 26 | 39 | pthread_t reader; |
| 27 | 40 | if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 && |
| ... | ... | @@ -114,8 +127,16 @@ static jstring output(JNIEnv *env, char *text) { |
| 114 | 127 | return result; |
| 115 | 128 | } |
| 116 | 129 | |
| 117 | // Nothing reads the section's events yet; the page shows only its own edits. | |
| 118 | static void woken(uintptr_t token) { (void)token; } | |
| 130 | // Called on the section's own thread, which may not yet know the JVM. | |
| 131 | static 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 | } | |
| 119 | 140 | |
| 120 | 141 | JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) { |
| 121 | 142 | (void)cls; |
| ... | ... | @@ -298,3 +319,52 @@ JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat |
| 298 | 319 | (void)env, (void)cls; |
| 299 | 320 | return (jint)sb_closest((const View *)(intptr_t)view, x, y); |
| 300 | 321 | } |
| 322 | ||
| 323 | // The section's events, then its saving status. | |
| 324 | JNIEXPORT 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 | ||
| 332 | JNIEXPORT 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 | ||
| 337 | JNIEXPORT 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 | ||
| 342 | JNIEXPORT 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 | ||
| 347 | JNIEXPORT 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 | ||
| 352 | JNIEXPORT 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`. | |
| 359 | JNIEXPORT 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 | 8 | import java.io.File; |
| 9 | 9 | import java.text.DateFormat; |
| 10 | 10 | import java.util.Date; |
| 11 | import java.util.concurrent.ExecutorService; | |
| 12 | import java.util.concurrent.Executors; | |
| 11 | 13 | import org.json.JSONArray; |
| 12 | 14 | import org.json.JSONException; |
| 13 | 15 | |
| ... | ... | @@ -17,10 +19,18 @@ final class Editor { |
| 17 | 19 | System.loadLibrary("snowbound"); |
| 18 | 20 | } |
| 19 | 21 | |
| 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 | 29 | 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; | |
| 24 | 34 | private static boolean opening; |
| 25 | 35 | |
| 26 | 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 | 43 | Date now = new Date(); |
| 34 | 44 | String date = DateFormat.getDateInstance(DateFormat.FULL).format(now); |
| 35 | 45 | String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now); |
| 36 | Handler main = new Handler(Looper.getMainLooper()); | |
| 37 | new Thread(() -> { | |
| 46 | WORK.execute(() -> { | |
| 38 | 47 | if (!notebook.exists()) { |
| 39 | 48 | notebooks.mkdirs(); |
| 40 | 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 | 52 | 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(); | |
| 50 | 61 | }); |
| 51 | 62 | } catch (JSONException error) { |
| 52 | 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 | } |
| 56 | 114 | |
| 57 | 115 | /** |
| ... | ... | @@ -93,4 +151,11 @@ final class Editor { |
| 93 | 151 | static native boolean deleteBackward(long view); |
| 94 | 152 | static native float[] rects(long view, int start, int end); |
| 95 | 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 | 1 | package net.paperclover.snowbound; |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity; |
| 4 | import android.app.AlertDialog; | |
| 4 | 5 | import android.os.Bundle; |
| 6 | import android.view.Gravity; | |
| 7 | import android.view.View; | |
| 5 | 8 | import android.widget.FrameLayout; |
| 9 | import android.widget.TextView; | |
| 6 | 10 | |
| 7 | 11 | public final class PageActivity extends Activity { |
| 8 | private PageView page; | |
| 12 | PageView page; | |
| 13 | private TextView status; | |
| 9 | 14 | |
| 10 | 15 | @Override |
| 11 | 16 | protected void onCreate(Bundle state) { |
| 12 | 17 | super.onCreate(state); |
| 13 | 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 | 23 | FrameLayout frame = new FrameLayout(this); |
| 15 | 24 | frame.setFitsSystemWindows(true); |
| 16 | 25 | frame.addView(page); |
| 17 | 26 | frame.addView(page.caret); |
| 27 | frame.addView(status, new FrameLayout.LayoutParams(-2, -2, Gravity.TOP | Gravity.END)); | |
| 18 | 28 | setContentView(frame); |
| 19 | Editor.shown = page; | |
| 29 | Editor.shown = this; | |
| 30 | showStatus(); | |
| 20 | 31 | Editor.open(this); |
| 21 | 32 | } |
| 22 | 33 | |
| 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 | 56 | @Override |
| 24 | 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 | 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 | 27 | |
| 28 | 28 | PageView(Context context) { |
| 29 | 29 | super(context); |
| 30 | setFocusableInTouchMode(true); | |
| 31 | 30 | getHolder().addCallback(this); |
| 32 | 31 | caret = new Caret(context); |
| 33 | 32 | keyboard = context.getSystemService(InputMethodManager.class); |
| ... | ... | @@ -80,8 +79,14 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor |
| 80 | 79 | Editor.render(Editor.view); |
| 81 | 80 | } |
| 82 | 81 | |
| 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 | 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 | 90 | dirty = true; |
| 86 | 91 | caret.invalidate(); |
| 87 | 92 | if (input == null || input.batch > 0) return; |
| ... | ... | @@ -90,10 +95,10 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor |
| 90 | 95 | marked == null ? -1 : marked[1]); |
| 91 | 96 | } |
| 92 | 97 | |
| 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() { | |
| 95 | 100 | keyboard.restartInput(this); |
| 96 | edited(); | |
| 101 | refresh(); | |
| 97 | 102 | } |
| 98 | 103 | |
| 99 | 104 | @Override |
| ... | ... | @@ -102,6 +107,8 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor |
| 102 | 107 | float scale = scale(), x = event.getX() / scale, y = event.getY() / scale; |
| 103 | 108 | switch (event.getActionMasked()) { |
| 104 | 109 | case MotionEvent.ACTION_DOWN: |
| 110 | // Focusable only once touched: Android would focus it at launch and open the keyboard. | |
| 111 | setFocusableInTouchMode(true); | |
| 105 | 112 | requestFocus(); |
| 106 | 113 | Editor.press(Editor.view, x, y); |
| 107 | 114 | break; |
| ... | ... | @@ -111,6 +118,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor |
| 111 | 118 | case MotionEvent.ACTION_UP: |
| 112 | 119 | case MotionEvent.ACTION_CANCEL: |
| 113 | 120 | Editor.release(Editor.view); |
| 121 | Editor.poll(); | |
| 114 | 122 | restartInput(); |
| 115 | 123 | keyboard.showSoftInput(this, 0); |
| 116 | 124 | break; |
| ... | ... | @@ -156,6 +164,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor |
| 156 | 164 | break; |
| 157 | 165 | case KeyEvent.KEYCODE_TAB: |
| 158 | 166 | Editor.insert(view, "\t"); |
| 167 | Editor.poll(); | |
| 159 | 168 | restartInput(); |
| 160 | 169 | return true; |
| 161 | 170 | case KeyEvent.KEYCODE_DPAD_LEFT: |