Skip to content

Kotlin and Java Modules

s edited this page Aug 14, 2026 · 10 revisions

JVM Environment: Kotlin and Java

Use the JVM environment when the module needs Android APIs, an existing JVM library, Context, or Kotlin coroutines.

This environment can provide the module's complete implementation. You do not need to handwrite C or C++ first. The generator supplies the native JSI and JNI connection needed to reach marked Kotlin and Java declarations.

If the same module also uses the native environment, exports from both environments join one JavaScript/TypeScript API.

Add a Kotlin/Java starter

supernote-module doctor
supernote-module add document --starter kotlin --yes

The Kotlin starter is created below the module's JVM source root:

local_modules/document/android/src/main/java/<namespace-path>/

Add Kotlin and Java files anywhere appropriate under that root. Update preserves the implementation source.

Import the generated annotations

Kotlin:

import supernote.generated.annotations.SupernoteConstructor
import supernote.generated.annotations.SupernotePluginAsync
import supernote.generated.annotations.SupernotePluginExport
import supernote.generated.annotations.SupernotePluginInternal

Java uses the same generated annotation package.

Do not edit KSP manifests, generated adapters, JNI callers, JSI bindings, or the shared runtime. Change your Kotlin/Java declaration, then regenerate and build.

Export a Kotlin function

A top-level Kotlin function is the simplest form:

package com.example.document

import supernote.generated.annotations.SupernotePluginExport

@SupernotePluginExport
fun pageCount(): Int = 42

JavaScript calls it without caring that it is implemented in Kotlin:

import document from 'document';

function onReadPageCount() {
  const count: number = document.pageCount();
  // Use count here.
}

The source declaration name is the generated API name. There is no annotation argument for renaming it.

Export a Java function

A static Java method avoids creating an owner instance:

package com.example.document;

import supernote.generated.annotations.SupernotePluginExport;

public final class DocumentApi {
  private DocumentApi() {}

  @SupernotePluginExport
  public static long identity(long value) {
    return value;
  }
}

JavaScript uses bigint for the Java long:

const value: bigint = document.identity(9007199254740993n);

Valid Kotlin object methods and static forms also avoid normal owner construction.

Use Android Context in a module-level API

When marked methods live on a normal class, V2 lazily creates one owner instance for that class in each active module session.

package com.example.document

import android.content.Context
import supernote.generated.annotations.SupernotePluginExport

class DocumentApi(private val context: Context) {
  @SupernotePluginExport
  fun cacheDirectory(): String = context.cacheDir.absolutePath
}

Context is injected by the runtime. It does not become a JavaScript argument:

const path: string = document.cacheDirectory();

Module-level owners may use one of these injected constructor shapes:

Api()
Api(Context)
Api(ReactApplicationContext)

Exactly one constructor must be eligible. If several could work, change the class so only one of those injected forms is eligible. The generator does not keep V1's constructor precedence or silently guess. SupernoteConstructor is for a JavaScript-owned object constructor, not this module-level owner.

Construction happens on the first routed call and the result is reused for that active module session. If user construction throws, that call fails and the broken owner is not cached. A later independent call may try again.

Leave ordinary JVM code unmarked

Public language visibility is not a Supernote export:

class DocumentApi(private val context: Context) {
  @SupernotePluginExport
  fun cacheDirectory(): String = context.cacheDir.absolutePath

  fun clearTemporaryState() {
    // ordinary Kotlin; JavaScript cannot call this
  }
}

This lets you change ordinary class design without accidentally changing the plugin's TypeScript API.

Use SupernotePluginInternal only when generated C++ or another generated route needs to call a JVM declaration without exposing it to JavaScript:

@SupernotePluginInternal
fun rebuildPage(page: Int): Int = page + 1

Read Using Both Environments for a complete internal-call example.

Supported Kotlin and Java values

Meaning Kotlin Java TypeScript
No value Unit void void
Boolean Boolean boolean boolean
32-bit integer Int int number
64-bit integer Long long bigint
32-bit float Float float number
64-bit float Double double number
String String String string
Bytes ByteArray byte[] Uint8Array

Marked values do not currently accept nullability, generic lists, maps, structs, enums, unsigned values, or arbitrary object parameters/results.

Use any JVM types you need inside ordinary implementation code and convert at the marked boundary.

Bytes

ByteArray and byte[] hold raw byte patterns. Java's signed byte spelling does not change the data.

Inputs are copied from exactly the visible part of a JavaScript Uint8Array. Results are copied into a new Uint8Array. Empty arrays work; null, ArrayBuffer, and other typed arrays are not accepted implicitly.

Do not pack one number or one point into bytes merely to avoid a normal JVM export. Small scalar calls are suitable for normal UI events. Batch when the caller already has a collection, especially when it replaces many generated C++ -> JVM calls.

Large byte values are dominated by copying. Keep the data in Kotlin or Java when only JVM code needs it, and return a small result instead of round-tripping the original buffer whenever possible.

64-bit integers

Kotlin Long and Java long always map to JavaScript bigint, never number. This avoids precision loss for IDs, timestamps, offsets, and file sizes.

Normal JVM exports are synchronous

A plain SupernotePluginExport returns its value before JavaScript continues. The call travels through JSI and JNI, but your Kotlin or Java method still runs as part of that JavaScript-thread call.

That is a good fit for a quick Android property lookup or small calculation. A blocking file, database, or network call can freeze the plugin UI. Use explicit async intent for work that may wait or take noticeable time.

Make a blocking JVM call asynchronous

A normal Kotlin or Java function can still expose a Promise API:

@SupernotePluginExport
@SupernotePluginAsync
fun loadPage(page: Int): ByteArray {
  return readPageBlocking(page)
}

JavaScript receives:

const bytes: Uint8Array = await document.loadPage(3);

The ordinary blocking implementation runs on the plugin's shared bounded worker executor. JavaScript returns immediately with a Promise instead of running the blocking function on its thread.

If the bounded queue is full, the Promise rejects with RESOURCE_EXHAUSTED.

Use a Kotlin suspend function

Kotlin suspend is the one initial JVM-native async implementation form with a dedicated adapter:

@SupernotePluginExport
@SupernotePluginAsync
suspend fun loadPage(page: Int): ByteArray {
  return repository.readPage(page)
}

The coroutine can suspend without occupying a worker thread while it waits. Its completion still uses the same Promise, error, cancellation, teardown, and runtime-safety rules as an ordinary blocking async function.

suspend does not automatically change the Supernote API. A suspend function without SupernotePluginAsync is rejected. Likewise, a normal function stays synchronous unless you add explicit async intent.

The public API does not expose AbortSignal or a custom cancellable operation object. Cancellation exists internally for feature/runtime teardown and remains cooperative.

Export a persistent Kotlin object

Mark the class when JavaScript should create and retain distinct implementation instances:

package com.example.document

import supernote.generated.annotations.SupernotePluginAsync
import supernote.generated.annotations.SupernotePluginExport

@SupernotePluginExport
class Document(private val path: String) {
  @SupernotePluginExport
  fun pageCount(): Int = countPages(path)

  @SupernotePluginExport
  @SupernotePluginAsync
  suspend fun loadPage(page: Int): ByteArray = readPage(path, page)

  fun clearTemporaryState() = Unit
}

This produces the conceptual TypeScript API:

const file = document.Document.create('/path/to/file.note');
const count = file.pageCount();
const bytes = await file.loadPage(0);

Each create call constructs a distinct JVM object. Marking the class exposes the object type and its one eligible constructor. Every other method still needs its own marker; clearTemporaryState remains ordinary Kotlin.

If the class has several eligible constructors, mark exactly one with SupernoteConstructor. Constructors are synchronous.

The same model works for a deliberately marked Java class.

Initial objects accept only the value types in the table above. Object parameters/results, returned-only objects, inheritance, properties, and custom factories are deferred.

Object lifetime and concurrency

If JavaScript starts an async object method and then drops the wrapper, the accepted operation keeps a strong JVM handle until the implementation can no longer access the receiver. It never relies on a temporary JNI local reference.

Garbage collection does not cancel accepted work.

The generator keeps the object alive, but it does not synchronize your object state. Two async calls—or one async and one synchronous call—may overlap. Use synchronized, a Kotlin Mutex, an actor, immutable state, or another design when your implementation is not already thread-safe.

Internal module services

Sometimes C++ needs one module-scoped JVM service rather than a JavaScript-owned object:

@SupernotePluginInternal
class IndexService(private val context: Context) {
  @SupernotePluginInternal
  fun rebuild(page: Int): Int = page + 1
}

V2 creates this service lazily and reuses one instance for that active module session. It has no JavaScript factory and does not appear in TypeScript.

One cached owner is not automatically thread-safe. Synchronize its state when generated calls can overlap.

Errors

Wrong JavaScript argument count or type throws TypeError. Invalid integer or range values throw RangeError before an async operation is accepted.

After acceptance, an async implementation failure rejects the Promise with a SupernoteError. An unexpected Kotlin/Java exception uses IMPLEMENTATION_ERROR; a generated/runtime failure uses INTERNAL. Backend exception class names are not a stable JavaScript API.

See Error Handling for complete try/catch examples, the stable codes, and the difference between a synchronous boundary error and a Promise rejection.

Regenerate and validate

After changing annotations, constructors, parameter types, or return types:

supernote-module update document --yes
supernote-module validate document --build --verbose

KSP reads what the Kotlin/Java compiler actually sees. Diagnostics point back to the source declaration that caused the problem.

Inspect the regenerated index.d.ts, then test the feature in the intended PluginHost. A local Gradle build cannot prove classloader behavior, coroutine completion, BigInt support, or same-process reload on the target device.

Clone this wiki locally