diff --git a/apps/android/AndroidManifest.xml b/apps/android/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ae6e67eac5a93af0bec3b5f8a800703bc342a13b
--- /dev/null
+++ b/apps/android/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android/build.py b/apps/android/build.py
new file mode 100644
index 0000000000000000000000000000000000000000..32a9845c472666d366c424df99c190b046d01d29
--- /dev/null
+++ b/apps/android/build.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Builds Snowbound's ARM64 APK, signed with a debug key kept in the output folder.
+
+ python3 apps/android/build.py --sdk SDK [--sdk SDK...] --ndk NDK --java-home JDK --output DIR
+
+Each --sdk is searched for build-tools and the platform. Rust comes from the rustup on PATH,
+which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustup's home).
+"""
+import argparse
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / 'tools'))
+from cargo_fresh import clean_local # noqa: E402
+
+API = 26
+TARGET = 'aarch64-linux-android'
+
+
+def run(*command, **options):
+ subprocess.run([str(part) for part in command], check=True, **options)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('--sdk', type=Path, action='append', required=True)
+ parser.add_argument('--ndk', type=Path, required=True)
+ parser.add_argument('--java-home', type=Path, required=True)
+ parser.add_argument('--output', type=Path, required=True)
+ args = parser.parse_args()
+
+ def sdk(path):
+ found = [root / path for root in args.sdk if (root / path).exists()]
+ if not found:
+ sys.exit(f'No --sdk has {path}')
+ return found[0].resolve()
+
+ tools = sdk(Path('build-tools/36.0.0'))
+ android = sdk(Path('platforms/android-36/android.jar'))
+ java = args.java_home.resolve() / 'bin'
+ ndk = args.ndk.resolve()
+ llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin'
+ output = args.output.resolve()
+ output.mkdir(parents=True, exist_ok=True)
+ source = Path(__file__).resolve().parent
+
+ environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk))
+ environment.setdefault('CARGO_TARGET_DIR', str(ROOT / 'target/android'))
+ # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver.
+ clean_local(ROOT, '--release', '--target', TARGET, environment=environment)
+ run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment)
+ library = output / 'libsnowbound.so'
+ run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC',
+ '-shared', '-Wl,--no-undefined', '-Wl,--exclude-libs,ALL', '-Wl,--gc-sections', '-Wl,--strip-debug',
+ '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include', source / 'jni/snowbound.c',
+ Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a',
+ '-landroid', '-llog', '-ldl', '-lm', '-o', library)
+ # Android 15 devices with 16 KiB pages load only libraries aligned to them.
+ segments = subprocess.run([llvm / 'llvm-readelf', '-lW', library], check=True, capture_output=True,
+ text=True).stdout
+ if any(int(line.split()[-1], 16) < 0x4000 for line in segments.splitlines() if line.strip().startswith('LOAD')):
+ sys.exit(f'{library} has a LOAD segment aligned below 16 KiB')
+
+ classes = output / 'classes'
+ shutil.rmtree(classes, ignore_errors=True)
+ run(java / 'javac', '-Xlint:-options', '--release', '8', '-classpath', android, '-d', classes,
+ *sorted((source / 'src').rglob('*.java')))
+ run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API,
+ '--lib', android, '--output', output, *sorted(classes.rglob('*.class')))
+ resources = output / 'resources.zip'
+ run(tools / 'aapt2', 'compile', '--dir', source / 'res', '-o', resources)
+ unsigned = output / 'unsigned.apk'
+ run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', source / 'AndroidManifest.xml',
+ '-o', unsigned, resources)
+ with zipfile.ZipFile(unsigned, 'a') as apk:
+ apk.write(output / 'classes.dex', 'classes.dex', zipfile.ZIP_DEFLATED)
+ # Android maps the library from the APK, so it stays uncompressed.
+ apk.write(library, 'lib/arm64-v8a/libsnowbound.so', zipfile.ZIP_STORED)
+ aligned = output / 'aligned.apk'
+ run(tools / 'zipalign', '-f', '-P', '16', '4', unsigned, aligned)
+ keystore = output / 'debug.keystore'
+ if not keystore.exists():
+ run(java / 'keytool', '-genkeypair', '-keystore', keystore, '-storepass', 'android', '-alias', 'debug',
+ '-keyalg', 'RSA', '-validity', '10000', '-dname', 'CN=Snowbound Debug')
+ apk = output / 'Snowbound.apk'
+ run(java / 'java', '-jar', tools / 'lib/apksigner.jar', 'sign', '--ks', keystore, '--ks-pass', 'pass:android',
+ '--out', apk, aligned)
+ run(java / 'java', '-jar', tools / 'lib/apksigner.jar', 'verify', apk)
+ run(tools / 'zipalign', '-c', '-P', '16', '4', apk)
+ print(apk)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/apps/android/jni/snowbound.c b/apps/android/jni/snowbound.c
new file mode 100644
index 0000000000000000000000000000000000000000..dca8047d4992ac73a2ec2dc05e672860c019771d
--- /dev/null
+++ b/apps/android/jni/snowbound.c
@@ -0,0 +1,214 @@
+// Editor's natives: crates/mobile's C surface for the Java host.
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "snowbound.h"
+
+#define JNI(name) Java_net_paperclover_snowbound_Editor_##name
+
+// Rust reports problems on stderr, which Android discards; these lines go to logcat instead.
+static void *log_stderr(void *pipe) {
+ FILE *lines = fdopen((int)(intptr_t)pipe, "r");
+ char line[1024];
+ while (lines && fgets(line, sizeof line, lines)) __android_log_write(ANDROID_LOG_WARN, "snowbound", line);
+ return NULL;
+}
+
+JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
+ (void)vm; (void)reserved;
+ int ends[2];
+ pthread_t reader;
+ if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 &&
+ !pthread_create(&reader, NULL, log_stderr, (void *)(intptr_t)ends[0]))
+ pthread_detach(reader);
+ return JNI_VERSION_1_6;
+}
+
+// JNI's own string calls speak modified UTF-8, which differs from Rust's for NUL and
+// characters beyond the BMP.
+static jstring java_text(JNIEnv *env, const char *text) {
+ jsize size = (jsize)strlen(text);
+ jbyteArray bytes = (*env)->NewByteArray(env, size);
+ if (!bytes) return NULL;
+ (*env)->SetByteArrayRegion(env, bytes, 0, size, (const jbyte *)text);
+ jclass string = (*env)->FindClass(env, "java/lang/String");
+ jmethodID decode = (*env)->GetMethodID(env, string, "", "([BLjava/lang/String;)V");
+ jstring encoding = (*env)->NewStringUTF(env, "UTF-8");
+ jstring result = (*env)->NewObject(env, string, decode, bytes, encoding);
+ (*env)->DeleteLocalRef(env, bytes);
+ (*env)->DeleteLocalRef(env, encoding);
+ (*env)->DeleteLocalRef(env, string);
+ return result;
+}
+
+// Throws IllegalStateException with `error`, which Rust allocated, or `fallback` without one.
+static void fail(JNIEnv *env, char *error, const char *fallback) {
+ jstring message = java_text(env, error ? error : fallback);
+ if (error) sb_string_free(error);
+ if (!message) return;
+ jclass type = (*env)->FindClass(env, "java/lang/IllegalStateException");
+ jmethodID make = (*env)->GetMethodID(env, type, "", "(Ljava/lang/String;)V");
+ jthrowable exception = (*env)->NewObject(env, type, make, message);
+ if (exception) (*env)->Throw(env, exception);
+ (*env)->DeleteLocalRef(env, type);
+ (*env)->DeleteLocalRef(env, message);
+}
+
+// A Java string as NUL-terminated UTF-8 to free, or null, throwing, if it holds a NUL or an
+// unpaired surrogate.
+static char *utf8(JNIEnv *env, jstring text) {
+ jsize count = (*env)->GetStringLength(env, text);
+ const jchar *units = (*env)->GetStringChars(env, text, NULL);
+ if (!units) return NULL;
+ char *result = malloc((size_t)count * 3 + 1);
+ size_t used = 0;
+ bool valid = result != NULL;
+ for (jsize i = 0; valid && i < count; i++) {
+ uint32_t c = units[i];
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < count && units[i + 1] >= 0xdc00 && units[i + 1] <= 0xdfff)
+ c = 0x10000 + ((c - 0xd800) << 10) + (units[++i] - 0xdc00);
+ else if (!c || (c >= 0xd800 && c <= 0xdfff)) {
+ valid = false;
+ break;
+ }
+ if (c < 0x80) {
+ result[used++] = (char)c;
+ } else if (c < 0x800) {
+ result[used++] = (char)(0xc0 | c >> 6);
+ result[used++] = (char)(0x80 | (c & 63));
+ } else if (c < 0x10000) {
+ result[used++] = (char)(0xe0 | c >> 12);
+ result[used++] = (char)(0x80 | (c >> 6 & 63));
+ result[used++] = (char)(0x80 | (c & 63));
+ } else {
+ result[used++] = (char)(0xf0 | c >> 18);
+ result[used++] = (char)(0x80 | (c >> 12 & 63));
+ result[used++] = (char)(0x80 | (c >> 6 & 63));
+ result[used++] = (char)(0x80 | (c & 63));
+ }
+ }
+ (*env)->ReleaseStringChars(env, text, units);
+ if (valid) {
+ result[used] = 0;
+ return result;
+ }
+ free(result);
+ fail(env, NULL, "Text holds a NUL or an unpaired surrogate");
+ return NULL;
+}
+
+// Rust's string as Java's, freeing it.
+static jstring output(JNIEnv *env, char *text) {
+ if (!text) {
+ fail(env, NULL, "Snowbound returned nothing");
+ return NULL;
+ }
+ jstring result = java_text(env, text);
+ sb_string_free(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; }
+
+JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) {
+ (void)cls;
+ char *n = utf8(env, notebook), *c = n ? utf8(env, cache) : NULL, *d = c ? utf8(env, date) : NULL,
+ *t = d ? utf8(env, time) : NULL, *error = NULL;
+ if (t && !sb_notebook_create(n, c, "", d, t, &error)) fail(env, error, "The notebook can't be created");
+ free(n), free(c), free(d), free(t);
+}
+
+JNIEXPORT jlong JNICALL JNI(library)(JNIEnv *env, jclass cls, jstring notebook, jstring cache) {
+ (void)cls;
+ char *n = utf8(env, notebook), *c = n ? utf8(env, cache) : NULL, *error = NULL;
+ const Library *library = c ? sb_library_open(n, c, true, &error) : NULL;
+ if (c && !library) fail(env, error, "The notebook can't be opened");
+ free(n), free(c);
+ return (jlong)(intptr_t)library;
+}
+
+JNIEXPORT jstring JNICALL JNI(sections)(JNIEnv *env, jclass cls, jlong library) {
+ (void)cls;
+ return output(env, sb_library_sections((const Library *)(intptr_t)library));
+}
+
+JNIEXPORT jlong JNICALL JNI(section)(JNIEnv *env, jclass cls, jlong library, jstring path) {
+ (void)cls;
+ char *p = utf8(env, path), *error = NULL;
+ Section *section = p ? sb_section_open((const Library *)(intptr_t)library, p, "", woken, 0, &error) : NULL;
+ if (p && !section) fail(env, error, "The section can't be opened");
+ free(p);
+ return (jlong)(intptr_t)section;
+}
+
+JNIEXPORT jstring JNICALL JNI(pages)(JNIEnv *env, jclass cls, jlong section) {
+ (void)cls;
+ return output(env, sb_section_pages((const Section *)(intptr_t)section));
+}
+
+JNIEXPORT jlong JNICALL JNI(view)(JNIEnv *env, jclass cls, jobject surface, jlong section, jstring page,
+ jfloat width, jfloat height, jfloat scale) {
+ (void)cls;
+ char *id = utf8(env, page);
+ ANativeWindow *window = id ? ANativeWindow_fromSurface(env, surface) : NULL;
+ View *view = window ? sb_view_new_android(window, (const Section *)(intptr_t)section, id, width, height, scale) : NULL;
+ if (window) ANativeWindow_release(window);
+ if (id && !view) fail(env, NULL, "The page can't be drawn");
+ free(id);
+ return (jlong)(intptr_t)view;
+}
+
+JNIEXPORT void JNICALL JNI(attach)(JNIEnv *env, jclass cls, jlong view, jobject surface) {
+ (void)cls;
+ ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
+ bool attached = window && sb_view_attach_android((View *)(intptr_t)view, window);
+ if (window) ANativeWindow_release(window);
+ if (!attached) fail(env, NULL, "The page can't be drawn");
+}
+
+JNIEXPORT void JNICALL JNI(detach)(JNIEnv *env, jclass cls, jlong view) {
+ (void)env, (void)cls;
+ sb_view_detach_android((View *)(intptr_t)view);
+}
+
+JNIEXPORT void JNICALL JNI(resize)(JNIEnv *env, jclass cls, jlong view, jfloat width, jfloat height, jfloat scale) {
+ (void)env, (void)cls;
+ sb_view_resize((View *)(intptr_t)view, width, height, scale);
+}
+
+JNIEXPORT void JNICALL JNI(render)(JNIEnv *env, jclass cls, jlong view) {
+ (void)env, (void)cls;
+ sb_view_render((View *)(intptr_t)view);
+}
+
+JNIEXPORT jboolean JNICALL JNI(framePending)(JNIEnv *env, jclass cls, jlong view) {
+ (void)env, (void)cls;
+ return sb_view_frame_pending((const View *)(intptr_t)view);
+}
+
+JNIEXPORT void JNICALL JNI(focus)(JNIEnv *env, jclass cls, jlong view, jboolean focused) {
+ (void)env, (void)cls;
+ sb_view_focus((View *)(intptr_t)view, focused);
+}
+
+JNIEXPORT void JNICALL JNI(press)(JNIEnv *env, jclass cls, jlong view, jfloat x, jfloat y) {
+ (void)env, (void)cls;
+ sb_view_press((View *)(intptr_t)view, x, y);
+}
+
+JNIEXPORT void JNICALL JNI(drag)(JNIEnv *env, jclass cls, jlong view, jfloat x, jfloat y) {
+ (void)env, (void)cls;
+ sb_view_drag((View *)(intptr_t)view, x, y);
+}
+
+JNIEXPORT void JNICALL JNI(release)(JNIEnv *env, jclass cls, jlong view) {
+ (void)env, (void)cls;
+ sb_view_release((View *)(intptr_t)view);
+}
diff --git a/apps/android/res/values/themes.xml b/apps/android/res/values/themes.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cd0a060536f096c4b3d63f1a5dce90e376120d91
--- /dev/null
+++ b/apps/android/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/apps/android/src/net/paperclover/snowbound/Editor.java b/apps/android/src/net/paperclover/snowbound/Editor.java
new file mode 100644
index 0000000000000000000000000000000000000000..f4e0f76ab1bdc8e6a9080038d0352a45f15eac35
--- /dev/null
+++ b/apps/android/src/net/paperclover/snowbound/Editor.java
@@ -0,0 +1,85 @@
+package net.paperclover.snowbound;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.Surface;
+import java.io.File;
+import java.text.DateFormat;
+import java.util.Date;
+import org.json.JSONArray;
+import org.json.JSONException;
+
+/** The page being edited, which outlives each activity showing it; touched only on the main thread. */
+final class Editor {
+ static {
+ System.loadLibrary("snowbound");
+ }
+
+ static long section, view;
+ static String page;
+ /** The view to attach once the page is open. */
+ static PageView shown;
+ private static boolean opening;
+
+ /** Opens the first page of the notebook in app storage, creating the notebook the first time. */
+ static void open(Context context) {
+ if (opening) return;
+ opening = true;
+ File notebooks = new File(context.getFilesDir(), "Notebooks");
+ File notebook = new File(notebooks, "Snowbound");
+ File cache = new File(context.getNoBackupFilesDir(), "Cache");
+ 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(() -> {
+ if (!notebook.exists()) {
+ notebooks.mkdirs();
+ create(notebook.getPath(), cache.getPath(), date, time);
+ }
+ long library = 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();
+ });
+ } catch (JSONException error) {
+ throw new IllegalStateException(error);
+ }
+ }, "Open notebook").start();
+ }
+
+ /**
+ * Draws the page into `surface`. The editor is made once and keeps its selection and Undo
+ * across surfaces; each later one starts at the last size, until SurfaceChanged resizes it.
+ */
+ static void draw(Surface surface, float width, float height, float scale) {
+ if (view != 0) {
+ attach(view, surface);
+ return;
+ }
+ view = view(surface, section, page, width, height, scale);
+ Log.i("snowbound", "Page view created");
+ }
+
+ static native void create(String notebook, String cache, String date, String time);
+ static native long library(String notebook, String cache);
+ static native String sections(long library);
+ static native long section(long library, String path);
+ static native String pages(long section);
+ static native long view(Surface surface, long section, String page, float width, float height, float scale);
+ static native void attach(long view, Surface surface);
+ static native void detach(long view);
+ static native void resize(long view, float width, float height, float scale);
+ static native void render(long view);
+ static native boolean framePending(long view);
+ static native void focus(long view, boolean focused);
+ static native void press(long view, float x, float y);
+ static native void drag(long view, float x, float y);
+ static native void release(long view);
+}
diff --git a/apps/android/src/net/paperclover/snowbound/PageActivity.java b/apps/android/src/net/paperclover/snowbound/PageActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..20b8a660a248661bdad4b833b558ed8e24c4c768
--- /dev/null
+++ b/apps/android/src/net/paperclover/snowbound/PageActivity.java
@@ -0,0 +1,27 @@
+package net.paperclover.snowbound;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.FrameLayout;
+
+public final class PageActivity extends Activity {
+ private PageView page;
+
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ page = new PageView(this);
+ FrameLayout frame = new FrameLayout(this);
+ frame.setFitsSystemWindows(true);
+ frame.addView(page);
+ setContentView(frame);
+ Editor.shown = page;
+ Editor.open(this);
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (Editor.shown == page) Editor.shown = null;
+ super.onDestroy();
+ }
+}
diff --git a/apps/android/src/net/paperclover/snowbound/PageView.java b/apps/android/src/net/paperclover/snowbound/PageView.java
new file mode 100644
index 0000000000000000000000000000000000000000..973050ed6b7f3f078e866a7f2a1885e668c92df1
--- /dev/null
+++ b/apps/android/src/net/paperclover/snowbound/PageView.java
@@ -0,0 +1,106 @@
+package net.paperclover.snowbound;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.view.Choreographer;
+import android.view.MotionEvent;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+/** Draws the editor's page into its surface, a frame whenever the page changes. */
+final class PageView extends SurfaceView implements SurfaceHolder.Callback, Choreographer.FrameCallback {
+ private boolean attached, dirty;
+
+ PageView(Context context) {
+ super(context);
+ setFocusableInTouchMode(true);
+ getHolder().addCallback(this);
+ }
+
+ private float scale() {
+ return getResources().getDisplayMetrics().density;
+ }
+
+ void attach() {
+ if (attached || Editor.page == null || !getHolder().getSurface().isValid() || getWidth() == 0) return;
+ float scale = scale();
+ Editor.draw(getHolder().getSurface(), getWidth() / scale, getHeight() / scale, scale);
+ attached = true;
+ Editor.focus(Editor.view, hasFocus() && hasWindowFocus());
+ dirty = true;
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ attach();
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ attach();
+ if (!attached) return;
+ float scale = scale();
+ Editor.resize(Editor.view, width / scale, height / scale, scale);
+ dirty = true;
+ }
+
+ /** Android destroys the surface once this returns, so the editor lets go of it first. */
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ if (!attached) return;
+ Choreographer.getInstance().removeFrameCallback(this);
+ Editor.detach(Editor.view);
+ attached = false;
+ }
+
+ @Override
+ public void doFrame(long nanos) {
+ Choreographer.getInstance().postFrameCallback(this);
+ if (Editor.framePending(Editor.view)) dirty = true;
+ if (!dirty) return;
+ dirty = false;
+ Editor.render(Editor.view);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (!attached) return false;
+ float scale = scale(), x = event.getX() / scale, y = event.getY() / scale;
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ requestFocus();
+ Editor.press(Editor.view, x, y);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ Editor.drag(Editor.view, x, y);
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ Editor.release(Editor.view);
+ break;
+ default:
+ return false;
+ }
+ dirty = true;
+ return true;
+ }
+
+ @Override
+ protected void onFocusChanged(boolean focused, int direction, Rect previous) {
+ super.onFocusChanged(focused, direction, previous);
+ focusChanged();
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean focused) {
+ super.onWindowFocusChanged(focused);
+ focusChanged();
+ }
+
+ private void focusChanged() {
+ if (!attached) return;
+ Editor.focus(Editor.view, hasFocus() && hasWindowFocus());
+ dirty = true;
+ }
+}