Sign in

    ajni

    Android JNI runtime primitives and MoonBit lifecycle bridge.

    moonbit
    android
    jni
    ndk
    Download zip
    Version
    0.2.5
    License
    Apache-2.0
    Last updated
    10 hours ago
    Downloads
    534

    #ajni

    ajni provides MoonBit-facing JVM JNI primitives and an Android runtime bridge. The root package builds checked JVM class, type, method, and native-method declarations; the Android package supplies a stable Kotlin host, lifecycle and UI-thread callbacks, safe Java UTF-16/MoonBit UTF-8 conversion, and an optional Android WebView feature.

    The generic, Android, and WebView packages are separate. Applications that only need JVM JNI declarations import Nanaloveyuki/ajni; Android hosts add Nanaloveyuki/ajni/android; applications that embed a browser additionally import Nanaloveyuki/ajni/webview and link its native stub.

    #Install

    moon add Nanaloveyuki/ajni

    For the optional WebView feature, import its package from a MoonBit package:

    import {
    "Nanaloveyuki/ajni/webview",
    }

    #Generic JNI Declarations

    The root package has no Android dependency. It validates internal class names, prevents void parameters, enforces 255 array dimensions, 255 explicit parameter slots, and the classfile's 65,535-byte Modified UTF-8 limit, and keeps raw JNI pointers out of MoonBit. Instance/interface methods must additionally reserve one parameter slot for implicit this; a descriptor does not prove native ABI.

    let string = try! @ajni.JniClass::parse("java/lang/String")
    let signature = try! @ajni.JniMethod::new(
    [@ajni.JniType::object(string)],
    return_type=@ajni.JniType::boolean(),
    )
    let registration = try! @ajni.NativeMethod::new("nativeAcceptsString", signature)
    println(registration.descriptor()) // (Ljava/lang/String;)Z

    Platform-specific code can consume these values when registering JNI methods. Raw JNIEnv*, jobject, global-reference deletion, and Java attachment remain C-owned because their lifetimes cannot be safely encoded as plain MoonBit values.

    JniClass::parse allows at most 65,533 MUTF-8 name bytes, reserving L and ; so existing non-raising object/descriptor constructors remain valid. Native method names may use all 65,535 bytes. These are encoding-byte limits, not UTF-16 lengths or ordinary UTF-8 lengths.

    #Android Runtime

    Import Nanaloveyuki/ajni/android to install an event handler before the Kotlin host forwards Activity or Surface events. The handler returns a token that can be removed during shutdown.

    import {
    "Nanaloveyuki/ajni/android",
    }

    let subscription = @android.install_event_handler(event => match event {
    @android.AndroidEvent::Lifecycle(@android.Lifecycle::Resumed) => println("resumed")
    @android.AndroidEvent::UiTask => println("Android main Looper callback")
    _ => ()
    })

    // Remove the observer before application shutdown.
    @android.remove_event_handler(subscription)

    @android.post_to_ui() schedules an asynchronous callback on Android's main Looper. @android.start_worker() demonstrates a native-owned thread attaching to ART, posting back to the UI thread, then detaching.

    Synchronous failures distinguish NotInitialized, RuntimeDestroyed, JavaException, and NativeFailure. Outside Android, readiness is false and Android/WebView operations raise NativeFailure instead of simulated success. Ordinary UTF-8 conversion replaces malformed input bytes with U+FFFD; overlong encodings, encoded surrogates, and out-of-range scalars are not accepted as text.

    #Android Host

    The Android application links the generated MoonBit native artifact and uses the reusable Kotlin host from the android:host Gradle module. Initialize it once, then forward the Activity lifecycle and attach a caller-owned container on Android's main thread:

    import dev.nanaloveyuki.ajni.host.NativeBridge NativeBridge.initialize(applicationContext) NativeBridge.attachWebViewContainer(container) // Forward every Activity lifecycle state on the main thread. NativeBridge.lifecycle(state) // During Activity teardown: NativeBridge.detachWebViewContainer(container) NativeBridge.shutdown()

    NativeBridge.LIFECYCLE_RESUMED and NativeBridge.LIFECYCLE_PAUSED also resume and pause attached WebViews. Attaching a replacement container destroys views in the previous container, so each Activity or host recreation starts from an explicit new create call.

    Initialize and shut down on the main thread. Readiness is published only after native initialization succeeds. Old runtime/container/view work is discarded; new views inherit the host's paused state. Shutdown completes pending assets but does not implicitly detach the caller-owned container.

    Use android/app/src/main/cpp/CMakeLists.txt as the integration template. It generates the MoonBit Android host, compiles the MoonBit runtime, and links libandroid and liblog. The configured minimum Android API is 24; supported demo ABIs are arm64-v8a and x86_64.

    #WebView Feature

    The WebView host must be attached before @webview.create. Commands are queued on Android's main Looper when called from another thread. Each view has a caller-provided Int64 handle.

    import {
    "Nanaloveyuki/ajni/webview",
    }

    let subscription = @webview.install_event_handler(event => match event.kind {
    @webview.EventKind::Created(_) => println("ready")
    @webview.EventKind::ScriptResult(request_id, json) =>
    println("\{request_id}: \{json}")
    @webview.EventKind::PageMessage(body, origin, _is_main_frame) =>
    println("\{origin}: \{body}")
    @webview.EventKind::AssetRequest(request) => println(request.path)
    @webview.EventKind::OperationFailed(operation_id, message) =>
    println("\{operation_id}: \{message}")
    _ => ()
    })

    try! @webview.create(
    1L,
    "https://app.example.test",
    @webview.InitialContent::Url("https://app.example.test/assets/index.html"),
    document_start_scripts=["globalThis.appReady = true"],
    operation_id="create-browser",
    )
    try! @webview.eval(1L, "document.title", "title-request")
    try! @webview.destroy(1L, operation_id="destroy-browser")
    @webview.remove_event_handler(subscription)

    Importing only the core package does not compile src/webview/ajni_webview_bridge.c. An Android build that imports ajni/webview must also include that stub, define AJNI_FEATURE_WEBVIEW=1, and export ajni_dispatch_webview_event; the bundled CMake template demonstrates all three requirements.

    The host enables JavaScript for eval, disables file and content access, disables mixed content and multiple windows, enables Safe Browsing on Android 8+, and does not expose addJavascriptInterface. Web messages are delivered only through AndroidX WebKit's trusted-origin listener. Embedded resources use the same origin's /assets/ path and emit AssetRequest; complete them with respond_asset before the configured timeout.

    Asset responses accept 100..299 or 400..599, not redirects. Malformed responses complete their owned request with an error; wrong handles do not consume another view's request. Renderer exit destroys the affected view and completes its pending resources without retry. The trusted message origin is not a network sandbox for page subresources, redirects, or links.

    #Maintenance Scope

    Until MoonBit 1.0 is officially released, ajni focuses on fixes to existing behavior, build correctness, and documentation. Feature expansion is deferred. See BUGFIX_PLAN.md for categorized defects, implementation status, evidence, and remaining validation limits.

    #Build And Verify

    Current static checks:

    moon fmt --check moon check --target native moon test --target native --build-only moon info --target native

    Android/WebView runtime testing is currently deferred. Static checks and APK compilation do not prove ART, lifecycle, Surface, or WebView behavior. Android compilation additionally requires the SDK, NDK 29.0.14206865, CMake, Java 17, and the pinned Gradle 8.10.2 wrapper under android/.

    CI installs and verifies the MoonBit version in .moon-version, compiles tests without executing them, builds both configured Android ABIs, and runs Kotlin compilation/static lint. The host ships precise JNI consumer keep rules; runtime lookup in an R8-minified consumer remains a separate verification step.

    The following emulator test command is retained for when runtime testing resumes; it is not part of the current validation requirement:

    $env:ANDROID_SDK_ROOT = "C:\path\to\Android\Sdk" $env:ANDROID_NDK_HOME = "C:\path\to\android-ndk-r29" $env:ANDROID_SERIAL = "emulator-5554" .\android\gradlew.bat -p android :app:connectedDebugAndroidTest

    The GitHub workflow builds the Android demo for both configured ABIs but does not execute instrumentation tests. Record platform runtime verification as deferred, not passed, while only static checks or compilation are available.

    #Contributing

    See CONTRIBUTING.md for feature boundaries, validation, and pull request requirements.

    #ajni JNI

    Nanaloveyuki/ajni provides checked JVM JNI declarations without an Android dependency. It intentionally does not expose raw JNIEnv*, jobject, or function pointers because their lifetime rules cannot be represented safely by plain MoonBit values.

    #JNI Descriptors

    ///|
    test {
    let string = try! @ajni.JniClass::parse("java/lang/String")
    let signature = try! @ajni.JniMethod::new(
    [@ajni.JniType::object(string)],
    return_type=@ajni.JniType::boolean(),
    )
    let registration = try! @ajni.NativeMethod::new(
    "nativeAcceptsString", signature,
    )
    assert_eq(registration.descriptor(), "(Ljava/lang/String;)Z")
    }

    JniType cannot represent void. Names and descriptors obey the classfile 65,535-byte Modified UTF-8 limit, not UTF-16 length or ordinary UTF-8 length: each UTF-16 code unit uses two bytes for NUL, one for U+0001–U+007F, two for U+0080–U+07FF, and three otherwise. A supplementary character therefore uses six bytes for its surrogate pair. Names still reject NUL as invalid syntax.

    JniClass::parse reserves two bytes for the L/; framing, accepting at most 65,533 encoded name bytes so its non-raising descriptor and JniType::object constructors always produce valid-sized descriptors. Native-method names may use all 65,535 bytes. Oversized names, array prefixes, and the complete method descriptor (including parentheses and return type) raise DescriptorTooLong; malformed names retain InvalidClassName or InvalidNativeMethodName.

    Arrays permit at most 255 dimensions. Method declarations permit at most 255 explicit parameter slots, counting long and double as two slots and arrays as one. Static methods can use all 255; instance/interface invocation also counts the implicit this, leaving at most 254 explicit slots. The descriptor does not encode that invocation context, and the current Kotlin native registrations are static. JniMethod/NativeMethod validate declaration shape and limits, not function-pointer ABI, linkage, or every invocation form.

    Import Nanaloveyuki/ajni/android for Android lifecycle, Surface, worker, and main-Looper callbacks. Browser support remains in the optional Nanaloveyuki/ajni/webview package.

    JniError

    pub(all) suberror JniError {
    NotInitialized
    RuntimeDestroyed
    NativeFailure(String)
    JavaException(String)
    InvalidClassName(String)
    InvalidNativeMethodName(String)
    DescriptorTooLong
    TooManyParameterSlots
    TooManyArrayDimensions
    } derive(
    Debug
    )

    JNI failures represented as values at the MoonBit boundary.

    JniClass

    pub struct JniClass {
    // private fields
    } derive(Eq,
    Debug
    )

    An internal JVM binary class name, for example java/lang/String.

    JniClass::descriptor

    fn JniClass::descriptor(self : JniClass) -> String

    JniClass::internal_name

    fn JniClass::internal_name(self : JniClass) -> String

    JniClass::parse

    fn JniClass::parse(internal_name : String) -> JniClass raise JniError

    Validate the internal name and reserve two MUTF-8 bytes for L and ;. Oversized names raise DescriptorTooLong; malformed names retain InvalidClassName. The non-raising descriptor/object constructors are then guaranteed to fit the 65,535-byte classfile limit.

    JniMethod

    pub struct JniMethod {
    // private fields
    } derive(Eq,
    Debug
    )

    A validated JVM method descriptor. The default return type is void. Validation covers descriptor shape, MUTF-8 length, and up to 255 explicit parameter slots, not a function-pointer ABI or an instance/static context. Instance invocation also needs a slot for this and permits at most 254 explicit slots; current Kotlin native registrations are static.

    JniMethod::descriptor

    fn JniMethod::descriptor(self : JniMethod) -> String

    JniMethod::new

    fn JniMethod::new(parameters : ArrayView[JniType], return_type? : JniType) -> JniMethod raise JniError

    JniType

    pub struct JniType {
    // private fields
    } derive(Eq,
    Debug
    )

    A non-void JNI value type. Its parameter-slot width is retained so method descriptors cannot undercount long and double.

    JniType::array

    fn JniType::array(element : JniType) -> JniType raise JniError

    JniType::boolean

    fn JniType::boolean() -> JniType

    JniType::byte

    fn JniType::byte() -> JniType

    JniType::char

    fn JniType::char() -> JniType

    JniType::descriptor

    fn JniType::descriptor(self : JniType) -> String

    JniType::double

    fn JniType::double() -> JniType

    JniType::float

    fn JniType::float() -> JniType

    JniType::int

    fn JniType::int() -> JniType

    JniType::long

    fn JniType::long() -> JniType

    JniType::object

    fn JniType::object(class : JniClass) -> JniType

    JniType::short

    fn JniType::short() -> JniType

    NativeMethod

    pub struct NativeMethod {
    // private fields
    } derive(Eq,
    Debug
    )

    A native-method registration declaration without a raw function pointer. It validates the name and descriptor, not registration linkage or ABI.

    NativeMethod::descriptor

    fn NativeMethod::descriptor(self : NativeMethod) -> String

    NativeMethod::name

    fn NativeMethod::name(self : NativeMethod) -> String

    NativeMethod::new

    fn NativeMethod::new(name : String, signature : JniMethod) -> NativeMethod raise JniError

    decode_utf8

    fn decode_utf8(bytes : BytesView) -> String

    Decodes UTF-8 received from native code. Invalid sequences, including a replacement emitted for isolated Java UTF-16 surrogates, are lossily normalised instead of allowing invalid text into the public API.

    encode_utf8

    fn encode_utf8(text : String) -> Bytes