commit 891fadca851549282d53cff50109d3cb03ac5176 Author: Hisham Aburaqibah Date: Thu Sep 3 10:44:30 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2a87e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.apk +*.apk.* +*keystore +android/lib/lib/arm64-v8a/libmain.so diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..f15b583 --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/res/drawable-v24/ic_launcher_background.xml b/android/res/drawable-v24/ic_launcher_background.xml new file mode 100644 index 0000000..bae8060 --- /dev/null +++ b/android/res/drawable-v24/ic_launcher_background.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/android/res/drawable-v24/ic_launcher_foreground.xml b/android/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..7cb66ad --- /dev/null +++ b/android/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/res/mipmap-anydpi-v26/ic_launcher.xml b/android/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/android/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/android/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/res/mipmap-hdpi/ic_launcher.png b/android/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..cb0e65c Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/res/mipmap-hdpi/ic_launcher_round.png b/android/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..5ffd225 Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ea22124 --- /dev/null +++ b/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Create a debug signing key to sign our application. +if [ ! -f .keystore ]; then + keytool -genkey -dname "CN=Android Debug, O=Android, C=US" -keystore .keystore -alias androiddebugkey -storepass android -keypass android -keyalg RSA -validity 30000 + if [ $? -ne 0 ]; then exit 1; fi +fi + +# Build the shared lib +if [[ $1 == "release" ]]; then + odin build . -target:linux_arm64 -subtarget:android -build-mode:shared -o:speed -define:RELEASE_BUILD=true +else + odin build . -target:linux_arm64 -subtarget:android -build-mode:shared -debug +fi + +name=$(basename "$PWD") + +# Create the proper lib directories and move the shared lib to them +# Rename the shared lib to match what we specified in the AndroidManifest.xml file +# for the android.app.lib_name metadata value +mkdir -p android/lib/lib/arm64-v8a +mv $name.so android/lib/lib/arm64-v8a/libmain.so || exit 1 + +# Bundle the directory into an apk +odin bundle android android -android-keystore:.keystore -android-keystore-password:"android" + +cleanup() { + rm test.apk-build +} + +cleanup + diff --git a/jni/array.odin b/jni/array.odin new file mode 100644 index 0000000..3d8ddde --- /dev/null +++ b/jni/array.odin @@ -0,0 +1,256 @@ +// Array marshalling between Odin slices and Java arrays. +// +// GENERATED by jnigen. Do not edit. +// +// Every to_* returns a LOCAL ref the caller must free; every from_* allocates +// with the given allocator and the caller owns the result. +package jni + +import "shared:android" + +to_bool_array :: proc(e: Env, vals: []bool) -> android.jbooleanArray { + arr := e^^.NewBooleanArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetBooleanArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jboolean)raw_data(vals)) + return arr +} + +from_bool_array :: proc(e: Env, arr: android.jbooleanArray, allocator := context.allocator) -> []bool { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]bool, n, allocator) + e^^.GetBooleanArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jboolean)raw_data(out)) + return out +} + +to_i8_array :: proc(e: Env, vals: []i8) -> android.jbyteArray { + arr := e^^.NewByteArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetByteArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jbyte)raw_data(vals)) + return arr +} + +from_i8_array :: proc(e: Env, arr: android.jbyteArray, allocator := context.allocator) -> []i8 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]i8, n, allocator) + e^^.GetByteArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jbyte)raw_data(out)) + return out +} + +to_u16_array :: proc(e: Env, vals: []u16) -> android.jcharArray { + arr := e^^.NewCharArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetCharArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jchar)raw_data(vals)) + return arr +} + +from_u16_array :: proc(e: Env, arr: android.jcharArray, allocator := context.allocator) -> []u16 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]u16, n, allocator) + e^^.GetCharArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jchar)raw_data(out)) + return out +} + +to_i16_array :: proc(e: Env, vals: []i16) -> android.jshortArray { + arr := e^^.NewShortArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetShortArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jshort)raw_data(vals)) + return arr +} + +from_i16_array :: proc(e: Env, arr: android.jshortArray, allocator := context.allocator) -> []i16 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]i16, n, allocator) + e^^.GetShortArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jshort)raw_data(out)) + return out +} + +to_i32_array :: proc(e: Env, vals: []i32) -> android.jintArray { + arr := e^^.NewIntArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetIntArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jint)raw_data(vals)) + return arr +} + +from_i32_array :: proc(e: Env, arr: android.jintArray, allocator := context.allocator) -> []i32 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]i32, n, allocator) + e^^.GetIntArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jint)raw_data(out)) + return out +} + +to_i64_array :: proc(e: Env, vals: []i64) -> android.jlongArray { + arr := e^^.NewLongArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetLongArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jlong)raw_data(vals)) + return arr +} + +from_i64_array :: proc(e: Env, arr: android.jlongArray, allocator := context.allocator) -> []i64 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]i64, n, allocator) + e^^.GetLongArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jlong)raw_data(out)) + return out +} + +to_f32_array :: proc(e: Env, vals: []f32) -> android.jfloatArray { + arr := e^^.NewFloatArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetFloatArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jfloat)raw_data(vals)) + return arr +} + +from_f32_array :: proc(e: Env, arr: android.jfloatArray, allocator := context.allocator) -> []f32 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]f32, n, allocator) + e^^.GetFloatArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jfloat)raw_data(out)) + return out +} + +to_f64_array :: proc(e: Env, vals: []f64) -> android.jdoubleArray { + arr := e^^.NewDoubleArray(e, android.jsize(len(vals))) + if arr == nil || len(vals) == 0 { + return arr + } + // Identical layout, so the slice can be handed over without a copy. + e^^.SetDoubleArrayRegion(e, arr, 0, android.jsize(len(vals)), cast([^]android.jdouble)raw_data(vals)) + return arr +} + +from_f64_array :: proc(e: Env, arr: android.jdoubleArray, allocator := context.allocator) -> []f64 { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]f64, n, allocator) + e^^.GetDoubleArrayRegion(e, arr, 0, android.jsize(n), cast([^]android.jdouble)raw_data(out)) + return out +} + +// Object arrays. `elem_class` is the array's component type. +to_object_array :: proc(e: Env, vals: []android.jobject, elem_class: android.jclass) -> android.jobjectArray { + arr := e^^.NewObjectArray(e, android.jsize(len(vals)), elem_class, nil) + if arr == nil { + return nil + } + for v, i in vals { + e^^.SetObjectArrayElement(e, arr, android.jsize(i), v) + } + return arr +} + +from_object_array :: proc(e: Env, arr: android.jobjectArray, allocator := context.allocator) -> []android.jobject { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]android.jobject, n, allocator) + for i in 0 ..< n { + out[i] = e^^.GetObjectArrayElement(e, arr, android.jsize(i)) + } + return out +} + +// String arrays need per-element conversion in both directions. +to_string_array :: proc(e: Env, vals: []string) -> android.jobjectArray { + cls := class_ref(e, "java/lang/String") + if cls == nil { + return nil + } + arr := e^^.NewObjectArray(e, android.jsize(len(vals)), cls, nil) + if arr == nil { + return nil + } + for v, i in vals { + s := to_jstring(e, v) + e^^.SetObjectArrayElement(e, arr, android.jsize(i), android.jobject(s)) + free_local(e, android.jobject(s)) + } + return arr +} + +from_string_array :: proc(e: Env, arr: android.jobjectArray, allocator := context.allocator) -> []string { + if arr == nil { + return nil + } + n := int(e^^.GetArrayLength(e, android.jarray(arr))) + if n == 0 { + return nil + } + out := make([]string, n, allocator) + for i in 0 ..< n { + el := e^^.GetObjectArrayElement(e, arr, android.jsize(i)) + out[i] = from_jstring(e, android.jstring(el), allocator) + free_local(e, el) + } + return out +} + diff --git a/jni/jni.odin b/jni/jni.odin new file mode 100644 index 0000000..cd2090a --- /dev/null +++ b/jni/jni.odin @@ -0,0 +1,178 @@ +// Runtime support for generated JNI bindings: VM/env acquisition, exception +// handling, cached class lookup, and string marshalling. +// +package jni + +import "core:strings" +import "shared:android" + +Env :: ^android.JNIEnv + +@(private) +vm: ^android.JavaVM + +// Records the process JavaVM. Call once from android_main before any binding. +init :: proc(app: ^android.android_app) -> bool { + if app == nil || app.activity == nil { + return false + } + vm = app.activity.vm + return vm != nil +} + +// The Activity instance, usable anywhere a Context is expected. +activity :: proc(app: ^android.android_app) -> android.jobject { + if app == nil || app.activity == nil { + return nil + } + return app.activity.clazz +} + +// The engine's loop runs on a glue-spawned thread, not the one holding +// activity.env, so the env has to come from the VM and the thread attached on +// first use. The thread lives as long as the app, so it is never detached. +@(thread_local) +tls_env: Env + +env :: proc() -> Env { + if tls_env != nil { + return tls_env + } + if vm == nil { + return nil + } + + e: Env + if vm^^.GetEnv(vm, cast(^rawptr)&e, android.JNI_VERSION_1_6) == android.JNI_EDETACHED { + if vm^^.AttachCurrentThread(vm, &e, nil) != android.JNI_OK { + return nil + } + } + tls_env = e + return e +} + +// +// Exceptions +// + +// Reports whether a Java exception is pending, printing and clearing it if so. +// Left pending, an exception makes every later JNI call on this thread fail. +failed :: proc(e: Env) -> bool { + if e == nil { + return true + } + if e^^.ExceptionCheck(e) == 0 { + return false + } + e^^.ExceptionDescribe(e) + e^^.ExceptionClear(e) + return true +} + +// +// Lookup +// + +// Resolves a class and pins it with a global ref. Generated code caches the +// result in a static, so a class is only ever looked up once. A local ref would +// go stale the moment the native frame returns. +class_ref :: proc(e: Env, name: cstring) -> android.jclass { + local := e^^.FindClass(e, name) + if local == nil || failed(e) { + return nil + } + global := e^^.NewGlobalRef(e, android.jobject(local)) + e^^.DeleteLocalRef(e, android.jobject(local)) + return android.jclass(global) +} + +method_id :: proc(e: Env, cls: android.jclass, name, sig: cstring) -> android.jmethodID { + id := e^^.GetMethodID(e, cls, name, sig) + if failed(e) { + return nil + } + return id +} + +static_method_id :: proc(e: Env, cls: android.jclass, name, sig: cstring) -> android.jmethodID { + id := e^^.GetStaticMethodID(e, cls, name, sig) + if failed(e) { + return nil + } + return id +} + +field_id :: proc(e: Env, cls: android.jclass, name, sig: cstring) -> android.jfieldID { + id := e^^.GetFieldID(e, cls, name, sig) + if failed(e) { + return nil + } + return id +} + +static_field_id :: proc(e: Env, cls: android.jclass, name, sig: cstring) -> android.jfieldID { + id := e^^.GetStaticFieldID(e, cls, name, sig) + if failed(e) { + return nil + } + return id +} + +free_local :: proc(e: Env, obj: android.jobject) { + if obj != nil { + e^^.DeleteLocalRef(e, obj) + } +} + +// +// Strings +// + +to_jstring :: proc(e: Env, s: string) -> android.jstring { + c := strings.clone_to_cstring(s, context.temp_allocator) + return e^^.NewStringUTF(e, c) +} + +from_jstring :: proc(e: Env, s: android.jstring, allocator := context.allocator) -> string { + if s == nil { + return "" + } + utf := e^^.GetStringUTFChars(e, s, nil) + if utf == nil { + return "" + } + out := strings.clone(string(utf), allocator) + e^^.ReleaseStringUTFChars(e, s, utf) + return out +} + +// +// Local reference frames +// +// Every object returned by a binding is a LOCAL ref, and locals are only +// reclaimed when control returns to Java -- which never happens on a native +// thread that owns its own loop. A sequence of object-returning calls made +// per-frame will therefore exhaust the local ref table (512 entries) and abort +// the process. Scope such a sequence in a frame: +// +// if !jni.push_frame(env) { return false } +// defer jni.pop_frame(env) +// +// Anything still needed after pop_frame must be a primitive, a copied string, +// or passed to pop_frame as `keep` to be promoted into the outer frame. +// + +push_frame :: proc(e: Env, capacity: i32 = 16) -> bool { + if e == nil { + return false + } + return e^^.PushLocalFrame(e, android.jint(capacity)) == android.JNI_OK +} + +pop_frame :: proc(e: Env, keep: android.jobject = nil) -> android.jobject { + if e == nil { + return nil + } + return e^^.PopLocalFrame(e, keep) +} diff --git a/jni_generated.odin b/jni_generated.odin new file mode 100644 index 0000000..91067f6 --- /dev/null +++ b/jni_generated.odin @@ -0,0 +1,901 @@ +// GENERATED by jnigen from the JNI spec. Do not edit. +// +// Every binding returns `ok` -- false means the class, method or field +// failed to resolve, or the call raised a Java exception (already printed +// to logcat and cleared). Class and method IDs resolve once and are cached. +package main + +import "shared:android" +import "jni" + +// +// Object types +// + +// android/content/Context +Context :: distinct android.jobject + +// android/widget/Toast +Toast :: distinct android.jobject + +// java/lang/StringBuilder +StringBuilder :: distinct android.jobject + +// android/os/Looper +Looper :: distinct android.jobject + +// android/app/Activity +Activity :: distinct android.jobject + +// android/view/Window +Window :: distinct android.jobject + +// android/view/View +View :: distinct android.jobject + +// android/view/WindowInsets +WindowInsets :: distinct android.jobject + +// android/graphics/Insets +Insets :: distinct android.jobject + +// java/lang/System.currentTimeMillis()J +system_current_time_millis :: proc() -> (result: i64, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "java/lang/System") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "currentTimeMillis", "()J") + if mid == nil { + return + } + } + + _ret := env^^.CallStaticLongMethodA(env, cls, mid, nil) + if jni.failed(env) { + return + } + result = i64(_ret) + ok = true + return +} + +// android/widget/Toast.makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast; +toast_make_text :: proc(ctx: Context, text: string, duration: i32) -> (result: Toast, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/widget/Toast") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "makeText", "(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;") + if mid == nil { + return + } + } + + args := [3]android.jvalue{} + args[0].l = android.jobject(ctx) + _a1 := jni.to_jstring(env, text) + defer jni.free_local(env, android.jobject(_a1)) + args[1].l = android.jobject(_a1) + args[2].i = android.jint(duration) + + _ret := env^^.CallStaticObjectMethodA(env, cls, mid, raw_data(args[:])) + if jni.failed(env) { + return + } + result = Toast(_ret) + ok = true + return +} + +// android/os/Looper.prepare()V +looper_prepare :: proc() -> (ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/os/Looper") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "prepare", "()V") + if mid == nil { + return + } + } + + env^^.CallStaticVoidMethodA(env, cls, mid, nil) + if jni.failed(env) { + return + } + ok = true + return +} + +// android/os/Looper.myLooper()Landroid/os/Looper; +looper_my_looper :: proc() -> (result: Looper, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/os/Looper") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "myLooper", "()Landroid/os/Looper;") + if mid == nil { + return + } + } + + _ret := env^^.CallStaticObjectMethodA(env, cls, mid, nil) + if jni.failed(env) { + return + } + result = Looper(_ret) + ok = true + return +} + +// android/widget/Toast.show()V +toast_show :: proc(self: Toast) -> (ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/widget/Toast") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "show", "()V") + if mid == nil { + return + } + } + + env^^.CallVoidMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + ok = true + return +} + +// java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder; +sb_append :: proc(self: StringBuilder, s: string) -> (result: StringBuilder, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "java/lang/StringBuilder") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;") + if mid == nil { + return + } + } + + args := [1]android.jvalue{} + _a0 := jni.to_jstring(env, s) + defer jni.free_local(env, android.jobject(_a0)) + args[0].l = android.jobject(_a0) + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, raw_data(args[:])) + if jni.failed(env) { + return + } + result = StringBuilder(_ret) + ok = true + return +} + +// java/lang/StringBuilder.toString()Ljava/lang/String; +sb_to_string :: proc(self: StringBuilder, allocator := context.allocator) -> (result: string, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "java/lang/StringBuilder") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "toString", "()Ljava/lang/String;") + if mid == nil { + return + } + } + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + defer jni.free_local(env, _ret) + result = jni.from_jstring(env, android.jstring(_ret), allocator) + ok = true + return +} + +// new java/lang/StringBuilder()V +sb_new :: proc() -> (result: StringBuilder, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "java/lang/StringBuilder") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "", "()V") + if mid == nil { + return + } + } + + _ret := env^^.NewObjectA(env, cls, mid, nil) + if jni.failed(env) { + return + } + result = StringBuilder(_ret) + ok = true + return +} + +// android/os/Build$VERSION.SDK_INT : I (get) +build_sdk_int :: proc() -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/os/Build$VERSION") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.static_field_id(env, cls, "SDK_INT", "I") + if fid == nil { + return + } + } + + _ret := env^^.GetStaticIntField(env, cls, fid) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/os/Build.MODEL : Ljava/lang/String; (get) +build_model :: proc(allocator := context.allocator) -> (result: string, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/os/Build") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.static_field_id(env, cls, "MODEL", "Ljava/lang/String;") + if fid == nil { + return + } + } + + _ret := env^^.GetStaticObjectField(env, cls, fid) + if jni.failed(env) { + return + } + defer jni.free_local(env, _ret) + result = jni.from_jstring(env, android.jstring(_ret), allocator) + ok = true + return +} + +// java/util/Arrays.sort([I)V +arrays_sort :: proc(vals: []i32) -> (ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "java/util/Arrays") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "sort", "([I)V") + if mid == nil { + return + } + } + + args := [1]android.jvalue{} + _a0 := jni.to_i32_array(env, vals) + defer jni.free_local(env, android.jobject(_a0)) + args[0].l = android.jobject(_a0) + + env^^.CallStaticVoidMethodA(env, cls, mid, raw_data(args[:])) + if jni.failed(env) { + return + } + ok = true + return +} + +// android/os/Build.SUPPORTED_ABIS : [Ljava/lang/String; (get) +build_supported_abis :: proc(allocator := context.allocator) -> (result: []string, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/os/Build") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.static_field_id(env, cls, "SUPPORTED_ABIS", "[Ljava/lang/String;") + if fid == nil { + return + } + } + + _ret := env^^.GetStaticObjectField(env, cls, fid) + if jni.failed(env) { + return + } + defer jni.free_local(env, _ret) + result = jni.from_string_array(env, android.jobjectArray(_ret), allocator) + ok = true + return +} + +// android/app/Activity.getWindow()Landroid/view/Window; +activity_get_window :: proc(self: Activity) -> (result: Window, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/app/Activity") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getWindow", "()Landroid/view/Window;") + if mid == nil { + return + } + } + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = Window(_ret) + ok = true + return +} + +// android/view/Window.getDecorView()Landroid/view/View; +window_get_decor_view :: proc(self: Window) -> (result: View, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/Window") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getDecorView", "()Landroid/view/View;") + if mid == nil { + return + } + } + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = View(_ret) + ok = true + return +} + +// android/view/View.getRootWindowInsets()Landroid/view/WindowInsets; +view_get_root_window_insets :: proc(self: View) -> (result: WindowInsets, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/View") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getRootWindowInsets", "()Landroid/view/WindowInsets;") + if mid == nil { + return + } + } + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = WindowInsets(_ret) + ok = true + return +} + +// android/view/WindowInsets$Type.systemBars()I +insets_type_system_bars :: proc() -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets$Type") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "systemBars", "()I") + if mid == nil { + return + } + } + + _ret := env^^.CallStaticIntMethodA(env, cls, mid, nil) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/view/WindowInsets$Type.displayCutout()I +insets_type_display_cutout :: proc() -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets$Type") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.static_method_id(env, cls, "displayCutout", "()I") + if mid == nil { + return + } + } + + _ret := env^^.CallStaticIntMethodA(env, cls, mid, nil) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/view/WindowInsets.getInsetsIgnoringVisibility(I)Landroid/graphics/Insets; +window_insets_get_ignoring_visibility :: proc(self: WindowInsets, type_mask: i32) -> (result: Insets, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getInsetsIgnoringVisibility", "(I)Landroid/graphics/Insets;") + if mid == nil { + return + } + } + + args := [1]android.jvalue{} + args[0].i = android.jint(type_mask) + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, raw_data(args[:])) + if jni.failed(env) { + return + } + result = Insets(_ret) + ok = true + return +} + +// android/view/WindowInsets.getInsets(I)Landroid/graphics/Insets; +window_insets_get :: proc(self: WindowInsets, type_mask: i32) -> (result: Insets, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getInsets", "(I)Landroid/graphics/Insets;") + if mid == nil { + return + } + } + + args := [1]android.jvalue{} + args[0].i = android.jint(type_mask) + + _ret := env^^.CallObjectMethodA(env, android.jobject(self), mid, raw_data(args[:])) + if jni.failed(env) { + return + } + result = Insets(_ret) + ok = true + return +} + +// android/graphics/Insets.left : I (get) +insets_left :: proc(self: Insets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/graphics/Insets") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.field_id(env, cls, "left", "I") + if fid == nil { + return + } + } + + _ret := env^^.GetIntField(env, android.jobject(self), fid) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/graphics/Insets.top : I (get) +insets_top :: proc(self: Insets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/graphics/Insets") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.field_id(env, cls, "top", "I") + if fid == nil { + return + } + } + + _ret := env^^.GetIntField(env, android.jobject(self), fid) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/graphics/Insets.right : I (get) +insets_right :: proc(self: Insets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/graphics/Insets") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.field_id(env, cls, "right", "I") + if fid == nil { + return + } + } + + _ret := env^^.GetIntField(env, android.jobject(self), fid) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/graphics/Insets.bottom : I (get) +insets_bottom :: proc(self: Insets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/graphics/Insets") + if cls == nil { + return + } + } + @(static) fid: android.jfieldID + if fid == nil { + fid = jni.field_id(env, cls, "bottom", "I") + if fid == nil { + return + } + } + + _ret := env^^.GetIntField(env, android.jobject(self), fid) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/view/WindowInsets.getSystemWindowInsetLeft()I +window_insets_sys_left :: proc(self: WindowInsets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getSystemWindowInsetLeft", "()I") + if mid == nil { + return + } + } + + _ret := env^^.CallIntMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/view/WindowInsets.getSystemWindowInsetTop()I +window_insets_sys_top :: proc(self: WindowInsets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getSystemWindowInsetTop", "()I") + if mid == nil { + return + } + } + + _ret := env^^.CallIntMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/view/WindowInsets.getSystemWindowInsetRight()I +window_insets_sys_right :: proc(self: WindowInsets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getSystemWindowInsetRight", "()I") + if mid == nil { + return + } + } + + _ret := env^^.CallIntMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + +// android/view/WindowInsets.getSystemWindowInsetBottom()I +window_insets_sys_bottom :: proc(self: WindowInsets) -> (result: i32, ok: bool) { + env := jni.env() + if env == nil { + return + } + + @(static) cls: android.jclass + if cls == nil { + cls = jni.class_ref(env, "android/view/WindowInsets") + if cls == nil { + return + } + } + @(static) mid: android.jmethodID + if mid == nil { + mid = jni.method_id(env, cls, "getSystemWindowInsetBottom", "()I") + if mid == nil { + return + } + } + + _ret := env^^.CallIntMethodA(env, android.jobject(self), mid, nil) + if jni.failed(env) { + return + } + result = i32(_ret) + ok = true + return +} + diff --git a/jni_spec.odin b/jni_spec.odin new file mode 100644 index 0000000..10da703 --- /dev/null +++ b/jni_spec.odin @@ -0,0 +1,175 @@ +#+build ignore +// JNI binding spec. Never compiled -- jnigen parses this file and emits +// jni_generated.odin. Regenerate with: +// +// odin run jnigen -- jni_spec.odin jni_generated.odin main +// +// Attributes: +// java_class="a/b/C" required; the class, or the type's Java class +// java_name="foo" Java-side name (defaults to the Odin proc name) +// java_static static method or field +// java_ctor constructor; must return a registered object type +// java_field field access; 1 result = getter, 0 results = setter +// java_sig="..." override the computed signature +package main + +import "shared:android" + +// +// Object types. Each becomes `Name :: distinct android.jobject` and lets +// signatures resolve the Odin type to its Java class. +// + +@(java_class = "android/content/Context") +Context :: distinct android.jobject + +@(java_class = "android/widget/Toast") +Toast :: distinct android.jobject + +@(java_class = "java/lang/StringBuilder") +StringBuilder :: distinct android.jobject + +@(java_class = "android/os/Looper") +Looper :: distinct android.jobject + +@(java_class = "android/app/Activity") +Activity :: distinct android.jobject + +@(java_class = "android/view/Window") +Window :: distinct android.jobject + +@(java_class = "android/view/View") +View :: distinct android.jobject + +@(java_class = "android/view/WindowInsets") +WindowInsets :: distinct android.jobject + +@(java_class = "android/graphics/Insets") +Insets :: distinct android.jobject + +// +// Static methods +// + +@(java_class = "java/lang/System", java_static, java_name = "currentTimeMillis") +system_current_time_millis :: proc() -> i64 + +// makeText takes a CharSequence, not a String, so the computed signature would +// not match. java_sig pins the real one while `string` stays in the Odin API. +@( + java_class = "android/widget/Toast", + java_static, + java_name = "makeText", + java_sig = "(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;", +) +toast_make_text :: proc(ctx: Context, text: string, duration: i32) -> Toast + +// Toast's constructor reads Looper.myLooper() and throws if the thread has +// none. The engine loop runs on a glue-spawned thread that only ever got a +// native ALooper, so the Java-side looper has to be prepared once by hand. +@(java_class = "android/os/Looper", java_static, java_name = "prepare") +looper_prepare :: proc() + +@(java_class = "android/os/Looper", java_static, java_name = "myLooper") +looper_my_looper :: proc() -> Looper + +// +// Instance methods -- the first parameter is the receiver. +// + +@(java_class = "android/widget/Toast", java_name = "show") +toast_show :: proc(self: Toast) + +@(java_class = "java/lang/StringBuilder", java_name = "append", java_sig = "(Ljava/lang/String;)Ljava/lang/StringBuilder;") +sb_append :: proc(self: StringBuilder, s: string) -> StringBuilder + +@(java_class = "java/lang/StringBuilder", java_name = "toString") +sb_to_string :: proc(self: StringBuilder) -> string + +// +// Constructor +// + +@(java_class = "java/lang/StringBuilder", java_ctor) +sb_new :: proc() -> StringBuilder + +// +// Fields +// + +@(java_class = "android/os/Build$VERSION", java_static, java_field, java_name = "SDK_INT") +build_sdk_int :: proc() -> i32 + +@(java_class = "android/os/Build", java_static, java_field, java_name = "MODEL") +build_model :: proc() -> string + +// +// Arrays +// + +@(java_class = "java/util/Arrays", java_static, java_name = "sort", java_sig = "([I)V") +arrays_sort :: proc(vals: []i32) + +@(java_class = "android/os/Build", java_static, java_field, java_name = "SUPPORTED_ABIS") +build_supported_abis :: proc() -> []string + +// +// Window insets: Activity -> Window -> decor View -> WindowInsets -> Insets. +// +// getRootWindowInsets returns null until the decor view is attached, so the +// first frames after launch will come back nil -- retry rather than treating +// it as an error. +// + +@(java_class = "android/app/Activity", java_name = "getWindow") +activity_get_window :: proc(self: Activity) -> Window + +@(java_class = "android/view/Window", java_name = "getDecorView") +window_get_decor_view :: proc(self: Window) -> View + +@(java_class = "android/view/View", java_name = "getRootWindowInsets") +view_get_root_window_insets :: proc(self: View) -> WindowInsets + +// WindowInsets.Type is a nested class, hence the $ separator. +@(java_class = "android/view/WindowInsets$Type", java_static, java_name = "systemBars") +insets_type_system_bars :: proc() -> i32 + +// A fullscreen theme hides the system bars, so systemBars() alone reports 0. +// The cutout is what still needs avoiding -- the two are OR'd into one mask. +@(java_class = "android/view/WindowInsets$Type", java_static, java_name = "displayCutout") +insets_type_display_cutout :: proc() -> i32 + +// Reports bar sizes even while hidden, which is what a fullscreen app wants +// when it draws its own chrome where the bars would otherwise sit. +@(java_class = "android/view/WindowInsets", java_name = "getInsetsIgnoringVisibility") +window_insets_get_ignoring_visibility :: proc(self: WindowInsets, type_mask: i32) -> Insets + +// API 30+. The computed signature (I)Landroid/graphics/Insets; is already right. +@(java_class = "android/view/WindowInsets", java_name = "getInsets") +window_insets_get :: proc(self: WindowInsets, type_mask: i32) -> Insets + +// Insets exposes its edges as public final int fields. +@(java_class = "android/graphics/Insets", java_field, java_name = "left") +insets_left :: proc(self: Insets) -> i32 + +@(java_class = "android/graphics/Insets", java_field, java_name = "top") +insets_top :: proc(self: Insets) -> i32 + +@(java_class = "android/graphics/Insets", java_field, java_name = "right") +insets_right :: proc(self: Insets) -> i32 + +@(java_class = "android/graphics/Insets", java_field, java_name = "bottom") +insets_bottom :: proc(self: Insets) -> i32 + +// Deprecated in API 30 but present since 20 -- the fallback below minSdk 30. +@(java_class = "android/view/WindowInsets", java_name = "getSystemWindowInsetLeft") +window_insets_sys_left :: proc(self: WindowInsets) -> i32 + +@(java_class = "android/view/WindowInsets", java_name = "getSystemWindowInsetTop") +window_insets_sys_top :: proc(self: WindowInsets) -> i32 + +@(java_class = "android/view/WindowInsets", java_name = "getSystemWindowInsetRight") +window_insets_sys_right :: proc(self: WindowInsets) -> i32 + +@(java_class = "android/view/WindowInsets", java_name = "getSystemWindowInsetBottom") +window_insets_sys_bottom :: proc(self: WindowInsets) -> i32 diff --git a/jnigen/emit.odin b/jnigen/emit.odin new file mode 100644 index 0000000..4665cb6 --- /dev/null +++ b/jnigen/emit.odin @@ -0,0 +1,296 @@ +package jnigen + +import "core:fmt" +import "core:strings" + +Emitter :: struct { + b: strings.Builder, +} + +w :: proc(e: ^Emitter, format: string, args: ..any) { + fmt.sbprintf(&e.b, format, ..args) + strings.write_byte(&e.b, '\n') +} + +emit_file :: proc(g: ^Gen, package_name: string) -> string { + e: Emitter + e.b = strings.builder_make() + + w(&e, "// GENERATED by jnigen from the JNI spec. Do not edit.") + w(&e, "//") + w(&e, "// Every binding returns `ok` -- false means the class, method or field") + w(&e, "// failed to resolve, or the call raised a Java exception (already printed") + w(&e, "// to logcat and cleared). Class and method IDs resolve once and are cached.") + w(&e, "package %s", package_name) + w(&e, "") + w(&e, "import \"shared:android\"") + w(&e, "import \"jni\"") + w(&e, "") + + if len(g.class_decls) > 0 { + w(&e, "//") + w(&e, "// Object types") + w(&e, "//") + w(&e, "") + for c in g.class_decls { + w(&e, "// %s", c.java_class) + w(&e, "%s :: distinct android.jobject", c.odin_name) + w(&e, "") + } + } + + for b in g.bindings { + emit_binding(&e, b) + } + + return strings.to_string(e.b) +} + +@(private = "file") +emit_signature :: proc(e: ^Emitter, b: Binding) { + sb := strings.builder_make(context.temp_allocator) + + if b.has_recv { + fmt.sbprintf(&sb, "%s: %s", b.recv.name, b.recv.type.odin) + } + for p in b.params { + if strings.builder_len(sb) > 0 { + strings.write_string(&sb, ", ") + } + fmt.sbprintf(&sb, "%s: %s", p.name, p.type.odin) + } + if allocates(b.result) { + if strings.builder_len(sb) > 0 { + strings.write_string(&sb, ", ") + } + strings.write_string(&sb, "allocator := context.allocator") + } + + if b.result.of == .Void { + w(e, "%s :: proc(%s) -> (ok: bool) {{", b.odin_name, strings.to_string(sb)) + } else { + w(e, "%s :: proc(%s) -> (result: %s, ok: bool) {{", b.odin_name, strings.to_string(sb), b.result.odin) + } +} + +@(private = "file") +emit_binding :: proc(e: ^Emitter, b: Binding) { + switch b.kind { + case .Method: + w(e, "// %s.%s%s", b.java_class, b.java_name, b.sig) + case .Ctor: + w(e, "// new %s%s", b.java_class, b.sig) + case .Field_Get: + w(e, "// %s.%s : %s (get)", b.java_class, b.java_name, b.sig) + case .Field_Set: + w(e, "// %s.%s : %s (set)", b.java_class, b.java_name, b.sig) + } + + emit_signature(e, b) + w(e, " env := jni.env()") + w(e, " if env == nil {{") + w(e, " return") + w(e, " }}") + w(e, "") + + // Resolved once; a global ref keeps the class alive past this frame. + w(e, " @(static) cls: android.jclass") + w(e, " if cls == nil {{") + w(e, " cls = jni.class_ref(env, \"%s\")", b.java_class) + w(e, " if cls == nil {{") + w(e, " return") + w(e, " }}") + w(e, " }}") + + is_field := b.kind == .Field_Get || b.kind == .Field_Set + if is_field { + lookup := b.is_static ? "static_field_id" : "field_id" + w(e, " @(static) fid: android.jfieldID") + w(e, " if fid == nil {{") + w(e, " fid = jni.%s(env, cls, \"%s\", \"%s\")", lookup, b.java_name, b.sig) + w(e, " if fid == nil {{") + w(e, " return") + w(e, " }}") + w(e, " }}") + } else { + lookup := (b.is_static && b.kind != .Ctor) ? "static_method_id" : "method_id" + w(e, " @(static) mid: android.jmethodID") + w(e, " if mid == nil {{") + w(e, " mid = jni.%s(env, cls, \"%s\", \"%s\")", lookup, b.java_name, b.sig) + w(e, " if mid == nil {{") + w(e, " return") + w(e, " }}") + w(e, " }}") + } + w(e, "") + + switch b.kind { + case .Method, .Ctor: + emit_call(e, b) + case .Field_Get: + emit_field_get(e, b) + case .Field_Set: + emit_field_set(e, b) + } + + w(e, "}}") + w(e, "") +} + +// Marshals one argument into `args[idx]`, emitting any temporary the value needs. +@(private = "file") +emit_marshal :: proc(e: ^Emitter, p: Param, idx: int) { + t := p.type + switch t.of { + case .Prim: + w(e, " args[%d].%s = %s(%s)", idx, t.jval, t.jcast, p.name) + case .String: + w(e, " _a%d := jni.to_jstring(env, %s)", idx, p.name) + w(e, " defer jni.free_local(env, android.jobject(_a%d))", idx) + w(e, " args[%d].l = android.jobject(_a%d)", idx, idx) + case .Array: + conv := t.elem == "string" ? "to_string_array" : fmt.tprintf("to_%s_array", t.elem) + w(e, " _a%d := jni.%s(env, %s)", idx, conv, p.name) + w(e, " defer jni.free_local(env, android.jobject(_a%d))", idx) + w(e, " args[%d].l = android.jobject(_a%d)", idx, idx) + case .Object: + w(e, " args[%d].l = android.jobject(%s)", idx, p.name) + case .Void: + } +} + +@(private = "file") +emit_call :: proc(e: ^Emitter, b: Binding) { + args_expr := "nil" + if len(b.params) > 0 { + w(e, " args := [%d]android.jvalue{{}}", len(b.params)) + for p, i in b.params { + emit_marshal(e, p, i) + } + w(e, "") + args_expr = "raw_data(args[:])" + } + + kind := title(b.result.call) + switch { + case b.kind == .Ctor: + w(e, " _ret := env^^.NewObjectA(env, cls, mid, %s)", args_expr) + case b.result.of == .Void && b.is_static: + // A void call has no value to bind. + w(e, " env^^.CallStaticVoidMethodA(env, cls, mid, %s)", args_expr) + case b.result.of == .Void: + w(e, " env^^.CallVoidMethodA(env, android.jobject(%s), mid, %s)", b.recv.name, args_expr) + case b.is_static: + w(e, " _ret := env^^.CallStatic%sMethodA(env, cls, mid, %s)", kind, args_expr) + case: + w(e, " _ret := env^^.Call%sMethodA(env, android.jobject(%s), mid, %s)", kind, b.recv.name, args_expr) + } + + w(e, " if jni.failed(env) {{") + w(e, " return") + w(e, " }}") + emit_result(e, b, "_ret") + w(e, " ok = true") + w(e, " return") +} + +@(private = "file") +emit_result :: proc(e: ^Emitter, b: Binding, ret: string) { + t := b.result + switch t.of { + case .Void: + case .Prim: + w(e, " result = %s(%s)", t.odin, ret) + case .String: + w(e, " defer jni.free_local(env, %s)", ret) + w(e, " result = jni.from_jstring(env, android.jstring(%s), allocator)", ret) + case .Array: + conv := t.elem == "string" ? "from_string_array" : fmt.tprintf("from_%s_array", t.elem) + jarr := t.elem == "string" ? "android.jobjectArray" : fmt.tprintf("android.j%sArray", java_arr_name(t.elem)) + w(e, " defer jni.free_local(env, %s)", ret) + w(e, " result = jni.%s(env, %s(%s), allocator)", conv, jarr, ret) + case .Object: + w(e, " result = %s(%s)", t.odin, ret) + } +} + +// Odin element type -> the jni.odin array type infix (jintArray, jlongArray...). +java_arr_name :: proc(elem: string) -> string { + for p in prims { + if p.odin == elem { + return p.call + } + } + return "object" +} + +@(private = "file") +emit_field_get :: proc(e: ^Emitter, b: Binding) { + t := b.result + getter := field_accessor(t, b.is_static, true) + target := b.is_static ? "cls" : fmt.tprintf("android.jobject(%s)", b.recv.name) + w(e, " _ret := env^^.%s(env, %s, fid)", getter, target) + w(e, " if jni.failed(env) {{") + w(e, " return") + w(e, " }}") + emit_result(e, b, "_ret") + w(e, " ok = true") + w(e, " return") +} + +@(private = "file") +emit_field_set :: proc(e: ^Emitter, b: Binding) { + p := b.params[0] + setter := field_accessor(p.type, b.is_static, false) + target := b.is_static ? "cls" : fmt.tprintf("android.jobject(%s)", b.recv.name) + + value := "" + switch p.type.of { + case .Prim: + value = fmt.tprintf("%s(%s)", p.type.jcast, p.name) + case .String: + w(e, " _v := jni.to_jstring(env, %s)", p.name) + w(e, " defer jni.free_local(env, android.jobject(_v))") + value = "android.jobject(_v)" + case .Array: + conv := p.type.elem == "string" ? "to_string_array" : fmt.tprintf("to_%s_array", p.type.elem) + w(e, " _v := jni.%s(env, %s)", conv, p.name) + w(e, " defer jni.free_local(env, android.jobject(_v))") + value = "android.jobject(_v)" + case .Object: + value = fmt.tprintf("android.jobject(%s)", p.name) + case .Void: + } + + w(e, " env^^.%s(env, %s, fid, %s)", setter, target, value) + w(e, " if jni.failed(env) {{") + w(e, " return") + w(e, " }}") + w(e, " ok = true") + w(e, " return") +} + +// GetStaticIntField / SetObjectField / ... +field_accessor :: proc(t: Type_Ref, is_static: bool, is_get: bool) -> string { + kind := "Object" + if t.of == .Prim { + for p in prims { + if p.odin == t.odin { + kind = title(p.call) + break + } + } + } + verb := is_get ? "Get" : "Set" + if is_static { + return fmt.tprintf("%sStatic%sField", verb, kind) + } + return fmt.tprintf("%s%sField", verb, kind) +} + +title :: proc(s: string) -> string { + if len(s) == 0 { + return s + } + return fmt.tprintf("%c%s", s[0] - 32, s[1:]) +} diff --git a/jnigen/main.odin b/jnigen/main.odin new file mode 100644 index 0000000..e32c03f --- /dev/null +++ b/jnigen/main.odin @@ -0,0 +1,72 @@ +// jnigen -- generates JNI binding boilerplate from an annotated Odin spec. +// +// odin run jnigen -- [spec.odin] [out.odin] [package] +// +// The spec is never compiled (it carries `#+build ignore`), so it needs no +// -custom-attribute flags and cannot collide with the generated procs. +package jnigen + +import "core:fmt" +import "core:odin/ast" +import "core:odin/parser" +import "core:os" + +main :: proc() { + spec_path := "jni_spec.odin" + out_path := "jni_generated.odin" + package_name := "main" + + args := os.args[1:] + if len(args) > 0 { + spec_path = args[0] + } + if len(args) > 1 { + out_path = args[1] + } + if len(args) > 2 { + package_name = args[2] + } + + src, read_err := os.read_entire_file(spec_path, context.allocator) + if read_err != nil { + fmt.eprintfln("jnigen: cannot read %s: %v", spec_path, read_err) + os.exit(1) + } + + p := parser.default_parser() + f := ast.File{ + src = string(src), + fullpath = spec_path, + } + if !parser.parse_file(&p, &f) { + fmt.eprintfln("jnigen: failed to parse %s", spec_path) + os.exit(1) + } + + g: Gen + g.classes = make(map[string]string) + + collect_classes(&g, &f) + collect_bindings(&g, &f) + + if len(g.errors) > 0 { + for e in g.errors { + fmt.eprintfln("jnigen: %s", e) + } + os.exit(1) + } + + out := emit_file(&g, package_name) + if werr := os.write_entire_file(out_path, transmute([]byte)out); werr != nil { + fmt.eprintfln("jnigen: cannot write %s: %v", out_path, werr) + os.exit(1) + } + + fmt.printfln( + "jnigen: %s -> %s (%d classes, %d bindings)", + spec_path, + out_path, + len(g.class_decls), + len(g.bindings), + ) +} diff --git a/jnigen/spec.odin b/jnigen/spec.odin new file mode 100644 index 0000000..79fce29 --- /dev/null +++ b/jnigen/spec.odin @@ -0,0 +1,288 @@ +package jnigen + +import "core:fmt" +import "core:odin/ast" +import "core:strings" + +Kind :: enum { + Method, + Ctor, + Field_Get, + Field_Set, +} + +Class_Of :: enum { + Void, + Prim, + String, + Object, + Array, +} + +Type_Ref :: struct { + odin: string, // Odin type as written in the spec + sig: string, // JNI signature fragment + of: Class_Of, + call: string, // "int" / "object" / "void" -- picks the jni.call_* wrapper + jval: string, // jvalue union field + jcast: string, // android.jint etc, for widening into the union + elem: string, // element type, arrays only +} + +Param :: struct { + name: string, + type: Type_Ref, +} + +Binding :: struct { + odin_name: string, + java_class: string, + java_name: string, + kind: Kind, + is_static: bool, + recv: Param, + has_recv: bool, + params: []Param, + result: Type_Ref, + sig: string, + line: int, +} + +Class_Bind :: struct { + odin_name: string, + java_class: string, +} + +Gen :: struct { + classes: map[string]string, // Odin type name -> java/class/path + class_decls: [dynamic]Class_Bind, + bindings: [dynamic]Binding, + errors: [dynamic]string, +} + +errorf :: proc(g: ^Gen, line: int, format: string, args: ..any) { + msg := fmt.tprintf(format, ..args) + append(&g.errors, fmt.aprintf("spec:%d: %s", line, msg)) +} + +@(private = "file") +attr_map :: proc(f: ^ast.File, attrs: [dynamic]^ast.Attribute) -> map[string]string { + out := make(map[string]string) + for attr in attrs { + for elem in attr.elems { + #partial switch e in elem.derived { + case ^ast.Field_Value: + key := src_of(f, e.field) + val := strings.trim(src_of(f, e.value), "\"") + out[key] = val + case ^ast.Ident: + out[e.name] = "" + } + } + } + return out +} + +src_of :: proc(f: ^ast.File, e: ^ast.Node) -> string { + if e == nil { + return "" + } + return strings.trim_space(f.src[e.pos.offset:e.end.offset]) +} + +// First pass: every `@(java_class=...) Name :: distinct jobject` becomes a +// registered object type, so later signatures can resolve `Toast` to +// `Landroid/widget/Toast;`. +collect_classes :: proc(g: ^Gen, f: ^ast.File) { + for decl in f.decls { + vd, ok := decl.derived.(^ast.Value_Decl) + if !ok || len(vd.attributes) == 0 || len(vd.values) == 0 { + continue + } + // A proc type/lit is a method binding, not a class registration. + #partial switch _ in vd.values[0].derived { + case ^ast.Proc_Type: + continue + case ^ast.Proc_Lit: + continue + } + + attrs := attr_map(f, vd.attributes) + defer delete(attrs) + cls, has := attrs["java_class"] + if !has || len(vd.names) == 0 { + continue + } + name := src_of(f, vd.names[0]) + g.classes[strings.clone(name)] = strings.clone(cls) + append(&g.class_decls, Class_Bind{strings.clone(name), strings.clone(cls)}) + } +} + +collect_bindings :: proc(g: ^Gen, f: ^ast.File) { + for decl in f.decls { + vd, ok := decl.derived.(^ast.Value_Decl) + if !ok || len(vd.attributes) == 0 || len(vd.values) == 0 || len(vd.names) == 0 { + continue + } + + pt: ^ast.Proc_Type + #partial switch v in vd.values[0].derived { + case ^ast.Proc_Type: + pt = v + case ^ast.Proc_Lit: + pt = v.type + case: + continue + } + + attrs := attr_map(f, vd.attributes) + defer delete(attrs) + java_class, has_class := attrs["java_class"] + if !has_class { + continue + } + + b: Binding + b.line = vd.pos.line + b.odin_name = strings.clone(src_of(f, vd.names[0])) + b.java_class = strings.clone(java_class) + _, b.is_static = attrs["java_static"] + + if name, has := attrs["java_name"]; has { + b.java_name = strings.clone(name) + } else { + b.java_name = b.odin_name + } + + _, is_ctor := attrs["java_ctor"] + _, is_field := attrs["java_field"] + + params := parse_params(g, f, pt, b.line) + results := parse_results(g, f, pt, b.line) + + if len(results) > 1 { + errorf(g, b.line, "'%s' declares %d results; bindings return at most one value", b.odin_name, len(results)) + continue + } + if len(results) == 1 { + b.result = results[0] + } else { + b.result = Type_Ref{odin = "", sig = "V", of = .Void, call = "void"} + } + + switch { + case is_ctor: + b.kind = .Ctor + b.java_name = "" + if b.result.of != .Object { + errorf(g, b.line, "'%s' is a constructor so it must return a registered object type", b.odin_name) + continue + } + b.params = params + b.sig = build_sig(params, Type_Ref{sig = "V", of = .Void}) + case is_field: + if b.result.of == .Void { + b.kind = .Field_Set + // instance setter: (self, value); static setter: (value) + if !b.is_static { + if len(params) != 2 { + errorf(g, b.line, "'%s' is an instance field setter so it must take (self, value)", b.odin_name) + continue + } + b.recv, b.has_recv = params[0], true + b.params = params[1:] + } else { + if len(params) != 1 { + errorf(g, b.line, "'%s' is a static field setter so it must take exactly (value)", b.odin_name) + continue + } + b.params = params + } + b.sig = b.params[0].type.sig + } else { + b.kind = .Field_Get + if !b.is_static { + if len(params) != 1 { + errorf(g, b.line, "'%s' is an instance field getter so it must take exactly (self)", b.odin_name) + continue + } + b.recv, b.has_recv = params[0], true + } else if len(params) != 0 { + errorf(g, b.line, "'%s' is a static field getter so it must take no parameters", b.odin_name) + continue + } + b.params = nil + b.sig = b.result.sig + } + case: + b.kind = .Method + if !b.is_static { + if len(params) == 0 { + errorf(g, b.line, "'%s' is an instance method so its first parameter must be the receiver", b.odin_name) + continue + } + b.recv, b.has_recv = params[0], true + b.params = params[1:] + } else { + b.params = params + } + b.sig = build_sig(b.params, b.result) + } + + if override, has := attrs["java_sig"]; has { + b.sig = strings.clone(override) + } + + append(&g.bindings, b) + } +} + +@(private = "file") +parse_params :: proc(g: ^Gen, f: ^ast.File, pt: ^ast.Proc_Type, line: int) -> []Param { + out := make([dynamic]Param) + if pt.params == nil { + return out[:] + } + for field in pt.params.list { + t, ok := resolve_type(g, src_of(f, field.type), line) + if !ok { + continue + } + // `a, b: i32` declares two params of one type. + if len(field.names) == 0 { + append(&out, Param{fmt.aprintf("_arg%d", len(out)), t}) + continue + } + for n in field.names { + append(&out, Param{strings.clone(src_of(f, n)), t}) + } + } + return out[:] +} + +@(private = "file") +parse_results :: proc(g: ^Gen, f: ^ast.File, pt: ^ast.Proc_Type, line: int) -> []Type_Ref { + out := make([dynamic]Type_Ref) + if pt.results == nil { + return out[:] + } + for field in pt.results.list { + t, ok := resolve_type(g, src_of(f, field.type), line) + if ok { + append(&out, t) + } + } + return out[:] +} + +build_sig :: proc(params: []Param, result: Type_Ref) -> string { + b := strings.builder_make() + strings.write_byte(&b, '(') + for p in params { + strings.write_string(&b, p.type.sig) + } + strings.write_byte(&b, ')') + strings.write_string(&b, result.sig) + return strings.to_string(b) +} diff --git a/jnigen/types.odin b/jnigen/types.odin new file mode 100644 index 0000000..af73461 --- /dev/null +++ b/jnigen/types.odin @@ -0,0 +1,89 @@ +package jnigen + +import "core:fmt" +import "core:strings" + +Prim :: struct { + odin: string, + sig: string, + call: string, + jval: string, + jcast: string, +} + +// Odin's u16 stands in for Java's char: both are unsigned 16-bit. +prims := []Prim{ + {"bool", "Z", "boolean", "z", "android.jboolean"}, + {"i8", "B", "byte", "b", "android.jbyte"}, + {"u16", "C", "char", "c", "android.jchar"}, + {"i16", "S", "short", "s", "android.jshort"}, + {"i32", "I", "int", "i", "android.jint"}, + {"i64", "J", "long", "j", "android.jlong"}, + {"f32", "F", "float", "f", "android.jfloat"}, + {"f64", "D", "double", "d", "android.jdouble"}, +} + +resolve_type :: proc(g: ^Gen, odin: string, line: int) -> (Type_Ref, bool) { + src := strings.trim_space(odin) + if src == "" { + return Type_Ref{sig = "V", of = .Void, call = "void"}, true + } + + for p in prims { + if src == p.odin { + return Type_Ref{ + odin = src, sig = p.sig, of = .Prim, + call = p.call, jval = p.jval, jcast = p.jcast, + }, true + } + } + + if src == "string" || src == "cstring" { + return Type_Ref{ + odin = src, sig = "Ljava/lang/String;", of = .String, + call = "object", jval = "l", + }, true + } + + if strings.has_prefix(src, "[]") { + elem := strings.trim_space(src[2:]) + et, ok := resolve_type(g, elem, line) + if !ok { + return {}, false + } + if et.of == .Array { + errorf(g, line, "nested slice '%s' is not supported; declare it as a jobject instead", src) + return {}, false + } + return Type_Ref{ + odin = src, sig = fmt.aprintf("[%s", et.sig), of = .Array, + call = "object", jval = "l", elem = elem, + }, true + } + + // A raw jobject is untyped on the Java side, so it signs as Object. + if src == "android.jobject" || src == "jobject" { + return Type_Ref{ + odin = src, sig = "Ljava/lang/Object;", of = .Object, + call = "object", jval = "l", + }, true + } + + if cls, ok := g.classes[src]; ok { + return Type_Ref{ + odin = src, sig = fmt.aprintf("L%s;", cls), of = .Object, + call = "object", jval = "l", + }, true + } + + errorf(g, line, "unknown type '%s'; register it with @(java_class=\"...\") or use a primitive", src) + return {}, false +} + +// Whether a returned value allocates and so needs an allocator parameter. +allocates :: proc(t: Type_Ref) -> bool { + if t.of == .String { + return true + } + return t.of == .Array +} diff --git a/main.odin b/main.odin new file mode 100644 index 0000000..366ea85 --- /dev/null +++ b/main.odin @@ -0,0 +1,146 @@ +package main + +import "shared:android" +import "core:fmt" +import "base:runtime" +import engine "../zephr" +import "jni" + +counter := 0 + +device_model: string +device_sdk: i32 +toast_ctx: Context +main_activity: Activity + +Edge_Insets :: struct { + left, top, right, bottom: f32 +} + +insets: Edge_Insets +insets_valid: bool + +@export +android_main :: proc "c" (state: ^android.android_app) { + context = runtime.default_context() + context.user_ptr = state + + engine.init("", "", {1000, 600}, false, "") + defer engine.deinit() + + // Static field reads only -- safe from the engine's thread. Anything + // touching the UI toolkit (Toast, dialogs) needs the main looper instead. + if jni.init(state) { + device_model, _ = build_model() + device_sdk, _ = build_sdk_int() + toast_ctx = Context(jni.activity(state)) + main_activity = Activity(jni.activity(state)) + } + + _, font_ok := engine.ui_text_register_font(#load("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf")) + ensure(font_ok) + + for !engine.should_quit() { + engine.frame_start() + defer engine.frame_end() + + e := engine.iter_events() + for e != nil { + #partial switch e.type { + case .WINDOW_CLOSED: + engine.quit() + case .WINDOW_RESIZED: + insets_valid = false + } + + e = engine.iter_events() + } + + if !insets_valid { + insets_valid = refresh_insets() + } + + window_size := engine.get_window_size() + + engine.ui_begin_build(window_size) + engine.ui_text_set_default_pixel_size(108) + engine.ui_push_text_color(0xFFFFFF) + draw_ui() + engine.ui_pop_text_color() + engine.ui_end_build() + + engine.ui_draw(engine.get_projection()) + } +} + +draw_ui :: proc() { + engine.ui_set_next_width(engine.ui_children_sum(1)) + engine.ui_column(); { + engine.ui_padding(insets.left, {.Left}) + engine.ui_padding(insets.right, {.Right}) + engine.ui_padding(insets.top, {.Top}) + engine.ui_padding(insets.bottom, {.Bottom}) + engine.ui_text(fmt.tprintf("%d", counter)) + engine.ui_text(fmt.tprintf("%s / API %d", device_model, device_sdk)) + engine.ui_text(fmt.tprintf("ins %v %v %v %v ok=%v", insets.left, insets.top, insets.right, insets.bottom, insets_valid)) + if click, _ := engine.ui_button("Hello"); click { + counter += 1 + show_toast(fmt.tprintf("Clicked %d times", counter)) + } + } +} + +// Toast needs a Java looper on this thread. Preparing one is a one-time cost +// and the native ALooper the engine polls is unaffected -- they're separate. +show_toast :: proc(msg: string) { + if l, ok := looper_my_looper(); ok && l == nil { + looper_prepare() + } + + toast, ok := toast_make_text(toast_ctx, msg, 0) // 0 = Toast.LENGTH_SHORT + if !ok { + return + } + toast_show(toast) +} + +// Walks Activity -> Window -> decor View -> WindowInsets. Returns false while +// the decor view is still unattached, which is normal for the first few frames. +refresh_insets :: proc() -> bool { + // This runs every frame until it succeeds, and each call below returns a + // local ref. Without a frame to scope them the ref table fills and aborts. + env := jni.env() + if !jni.push_frame(env) { + return false + } + defer jni.pop_frame(env) + + win := activity_get_window(main_activity) or_return + decor := window_get_decor_view(win) or_return + wi := view_get_root_window_insets(decor) or_return + if wi == nil { + return false + } + + // API 30+ typed insets. Below that both calls fail and we fall through. + if mask, ok := insets_type_system_bars(); ok { + if cut, got_cut := insets_type_display_cutout(); got_cut { + mask |= cut + } + if ins, got := window_insets_get_ignoring_visibility(wi, mask); got && ins != nil { + l := insets_left(ins) or_return + t := insets_top(ins) or_return + r := insets_right(ins) or_return + b := insets_bottom(ins) or_return + insets = {f32(l), f32(t), f32(r), f32(b)} + return true + } + } + + l := window_insets_sys_left(wi) or_return + t := window_insets_sys_top(wi) or_return + r := window_insets_sys_right(wi) or_return + b := window_insets_sys_bottom(wi) or_return + insets = {f32(l), f32(t), f32(r), f32(b)} + return true +}