Creating Android Plugins

Jasonelle allows you to extend the core functionality by writing native Android plugins in Kotlin or Java and exposing them to your JavaScript web application.

This guide outlines the general process for creating a new Android plugin for Jasonelle.

1. Project Structure

A plugin in Jasonelle is essentially an Android Library module. If you are creating a core plugin, it resides in sources/android/JLPluginYourName. If you are creating a custom user plugin, it usually goes into lib/common/sources/plugins/JLPluginYourName/android.

The easiest way to get started is to copy an existing simple plugin, such as JLPluginHello:

cp -R sources/android/JLPluginHello sources/android/JLPluginYourName

Then, rename the packages, folders, and references from hello to yourname and JLPluginHello to JLPluginYourName.

2. Implement the Plugin Class

Your plugin’s entry point is a Kotlin (or Java) class that inherits from com.jasonelle.kernel.Plugin.

package com.jasonelle.plugins.yourname

import android.content.Context
import com.jasonelle.kernel.Plugin as KernelPlugin

class Plugin(
  context: Context? = null,
) : KernelPlugin(context) {

  // The name used to call this plugin from JavaScript
  override val name: String get() = "yourname"

  // The unique identifier for this plugin
  override val id: String get() = "com.jasonelle.plugins.yourname"

  override fun handle_call(
    callbackId: String,
    args: Map<String, Any?>?,
    respond: (String) -> Unit,
  ) {
    logger.info("Handling request in YourName plugin")

    val action = args?.get("action") as? String
    if (action == null) {
      reject(
        args = mapOf("error" to "No action provided"),
        callbackId = callbackId,
        status = "error",
        respond = respond,
      )
      return
    }

    if (action == "doSomething") {
      // Perform your native action...

      // Send a successful response back to JS
      resolve(args = mapOf("success" to true), callbackId = callbackId, respond = respond)
    } else {
      reject(
        args = mapOf("error" to "Unknown action $action"),
        callbackId = callbackId,
        status = "error",
        respond = respond,
      )
    }
  }
}

Important Methods:

  • resolve(args: Map<String, Any?>, callbackId: String, status: String = "ok", respond: (String) → Unit): Resolves the JS Promise successfully with a return payload.

  • reject(args: Map<String, Any?>, callbackId: String, status: String = "ok", respond: (String) → Unit): Rejects the JS Promise with an error payload.

reject takes a payload map, not a message string, and its status defaults to "ok". Always pass status = "error" explicitly, otherwise the Promise rejects with a "status": "ok" payload that the JavaScript client reads as a success.

3. Add Dependencies

If your plugin requires third-party SDKs, declare them in the module’s build.gradle.kts (or build.gradle).

dependencies {
  implementation(project(":JLKernel"))

  // Add your custom SDK dependencies here:
  // implementation("com.example:custom-sdk:1.0.0")
}

4. Register the Plugin

To ensure Jasonelle’s build system links and bundles the plugin into the final app, register it in lib/common/config/config.jsonc (for both platforms) or lib/android/config/config.jsonc (for Android only).

"plugins": {
  // If it's a core plugin inside sources/android:
  "JLPluginYourName": "@jasonelle",

  // OR if it's a custom plugin in lib/common/sources/plugins/JLPluginYourName:
  // "JLPluginYourName": "@lib/JLPluginYourName"
}

5. Call it from JavaScript

Native code is reached through the window.jasonelle object that Jasonelle injects into the web view. Each plugin exposes its own methods on window.jasonelle.plugins.<name>, so first add a Plugin.js in the plugin’s src/main/assets/plugins/yourname/ folder that registers them. Copying JLPluginHello in step 1 already brought one over, so you only need to update it:

(() => {
  const native = window.jasonelle;
  const plugin = native.plugin.init("yourname", "com.jasonelle.plugins.yourname");

  plugin.doSomething = () => native.post(plugin.id, { action: "doSomething" });

  window.jasonelle.plugins.yourname = plugin;
})();

The first argument to plugin.init is the short name, the second is the plugin id that native code registers the plugin under. native.post returns a Promise that resolves or rejects with whatever resolve or reject sent:

window.jasonelle.plugins.yourname.doSomething()
    .then(response => console.log("Success:", response))
    .catch(error => console.error("Error:", error));

To skip the wrapper, window.jasonelle.post sends the arguments straight to handle_call using the plugin id:

await window.jasonelle.post("com.jasonelle.plugins.yourname", { action: "doSomething" });

6. Add Tests

Cover every branch of handle_call with a JUnit 5 test, using a no-op or mocked dependency so the test asserts the bridge contract rather than a live SDK. The exposed name and id, the null and empty argument rejects, each action resolving, the malformed arguments that are dropped silently, the unknown action reject, and the bundled JavaScript are all worth a case.

@Test
fun `handle_call counterAdd resolves success`() {
  val plugin = Plugin()

  val response = StringBuilder()
  plugin.handle_call(
    callbackId = "call_1",
    args = mapOf("action" to "counter.add", "name" to "button_clicks", "value" to 1),
    respond = { response.append(it) },
  )

  val script = response.toString()
  assertThat(script).startsWith("window.jasonelle.result.resolve({")
  assertThat(script).contains("\"success\":true")
}
@Test
fun `handle_call unknown action rejects with error`() {
  val plugin = Plugin()

  val response = StringBuilder()
  plugin.handle_call(
    callbackId = "call_1",
    args = mapOf("action" to "gauge.record"),
    respond = { response.append(it) },
  )

  val script = response.toString()
  assertThat(script).startsWith("window.jasonelle.result.reject({")
  assertThat(script).contains("\"status\":\"error\"")
  assertThat(script).contains("\"error\":\"Unknown action gauge.record\"")
}

Verify the Tests Bite

A test that never fails proves nothing. Temporarily change an expected string in the plugin and confirm the matching tests fail, then restore it. The OpenTelemetry plugin does this for every error message it asserts.

7. Build and Test

Run the whole suite plus the linter, which the build enforces:

cd sources/android
task test                          # ./gradlew test across all modules
./gradlew test ktlintCheck         # or run it directly
./gradlew :JLPluginYourName:testDebugUnitTest   # just this module

See the OpenTelemetry plugin for a worked example.