-
Notifications
You must be signed in to change notification settings - Fork 0
Kotlin and Java Modules
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.
supernote-module doctor
supernote-module add document --starter kotlin --yesThe 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.
Kotlin:
import supernote.generated.annotations.SupernoteConstructor
import supernote.generated.annotations.SupernotePluginAsync
import supernote.generated.annotations.SupernotePluginExport
import supernote.generated.annotations.SupernotePluginInternalJava 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.
A top-level Kotlin function is the simplest form:
package com.example.document
import supernote.generated.annotations.SupernotePluginExport
@SupernotePluginExport
fun pageCount(): Int = 42JavaScript 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.
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.
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.
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 + 1Read Using Both Environments for a complete internal-call example.
| 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.
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.
Kotlin Long and Java long always map to JavaScript bigint, never
number. This avoids precision loss for IDs, timestamps, offsets, and file
sizes.
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.
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.
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.
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.
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.
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.
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.
After changing annotations, constructors, parameter types, or return types:
supernote-module update document --yes
supernote-module validate document --build --verboseKSP 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.