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; + } +}