Repository navigation
Native JNI Modules
A module can use the native C/C++ environment, the Kotlin/Java JVM environment, or both. They remain useful independently even when they live in the same module.
Most mixed modules do not need a direct cross-language call. C++ can export one function, Kotlin can export another, and JavaScript can call both through the same generated API.
Use a generated internal route when native C++ really does need to call Kotlin or Java without exposing that helper to JavaScript.
For example:
- C++ reads or processes a Supernote file;
- Kotlin asks Android for a
Context, opens a platform API, or uses a JVM library; - JavaScript gets one module API that hides the handoff between them.
You do not create a Native module and a JVM module. Both environments belong to the same generated module package.
Create both starters at once:
sn-module-gen add document \
--starter cpp \
--starter kotlin \
--yesOr add the second environment to an existing module later:
local_modules/document/android/src/main/
├── cpp/
│ ├── document.cpp
│ └── document.hpp
└── java/<namespace-path>/
└── DocumentApi.kt
The starter choice only creates example files. It does not lock the module to a backend or language.
A public function may be implemented in C++:
#include <cstdint>
// @SupernotePluginExport
std::int32_t pageCount() {
return 42;
}Another public function in the same module may be implemented in Kotlin:
@SupernotePluginExport
fun deviceModel(): String = android.os.Build.MODELJavaScript uses both from the same module:
import document from 'document';
function onReadDocumentInfo() {
const count = document.pageCount();
const model = document.deviceModel();
// Use count and model here.
}The generator checks the combined module API. If C++ and Kotlin both export
pageCount, generation fails instead of silently picking one.
Mark the JVM declaration as internal when generated C++ needs to reach it but JavaScript should not see it:
import supernote.generated.annotations.SupernotePluginInternal
@SupernotePluginInternal
fun internalJvmPage(page: Int): Int = page + 3Regenerate the complete author project and validate it:
sn-module-gen update --yes
sn-module-gen validate --verboseThe generator writes a typed C++ facade below:
local_modules/document/.supernote-generated/android/internal.hpp
Find the exact generated header for the feature:
find local_modules/document/.supernote-generated \
-name internal.hpp -printThen include the package-local generated header and use its actual namespace. The generated package CMake exposes the adapter include directory to the module:
#include "internal.hpp"
// @SupernotePluginExport
std::int32_t rebuildFromJavaScript(std::int32_t page) {
return supernote::internal::DocumentFeature::internalJvmPage(page);
}Copy the namespace from your generated header; DocumentFeature above is an
example. Run the app build separately after generation and validation.
The call flow is:
JavaScript
-> exported C++ function
-> generated typed internal facade
-> JNI
-> Kotlin/Java function
internalJvmPage does not appear in TypeScript. Only
rebuildFromJavaScript does.
For the overall device results and a shorter route-selection guide, read Performance and Benchmarks. This section focuses on the generated native-to-JVM boundary.
One generated call per live UI event is not automatically a problem. In a historical pre-public debug plugin measured on one Supernote Nomad, a tiny call with two numbers took about:
JavaScript -> JSI -> C++ 2.3 microseconds
JavaScript -> JSI -> JNI -> Kotlin 3.6 microseconds
JavaScript -> JSI -> JNI -> Java 3.7 microseconds
Those numbers are specific to that device, firmware, build, and small test function. They are useful for scale, not a performance promise.
The route to avoid in a large loop is C++ calling a generated JVM function once for every item:
for (const Point &point : points) {
total += supernote::internal::DocumentFeature::scorePoint(
point.x, point.y);
}That generated internal route includes feature-session checks, JNI setup, conversion, exception checks, and Supernote error mapping on every call. The same benchmark measured roughly 19 microseconds for each tiny C++ -> Kotlin call after warm-up.
If the points already form one job, cross once and keep the loop in Kotlin or Java:
@SupernotePluginInternal
fun scorePoints(points: ByteArray): Double {
// Decode and process the complete batch here.
}The measured difference for 4,096 tiny points was about:
C++ -> generated JNI -> Kotlin once per point 78.0 milliseconds
C++ -> generated JNI -> Kotlin once per batch 0.07 milliseconds
The count-only batch above was deliberately the cheapest possible control. If C++ actually owns all the coordinates, passing one packed 32 KiB byte buffer and decoding it in Kotlin took about 3.45 milliseconds. That was still about 23 times faster than 4,096 separate generated JNI calls.
For public JavaScript calls, a prebuilt packed point buffer became faster than individual scalar calls somewhere between 4 and 16 points in this test. But packing one point was slower than passing two numbers, and JavaScript packing time was not included. Use bytes when you already have a real collection to move, not as ceremony around every scalar call.
The practical rule is:
Small scalar calls are cheap enough for normal UI events. If you already have a group of values, send one batch. This matters most when C++ calls Kotlin or Java: do the loop on the JVM side instead of crossing generated JNI once per item. Large strings and byte arrays are dominated by copying, so keep data on the side that owns it and return only what the caller needs.
Batching reduces repeated bridge work. It does not make a long synchronous operation safe for the JavaScript thread. Mark potentially blocking work async even when it accepts one well-designed batch.
Use an internal class when several generated calls should share one JVM service instance for the feature session:
@SupernotePluginInternal
class JvmIndexService {
@SupernotePluginInternal
fun rebuild(page: Int): Int = page + 17
}The generated C++ facade gives the service a nested name:
const auto value =
supernote::internal::DocumentFeature::JvmIndexService::rebuild(page);The generator constructs the service lazily and reuses it for that feature session. It has no JavaScript factory. The generator does not add a mutex, so synchronize mutable service state when calls may overlap.
Internal calls use the same explicit async intent as public calls:
@SupernotePluginInternal
@SupernotePluginAsync
fun internalJvmBlocking(page: Int): ByteArray {
return readPageBlocking(page)
}The generated C++ facade uses a completion callback, not a JavaScript Promise:
static void internalJvmBlocking(
std::int32_t page,
std::function<void(
supernote::Result<std::vector<std::byte>>)> completion);An ordinary blocking implementation runs on the shared bounded worker
executor. A deliberately supported Kotlin suspend implementation uses the
coroutine adapter. Both still follow the same cancellation, teardown, and
single-completion rules.
If this internal operation is wrapped by a public async export, that outer export is what creates and settles the JavaScript Promise.
See Error Handling for a
complete supernote::Result<T> callback example, stable error codes, callback
threading, and teardown behavior.
The current generator does not create this direction. There is no generated
Kotlin-facing version of internal.hpp.
It is technically possible to handwrite ordinary JNI. User .c, .cc, .cpp
and .cxx files compile only when author CMake selects them, and the linker must
retain the required symbols from module static archives. The shape is the usual
JVM native method and matching JNI entry point:
package com.example.document
object NativeBridge {
@JvmStatic
external fun checksum(value: Int): Int
}#include <jni.h>
jint nativeChecksum(jint value) {
return value + 7;
}
extern "C" JNIEXPORT jint JNICALL
Java_com_example_document_NativeBridge_checksum(
JNIEnv *, jclass, jint value) {
return nativeChecksum(value);
}This is an advanced unmanaged escape hatch, not a generated Supernote route. The developer owns the exact JVM name and descriptor, JNI conversions, local and global references, exceptions, threading, and native lifetime rules. The handwritten boundary does not automatically gain Supernote type validation, cancellation, error mapping, or feature-session checks. If it is called inside a marked Kotlin or Java export, the outer export still has its normal generated behavior, but the generator cannot validate or manage what happens inside the handwritten JNI call.
It also has an important PluginHost limitation. A fresh process may resolve the name-based JNI method, while a same-process plugin reload may reuse the native library with a new child classloader. The generated runtime explicitly re-registers its own native methods for that case; it does not register arbitrary handwritten methods. A handwritten bridge must therefore be tested for both first load and same-process reload, and should not be presented as a reload-safe generated capability.
For the current generator, prefer independent exports from each environment. When one environment must orchestrate the other, the supported generated direction is native C++ calling a marked Kotlin or Java declaration.
Generated public and internal routes use the same closed type system:
| Meaning | C++ | Kotlin/Java |
|---|---|---|
| No value | void |
Unit / void
|
| Boolean | bool |
Boolean / boolean
|
| 32-bit integer | std::int32_t |
Int / int
|
| 64-bit integer | std::int64_t |
Long / long
|
| 32-bit float | float |
Float / float
|
| 64-bit float | double |
Double / double
|
| String | std::string |
String |
| Bytes | std::vector<std::byte> |
ByteArray / byte[]
|
| String enum | marked enum class
|
marked Kotlin/Java enum |
| Declared value | marked aggregate class/struct | marked data/record/final value class |
| Typed array | std::vector<T> |
List<T> |
| Nullable | std::optional<T> |
T? / JSpecify @Nullable T
|
Declared copied values, enums, arrays, and nullable compositions can cross a generated C++/JVM internal route when both families provide the same logical schema. Native reference objects cannot cross that family boundary in the current generator: C++ objects route only to C++, and Kotlin/Java objects remain on the JVM. Cross-family object proxies are deferred without changing the public JS/TS type model. Maps, arbitrary JavaScript objects, mixed arrays, and callbacks remain unsupported.
Strings use UTF-8 when crossing native/JNI boundaries. Bytes are copied as raw
8-bit patterns, so Java's signed byte spelling does not change the data.
For marked declarations, generation handles:
- JVM compiler discovery and stable adapter entry points;
- JNI descriptors, class loading, and owned reference lifetimes;
- C++ caller facades for generated internal routes;
- argument and result conversion;
- async worker or coroutine routing;
- feature/runtime lifetime checks;
- the single plugin-wide runtime and worker services;
- the final JSI and TypeScript API.
The current release does not generate a general Kotlin-facing facade for calling arbitrary internal C++ declarations. The supported generated cross-language caller described on this page is C++ calling marked JVM declarations.
After changing either language:
sn-module-gen update --yes
sn-module-gen validate --verboseCheck three things before testing the plugin:
-
.supernote-generated/index.d.tscontains only the intended JavaScript exports. - The generated
internal.hppcontains the expected hidden C++ calls. - The plugin builds from fresh generated output.
A local build proves generation and compilation. First load, same-process reload, class loading, Promise completion, and teardown still need testing in the target PluginHost.
For language-specific examples, continue with Native Environment: C and C++ or JVM Environment: Kotlin and Java.