| author | |
| committer | |
| log | 10cde9f3e120e69e22c88fe33ecfe264bd350fb0 |
| tree | 72f2f26514f499d2c000ccf72273463e25a74081 |
| parent | ee54ecdfad5603988e09516439764799c94202e3 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
apps/android is the Android host: a Java activity around a SurfaceView, and C that hands
crates/mobile its ANativeWindow. On first launch it creates a notebook in app storage, its
replicas in the no-backup folder, and draws New Section 1's page. The editor outlives each
activity, so rotation and Home detach and reattach the same page. Touches reach the page;
typing, saving on stop and section events come next.
build.py makes an ARM64 APK for Android 8.0 and later without Gradle, signed with a debug key
kept beside it, its library aligned for 16 KiB pages. It builds the Rust half in release, as
wgpu names GPU objects in a debug build and the emulator's Vulkan driver crashes on that.
Refs #24
Assisted-by: claude-opus-5.57 files changed, 549 insertions(+), 0 deletions(-)
apps/android/AndroidManifest.xml created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.paperclover.snowbound" | |
| 2 | android:versionCode="1" android:versionName="0.1"> | |
| 3 | <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" /> | |
| 4 | <application android:label="Snowbound" android:theme="@style/Page" android:extractNativeLibs="false"> | |
| 5 | <activity android:name=".PageActivity" android:exported="true"> | |
| 6 | <intent-filter> | |
| 7 | <action android:name="android.intent.action.MAIN" /> | |
| 8 | <category android:name="android.intent.category.LAUNCHER" /> | |
| 9 | </intent-filter> | |
| 10 | </activity> | |
| 11 | </application> | |
| 12 | </manifest> |
apps/android/build.py created+99| ... | ... | @@ -0,0 +1,99 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Builds Snowbound's ARM64 APK, signed with a debug key kept in the output folder. | |
| 3 | ||
| 4 | python3 apps/android/build.py --sdk SDK [--sdk SDK...] --ndk NDK --java-home JDK --output DIR | |
| 5 | ||
| 6 | Each --sdk is searched for build-tools and the platform. Rust comes from the rustup on PATH, | |
| 7 | which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustup's home). | |
| 8 | """ | |
| 9 | import argparse | |
| 10 | import os | |
| 11 | from pathlib import Path | |
| 12 | import shutil | |
| 13 | import subprocess | |
| 14 | import sys | |
| 15 | import zipfile | |
| 16 | ||
| 17 | ROOT = Path(__file__).resolve().parents[2] | |
| 18 | sys.path.insert(0, str(ROOT / 'tools')) | |
| 19 | from cargo_fresh import clean_local # noqa: E402 | |
| 20 | ||
| 21 | API = 26 | |
| 22 | TARGET = 'aarch64-linux-android' | |
| 23 | ||
| 24 | ||
| 25 | def run(*command, **options): | |
| 26 | subprocess.run([str(part) for part in command], check=True, **options) | |
| 27 | ||
| 28 | ||
| 29 | def main(): | |
| 30 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 31 | parser.add_argument('--sdk', type=Path, action='append', required=True) | |
| 32 | parser.add_argument('--ndk', type=Path, required=True) | |
| 33 | parser.add_argument('--java-home', type=Path, required=True) | |
| 34 | parser.add_argument('--output', type=Path, required=True) | |
| 35 | args = parser.parse_args() | |
| 36 | ||
| 37 | def sdk(path): | |
| 38 | found = [root / path for root in args.sdk if (root / path).exists()] | |
| 39 | if not found: | |
| 40 | sys.exit(f'No --sdk has {path}') | |
| 41 | return found[0].resolve() | |
| 42 | ||
| 43 | tools = sdk(Path('build-tools/36.0.0')) | |
| 44 | android = sdk(Path('platforms/android-36/android.jar')) | |
| 45 | java = args.java_home.resolve() / 'bin' | |
| 46 | ndk = args.ndk.resolve() | |
| 47 | llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin' | |
| 48 | output = args.output.resolve() | |
| 49 | output.mkdir(parents=True, exist_ok=True) | |
| 50 | source = Path(__file__).resolve().parent | |
| 51 | ||
| 52 | environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk)) | |
| 53 | environment.setdefault('CARGO_TARGET_DIR', str(ROOT / 'target/android')) | |
| 54 | # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver. | |
| 55 | clean_local(ROOT, '--release', '--target', TARGET, environment=environment) | |
| 56 | run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment) | |
| 57 | library = output / 'libsnowbound.so' | |
| 58 | run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC', | |
| 59 | '-shared', '-Wl,--no-undefined', '-Wl,--exclude-libs,ALL', '-Wl,--gc-sections', '-Wl,--strip-debug', | |
| 60 | '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include', source / 'jni/snowbound.c', | |
| 61 | Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a', | |
| 62 | '-landroid', '-llog', '-ldl', '-lm', '-o', library) | |
| 63 | # Android 15 devices with 16 KiB pages load only libraries aligned to them. | |
| 64 | segments = subprocess.run([llvm / 'llvm-readelf', '-lW', library], check=True, capture_output=True, | |
| 65 | text=True).stdout | |
| 66 | if any(int(line.split()[-1], 16) < 0x4000 for line in segments.splitlines() if line.strip().startswith('LOAD')): | |
| 67 | sys.exit(f'{library} has a LOAD segment aligned below 16 KiB') | |
| 68 | ||
| 69 | classes = output / 'classes' | |
| 70 | shutil.rmtree(classes, ignore_errors=True) | |
| 71 | run(java / 'javac', '-Xlint:-options', '--release', '8', '-classpath', android, '-d', classes, | |
| 72 | *sorted((source / 'src').rglob('*.java'))) | |
| 73 | run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API, | |
| 74 | '--lib', android, '--output', output, *sorted(classes.rglob('*.class'))) | |
| 75 | resources = output / 'resources.zip' | |
| 76 | run(tools / 'aapt2', 'compile', '--dir', source / 'res', '-o', resources) | |
| 77 | unsigned = output / 'unsigned.apk' | |
| 78 | run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', source / 'AndroidManifest.xml', | |
| 79 | '-o', unsigned, resources) | |
| 80 | with zipfile.ZipFile(unsigned, 'a') as apk: | |
| 81 | apk.write(output / 'classes.dex', 'classes.dex', zipfile.ZIP_DEFLATED) | |
| 82 | # Android maps the library from the APK, so it stays uncompressed. | |
| 83 | apk.write(library, 'lib/arm64-v8a/libsnowbound.so', zipfile.ZIP_STORED) | |
| 84 | aligned = output / 'aligned.apk' | |
| 85 | run(tools / 'zipalign', '-f', '-P', '16', '4', unsigned, aligned) | |
| 86 | keystore = output / 'debug.keystore' | |
| 87 | if not keystore.exists(): | |
| 88 | run(java / 'keytool', '-genkeypair', '-keystore', keystore, '-storepass', 'android', '-alias', 'debug', | |
| 89 | '-keyalg', 'RSA', '-validity', '10000', '-dname', 'CN=Snowbound Debug') | |
| 90 | apk = output / 'Snowbound.apk' | |
| 91 | run(java / 'java', '-jar', tools / 'lib/apksigner.jar', 'sign', '--ks', keystore, '--ks-pass', 'pass:android', | |
| 92 | '--out', apk, aligned) | |
| 93 | run(java / 'java', '-jar', tools / 'lib/apksigner.jar', 'verify', apk) | |
| 94 | run(tools / 'zipalign', '-c', '-P', '16', '4', apk) | |
| 95 | print(apk) | |
| 96 | ||
| 97 | ||
| 98 | if __name__ == '__main__': | |
| 99 | main() |
apps/android/jni/snowbound.c created+214| ... | ... | @@ -0,0 +1,214 @@ |
| 1 | // Editor's natives: crates/mobile's C surface for the Java host. | |
| 2 | #include <android/log.h> | |
| 3 | #include <android/native_window_jni.h> | |
| 4 | #include <jni.h> | |
| 5 | #include <pthread.h> | |
| 6 | #include <stdint.h> | |
| 7 | #include <stdio.h> | |
| 8 | #include <stdlib.h> | |
| 9 | #include <string.h> | |
| 10 | #include <unistd.h> | |
| 11 | #include "snowbound.h" | |
| 12 | ||
| 13 | #define JNI(name) Java_net_paperclover_snowbound_Editor_##name | |
| 14 | ||
| 15 | // Rust reports problems on stderr, which Android discards; these lines go to logcat instead. | |
| 16 | static void *log_stderr(void *pipe) { | |
| 17 | FILE *lines = fdopen((int)(intptr_t)pipe, "r"); | |
| 18 | char line[1024]; | |
| 19 | while (lines && fgets(line, sizeof line, lines)) __android_log_write(ANDROID_LOG_WARN, "snowbound", line); | |
| 20 | return NULL; | |
| 21 | } | |
| 22 | ||
| 23 | JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { | |
| 24 | (void)vm; (void)reserved; | |
| 25 | int ends[2]; | |
| 26 | pthread_t reader; | |
| 27 | if (!pipe(ends) && dup2(ends[1], STDERR_FILENO) >= 0 && | |
| 28 | !pthread_create(&reader, NULL, log_stderr, (void *)(intptr_t)ends[0])) | |
| 29 | pthread_detach(reader); | |
| 30 | return JNI_VERSION_1_6; | |
| 31 | } | |
| 32 | ||
| 33 | // JNI's own string calls speak modified UTF-8, which differs from Rust's for NUL and | |
| 34 | // characters beyond the BMP. | |
| 35 | static jstring java_text(JNIEnv *env, const char *text) { | |
| 36 | jsize size = (jsize)strlen(text); | |
| 37 | jbyteArray bytes = (*env)->NewByteArray(env, size); | |
| 38 | if (!bytes) return NULL; | |
| 39 | (*env)->SetByteArrayRegion(env, bytes, 0, size, (const jbyte *)text); | |
| 40 | jclass string = (*env)->FindClass(env, "java/lang/String"); | |
| 41 | jmethodID decode = (*env)->GetMethodID(env, string, "<init>", "([BLjava/lang/String;)V"); | |
| 42 | jstring encoding = (*env)->NewStringUTF(env, "UTF-8"); | |
| 43 | jstring result = (*env)->NewObject(env, string, decode, bytes, encoding); | |
| 44 | (*env)->DeleteLocalRef(env, bytes); | |
| 45 | (*env)->DeleteLocalRef(env, encoding); | |
| 46 | (*env)->DeleteLocalRef(env, string); | |
| 47 | return result; | |
| 48 | } | |
| 49 | ||
| 50 | // Throws IllegalStateException with `error`, which Rust allocated, or `fallback` without one. | |
| 51 | static void fail(JNIEnv *env, char *error, const char *fallback) { | |
| 52 | jstring message = java_text(env, error ? error : fallback); | |
| 53 | if (error) sb_string_free(error); | |
| 54 | if (!message) return; | |
| 55 | jclass type = (*env)->FindClass(env, "java/lang/IllegalStateException"); | |
| 56 | jmethodID make = (*env)->GetMethodID(env, type, "<init>", "(Ljava/lang/String;)V"); | |
| 57 | jthrowable exception = (*env)->NewObject(env, type, make, message); | |
| 58 | if (exception) (*env)->Throw(env, exception); | |
| 59 | (*env)->DeleteLocalRef(env, type); | |
| 60 | (*env)->DeleteLocalRef(env, message); | |
| 61 | } | |
| 62 | ||
| 63 | // A Java string as NUL-terminated UTF-8 to free, or null, throwing, if it holds a NUL or an | |
| 64 | // unpaired surrogate. | |
| 65 | static char *utf8(JNIEnv *env, jstring text) { | |
| 66 | jsize count = (*env)->GetStringLength(env, text); | |
| 67 | const jchar *units = (*env)->GetStringChars(env, text, NULL); | |
| 68 | if (!units) return NULL; | |
| 69 | char *result = malloc((size_t)count * 3 + 1); | |
| 70 | size_t used = 0; | |
| 71 | bool valid = result != NULL; | |
| 72 | for (jsize i = 0; valid && i < count; i++) { | |
| 73 | uint32_t c = units[i]; | |
| 74 | if (c >= 0xd800 && c <= 0xdbff && i + 1 < count && units[i + 1] >= 0xdc00 && units[i + 1] <= 0xdfff) | |
| 75 | c = 0x10000 + ((c - 0xd800) << 10) + (units[++i] - 0xdc00); | |
| 76 | else if (!c || (c >= 0xd800 && c <= 0xdfff)) { | |
| 77 | valid = false; | |
| 78 | break; | |
| 79 | } | |
| 80 | if (c < 0x80) { | |
| 81 | result[used++] = (char)c; | |
| 82 | } else if (c < 0x800) { | |
| 83 | result[used++] = (char)(0xc0 | c >> 6); | |
| 84 | result[used++] = (char)(0x80 | (c & 63)); | |
| 85 | } else if (c < 0x10000) { | |
| 86 | result[used++] = (char)(0xe0 | c >> 12); | |
| 87 | result[used++] = (char)(0x80 | (c >> 6 & 63)); | |
| 88 | result[used++] = (char)(0x80 | (c & 63)); | |
| 89 | } else { | |
| 90 | result[used++] = (char)(0xf0 | c >> 18); | |
| 91 | result[used++] = (char)(0x80 | (c >> 12 & 63)); | |
| 92 | result[used++] = (char)(0x80 | (c >> 6 & 63)); | |
| 93 | result[used++] = (char)(0x80 | (c & 63)); | |
| 94 | } | |
| 95 | } | |
| 96 | (*env)->ReleaseStringChars(env, text, units); | |
| 97 | if (valid) { | |
| 98 | result[used] = 0; | |
| 99 | return result; | |
| 100 | } | |
| 101 | free(result); | |
| 102 | fail(env, NULL, "Text holds a NUL or an unpaired surrogate"); | |
| 103 | return NULL; | |
| 104 | } | |
| 105 | ||
| 106 | // Rust's string as Java's, freeing it. | |
| 107 | static jstring output(JNIEnv *env, char *text) { | |
| 108 | if (!text) { | |
| 109 | fail(env, NULL, "Snowbound returned nothing"); | |
| 110 | return NULL; | |
| 111 | } | |
| 112 | jstring result = java_text(env, text); | |
| 113 | sb_string_free(text); | |
| 114 | return result; | |
| 115 | } | |
| 116 | ||
| 117 | // Nothing reads the section's events yet; the page shows only its own edits. | |
| 118 | static void woken(uintptr_t token) { (void)token; } | |
| 119 | ||
| 120 | JNIEXPORT void JNICALL JNI(create)(JNIEnv *env, jclass cls, jstring notebook, jstring cache, jstring date, jstring time) { | |
| 121 | (void)cls; | |
| 122 | char *n = utf8(env, notebook), *c = n ? utf8(env, cache) : NULL, *d = c ? utf8(env, date) : NULL, | |
| 123 | *t = d ? utf8(env, time) : NULL, *error = NULL; | |
| 124 | if (t && !sb_notebook_create(n, c, "", d, t, &error)) fail(env, error, "The notebook can't be created"); | |
| 125 | free(n), free(c), free(d), free(t); | |
| 126 | } | |
| 127 | ||
| 128 | JNIEXPORT jlong JNICALL JNI(library)(JNIEnv *env, jclass cls, jstring notebook, jstring cache) { | |
| 129 | (void)cls; | |
| 130 | char *n = utf8(env, notebook), *c = n ? utf8(env, cache) : NULL, *error = NULL; | |
| 131 | const Library *library = c ? sb_library_open(n, c, true, &error) : NULL; | |
| 132 | if (c && !library) fail(env, error, "The notebook can't be opened"); | |
| 133 | free(n), free(c); | |
| 134 | return (jlong)(intptr_t)library; | |
| 135 | } | |
| 136 | ||
| 137 | JNIEXPORT jstring JNICALL JNI(sections)(JNIEnv *env, jclass cls, jlong library) { | |
| 138 | (void)cls; | |
| 139 | return output(env, sb_library_sections((const Library *)(intptr_t)library)); | |
| 140 | } | |
| 141 | ||
| 142 | JNIEXPORT jlong JNICALL JNI(section)(JNIEnv *env, jclass cls, jlong library, jstring path) { | |
| 143 | (void)cls; | |
| 144 | char *p = utf8(env, path), *error = NULL; | |
| 145 | Section *section = p ? sb_section_open((const Library *)(intptr_t)library, p, "", woken, 0, &error) : NULL; | |
| 146 | if (p && !section) fail(env, error, "The section can't be opened"); | |
| 147 | free(p); | |
| 148 | return (jlong)(intptr_t)section; | |
| 149 | } | |
| 150 | ||
| 151 | JNIEXPORT jstring JNICALL JNI(pages)(JNIEnv *env, jclass cls, jlong section) { | |
| 152 | (void)cls; | |
| 153 | return output(env, sb_section_pages((const Section *)(intptr_t)section)); | |
| 154 | } | |
| 155 | ||
| 156 | JNIEXPORT jlong JNICALL JNI(view)(JNIEnv *env, jclass cls, jobject surface, jlong section, jstring page, | |
| 157 | jfloat width, jfloat height, jfloat scale) { | |
| 158 | (void)cls; | |
| 159 | char *id = utf8(env, page); | |
| 160 | ANativeWindow *window = id ? ANativeWindow_fromSurface(env, surface) : NULL; | |
| 161 | View *view = window ? sb_view_new_android(window, (const Section *)(intptr_t)section, id, width, height, scale) : NULL; | |
| 162 | if (window) ANativeWindow_release(window); | |
| 163 | if (id && !view) fail(env, NULL, "The page can't be drawn"); | |
| 164 | free(id); | |
| 165 | return (jlong)(intptr_t)view; | |
| 166 | } | |
| 167 | ||
| 168 | JNIEXPORT void JNICALL JNI(attach)(JNIEnv *env, jclass cls, jlong view, jobject surface) { | |
| 169 | (void)cls; | |
| 170 | ANativeWindow *window = ANativeWindow_fromSurface(env, surface); | |
| 171 | bool attached = window && sb_view_attach_android((View *)(intptr_t)view, window); | |
| 172 | if (window) ANativeWindow_release(window); | |
| 173 | if (!attached) fail(env, NULL, "The page can't be drawn"); | |
| 174 | } | |
| 175 | ||
| 176 | JNIEXPORT void JNICALL JNI(detach)(JNIEnv *env, jclass cls, jlong view) { | |
| 177 | (void)env, (void)cls; | |
| 178 | sb_view_detach_android((View *)(intptr_t)view); | |
| 179 | } | |
| 180 | ||
| 181 | JNIEXPORT void JNICALL JNI(resize)(JNIEnv *env, jclass cls, jlong view, jfloat width, jfloat height, jfloat scale) { | |
| 182 | (void)env, (void)cls; | |
| 183 | sb_view_resize((View *)(intptr_t)view, width, height, scale); | |
| 184 | } | |
| 185 | ||
| 186 | JNIEXPORT void JNICALL JNI(render)(JNIEnv *env, jclass cls, jlong view) { | |
| 187 | (void)env, (void)cls; | |
| 188 | sb_view_render((View *)(intptr_t)view); | |
| 189 | } | |
| 190 | ||
| 191 | JNIEXPORT jboolean JNICALL JNI(framePending)(JNIEnv *env, jclass cls, jlong view) { | |
| 192 | (void)env, (void)cls; | |
| 193 | return sb_view_frame_pending((const View *)(intptr_t)view); | |
| 194 | } | |
| 195 | ||
| 196 | JNIEXPORT void JNICALL JNI(focus)(JNIEnv *env, jclass cls, jlong view, jboolean focused) { | |
| 197 | (void)env, (void)cls; | |
| 198 | sb_view_focus((View *)(intptr_t)view, focused); | |
| 199 | } | |
| 200 | ||
| 201 | JNIEXPORT void JNICALL JNI(press)(JNIEnv *env, jclass cls, jlong view, jfloat x, jfloat y) { | |
| 202 | (void)env, (void)cls; | |
| 203 | sb_view_press((View *)(intptr_t)view, x, y); | |
| 204 | } | |
| 205 | ||
| 206 | JNIEXPORT void JNICALL JNI(drag)(JNIEnv *env, jclass cls, jlong view, jfloat x, jfloat y) { | |
| 207 | (void)env, (void)cls; | |
| 208 | sb_view_drag((View *)(intptr_t)view, x, y); | |
| 209 | } | |
| 210 | ||
| 211 | JNIEXPORT void JNICALL JNI(release)(JNIEnv *env, jclass cls, jlong view) { | |
| 212 | (void)env, (void)cls; | |
| 213 | sb_view_release((View *)(intptr_t)view); | |
| 214 | } |
apps/android/res/values/themes.xml created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | <resources> | |
| 2 | <style name="Page" parent="@android:style/Theme.Material.Light.NoActionBar"> | |
| 3 | <item name="android:windowLightStatusBar">true</item> | |
| 4 | <item name="android:windowLightNavigationBar">true</item> | |
| 5 | </style> | |
| 6 | </resources> |
apps/android/src/net/paperclover/snowbound/Editor.java created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | package net.paperclover.snowbound; | |
| 2 | ||
| 3 | import android.content.Context; | |
| 4 | import android.os.Handler; | |
| 5 | import android.os.Looper; | |
| 6 | import android.util.Log; | |
| 7 | import android.view.Surface; | |
| 8 | import java.io.File; | |
| 9 | import java.text.DateFormat; | |
| 10 | import java.util.Date; | |
| 11 | import org.json.JSONArray; | |
| 12 | import org.json.JSONException; | |
| 13 | ||
| 14 | /** The page being edited, which outlives each activity showing it; touched only on the main thread. */ | |
| 15 | final class Editor { | |
| 16 | static { | |
| 17 | System.loadLibrary("snowbound"); | |
| 18 | } | |
| 19 | ||
| 20 | static long section, view; | |
| 21 | static String page; | |
| 22 | /** The view to attach once the page is open. */ | |
| 23 | static PageView shown; | |
| 24 | private static boolean opening; | |
| 25 | ||
| 26 | /** Opens the first page of the notebook in app storage, creating the notebook the first time. */ | |
| 27 | static void open(Context context) { | |
| 28 | if (opening) return; | |
| 29 | opening = true; | |
| 30 | File notebooks = new File(context.getFilesDir(), "Notebooks"); | |
| 31 | File notebook = new File(notebooks, "Snowbound"); | |
| 32 | File cache = new File(context.getNoBackupFilesDir(), "Cache"); | |
| 33 | Date now = new Date(); | |
| 34 | String date = DateFormat.getDateInstance(DateFormat.FULL).format(now); | |
| 35 | String time = DateFormat.getTimeInstance(DateFormat.SHORT).format(now); | |
| 36 | Handler main = new Handler(Looper.getMainLooper()); | |
| 37 | new Thread(() -> { | |
| 38 | if (!notebook.exists()) { | |
| 39 | notebooks.mkdirs(); | |
| 40 | create(notebook.getPath(), cache.getPath(), date, time); | |
| 41 | } | |
| 42 | long library = library(notebook.getPath(), cache.getPath()); | |
| 43 | 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(); | |
| 50 | }); | |
| 51 | } catch (JSONException error) { | |
| 52 | throw new IllegalStateException(error); | |
| 53 | } | |
| 54 | }, "Open notebook").start(); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Draws the page into `surface`. The editor is made once and keeps its selection and Undo | |
| 59 | * across surfaces; each later one starts at the last size, until SurfaceChanged resizes it. | |
| 60 | */ | |
| 61 | static void draw(Surface surface, float width, float height, float scale) { | |
| 62 | if (view != 0) { | |
| 63 | attach(view, surface); | |
| 64 | return; | |
| 65 | } | |
| 66 | view = view(surface, section, page, width, height, scale); | |
| 67 | Log.i("snowbound", "Page view created"); | |
| 68 | } | |
| 69 | ||
| 70 | static native void create(String notebook, String cache, String date, String time); | |
| 71 | static native long library(String notebook, String cache); | |
| 72 | static native String sections(long library); | |
| 73 | static native long section(long library, String path); | |
| 74 | static native String pages(long section); | |
| 75 | static native long view(Surface surface, long section, String page, float width, float height, float scale); | |
| 76 | static native void attach(long view, Surface surface); | |
| 77 | static native void detach(long view); | |
| 78 | static native void resize(long view, float width, float height, float scale); | |
| 79 | static native void render(long view); | |
| 80 | static native boolean framePending(long view); | |
| 81 | static native void focus(long view, boolean focused); | |
| 82 | static native void press(long view, float x, float y); | |
| 83 | static native void drag(long view, float x, float y); | |
| 84 | static native void release(long view); | |
| 85 | } |
apps/android/src/net/paperclover/snowbound/PageActivity.java created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | package net.paperclover.snowbound; | |
| 2 | ||
| 3 | import android.app.Activity; | |
| 4 | import android.os.Bundle; | |
| 5 | import android.widget.FrameLayout; | |
| 6 | ||
| 7 | public final class PageActivity extends Activity { | |
| 8 | private PageView page; | |
| 9 | ||
| 10 | @Override | |
| 11 | protected void onCreate(Bundle state) { | |
| 12 | super.onCreate(state); | |
| 13 | page = new PageView(this); | |
| 14 | FrameLayout frame = new FrameLayout(this); | |
| 15 | frame.setFitsSystemWindows(true); | |
| 16 | frame.addView(page); | |
| 17 | setContentView(frame); | |
| 18 | Editor.shown = page; | |
| 19 | Editor.open(this); | |
| 20 | } | |
| 21 | ||
| 22 | @Override | |
| 23 | protected void onDestroy() { | |
| 24 | if (Editor.shown == page) Editor.shown = null; | |
| 25 | super.onDestroy(); | |
| 26 | } | |
| 27 | } |
apps/android/src/net/paperclover/snowbound/PageView.java created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | package net.paperclover.snowbound; | |
| 2 | ||
| 3 | import android.content.Context; | |
| 4 | import android.graphics.Rect; | |
| 5 | import android.view.Choreographer; | |
| 6 | import android.view.MotionEvent; | |
| 7 | import android.view.SurfaceHolder; | |
| 8 | import android.view.SurfaceView; | |
| 9 | ||
| 10 | /** Draws the editor's page into its surface, a frame whenever the page changes. */ | |
| 11 | final class PageView extends SurfaceView implements SurfaceHolder.Callback, Choreographer.FrameCallback { | |
| 12 | private boolean attached, dirty; | |
| 13 | ||
| 14 | PageView(Context context) { | |
| 15 | super(context); | |
| 16 | setFocusableInTouchMode(true); | |
| 17 | getHolder().addCallback(this); | |
| 18 | } | |
| 19 | ||
| 20 | private float scale() { | |
| 21 | return getResources().getDisplayMetrics().density; | |
| 22 | } | |
| 23 | ||
| 24 | void attach() { | |
| 25 | if (attached || Editor.page == null || !getHolder().getSurface().isValid() || getWidth() == 0) return; | |
| 26 | float scale = scale(); | |
| 27 | Editor.draw(getHolder().getSurface(), getWidth() / scale, getHeight() / scale, scale); | |
| 28 | attached = true; | |
| 29 | Editor.focus(Editor.view, hasFocus() && hasWindowFocus()); | |
| 30 | dirty = true; | |
| 31 | Choreographer.getInstance().postFrameCallback(this); | |
| 32 | } | |
| 33 | ||
| 34 | @Override | |
| 35 | public void surfaceCreated(SurfaceHolder holder) { | |
| 36 | attach(); | |
| 37 | } | |
| 38 | ||
| 39 | @Override | |
| 40 | public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { | |
| 41 | attach(); | |
| 42 | if (!attached) return; | |
| 43 | float scale = scale(); | |
| 44 | Editor.resize(Editor.view, width / scale, height / scale, scale); | |
| 45 | dirty = true; | |
| 46 | } | |
| 47 | ||
| 48 | /** Android destroys the surface once this returns, so the editor lets go of it first. */ | |
| 49 | @Override | |
| 50 | public void surfaceDestroyed(SurfaceHolder holder) { | |
| 51 | if (!attached) return; | |
| 52 | Choreographer.getInstance().removeFrameCallback(this); | |
| 53 | Editor.detach(Editor.view); | |
| 54 | attached = false; | |
| 55 | } | |
| 56 | ||
| 57 | @Override | |
| 58 | public void doFrame(long nanos) { | |
| 59 | Choreographer.getInstance().postFrameCallback(this); | |
| 60 | if (Editor.framePending(Editor.view)) dirty = true; | |
| 61 | if (!dirty) return; | |
| 62 | dirty = false; | |
| 63 | Editor.render(Editor.view); | |
| 64 | } | |
| 65 | ||
| 66 | @Override | |
| 67 | public boolean onTouchEvent(MotionEvent event) { | |
| 68 | if (!attached) return false; | |
| 69 | float scale = scale(), x = event.getX() / scale, y = event.getY() / scale; | |
| 70 | switch (event.getActionMasked()) { | |
| 71 | case MotionEvent.ACTION_DOWN: | |
| 72 | requestFocus(); | |
| 73 | Editor.press(Editor.view, x, y); | |
| 74 | break; | |
| 75 | case MotionEvent.ACTION_MOVE: | |
| 76 | Editor.drag(Editor.view, x, y); | |
| 77 | break; | |
| 78 | case MotionEvent.ACTION_UP: | |
| 79 | case MotionEvent.ACTION_CANCEL: | |
| 80 | Editor.release(Editor.view); | |
| 81 | break; | |
| 82 | default: | |
| 83 | return false; | |
| 84 | } | |
| 85 | dirty = true; | |
| 86 | return true; | |
| 87 | } | |
| 88 | ||
| 89 | @Override | |
| 90 | protected void onFocusChanged(boolean focused, int direction, Rect previous) { | |
| 91 | super.onFocusChanged(focused, direction, previous); | |
| 92 | focusChanged(); | |
| 93 | } | |
| 94 | ||
| 95 | @Override | |
| 96 | public void onWindowFocusChanged(boolean focused) { | |
| 97 | super.onWindowFocusChanged(focused); | |
| 98 | focusChanged(); | |
| 99 | } | |
| 100 | ||
| 101 | private void focusChanged() { | |
| 102 | if (!attached) return; | |
| 103 | Editor.focus(Editor.view, hasFocus() && hasWindowFocus()); | |
| 104 | dirty = true; | |
| 105 | } | |
| 106 | } |