Skip to content

Marshal const void* buffers, pass JNI includes, cross-compile ABI builds - #3

Open
lemcoder wants to merge 3 commits into
feature/jni-interopfrom
void-buffer-marshalling
Open

Marshal const void* buffers, pass JNI includes, cross-compile ABI builds#3
lemcoder wants to merge 3 commits into
feature/jni-interopfrom
void-buffer-marshalling

Conversation

@lemcoder

Copy link
Copy Markdown
Owner

Three gaps found while binding pdfium — a C++ library shipped as a prebuilt binary — for JVM, Android and Kotlin/Native from a single .def. Each one cost real debugging time, and each failed in a way that pointed somewhere other than the cause.

const void* stayed a raw address

pdfium's loader is FPDF_LoadMemDocument(const void* data, int size, const char* password). cinterop writes that parameter as CValuesRef<*>, which fell past the element-type match in classify and became a Long:

before: kniBridge5(p0: Long,       p1: Int, p2: String?): Long
after:  kniBridge5(p0: ByteArray?, p1: Int, p2: String?): Long

A JVM caller had no way to hand it a ByteArray without finding off-heap memory — the thing these bindings exist to avoid. const says the callee only reads, so it is data; a non-const void* is a COpaquePointer and still crosses as an address, which is right for a handle. The C stub needed nothing: jbyte* converts to const void* implicitly.

The stub could not find jni.h

The generated stub includes it, and the CMakeLists in the README supplies no path — so that example fails for anyone whose compiler does not have the JDK headers on its default path, which is most people using an IDE-bundled JBR. The plugin already chooses a JDK for the bindings, so it now passes that JDK's include roots as KONAN_JNI_INCLUDE_DIRS. find_package(JNI) would also work but is free to pick a different JDK than the one the bindings were generated against.

An ABI build used the host compiler

abi() means Android, and Android means cross-compiling, but no toolchain was passed. CMake used the host clang, compiled the stub for macOS, and then:

ld: unknown file type in '.../android-arm64/lib/libpdfium.so'

which says nothing about Android at all. The NDK is now found through ANDROID_NDK_HOME, then ndk.dir or sdk.dir in local.properties, then ANDROID_HOME, taking the newest when several are installed — and skipped entirely when the build names -DCMAKE_TOOLCHAIN_FILE itself. abi("arm64-v8a") { platform.set(26) } chooses the minimum API; it is android-21 otherwise.

What I tried and removed

I also added jvmInterops(sourceSet), to put bindings in a source set shared by a JVM and an Android target. You had already removed exactly that API in 83dcfc9, for exactly the reason it was wrong. I should have read the history before adding it. It is not in this branch — only the net change is.

Verification

Unit tests pass, including a new case for the void-buffer mapping. Beyond the tests, all three are exercised end to end in MikroMarkdown: the JVM leg converts a real PDF through the generated bridges, and the Android leg produces arm64-v8a and x86_64 stubs that are genuine ELF objects naming libpdfium.so.

Version bumped to 1.2.0-alpha06.

🤖 Generated with Claude Code

… builds

Three gaps found binding pdfium — a C++ library shipped as a prebuilt binary — for
JVM, Android and Kotlin/Native from one .def.

cinterop writes a const void* parameter as CValuesRef<*>, which fell through the
element-type match and stayed a raw address, so a JVM caller had no way to hand
FPDF_LoadMemDocument a ByteArray without finding off-heap memory. Const says the
callee only reads, so it is data; a non-const void* is a COpaquePointer and still
crosses as an address.

The generated stub includes jni.h and the documented CMakeLists had no way to find
it, so that example fails for anyone whose compiler lacks the JDK headers on its
default path. The plugin already picks a JDK for the bindings and now passes its
include roots as KONAN_JNI_INCLUDE_DIRS; find_package(JNI) would be free to choose
a different one.

An ABI build used the host compiler. abi() means Android, which means
cross-compiling, so the NDK toolchain is now supplied — found through
ANDROID_NDK_HOME, ndk.dir or sdk.dir in local.properties, or ANDROID_HOME, and
skipped when the build names a toolchain itself. Without it the failure is a linker
reporting an unknown file type, which says nothing about Android. abi {
platform.set(26) } chooses the minimum API.

Version bumped to 1.2.0-alpha06.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lemcoder and others added 2 commits August 20, 2026 22:33
cinterop numbers them, so a binding read `kniBridge54(handle)` where the C
API says `FPDFText_CountChars(text_page)`. The number is an artefact of the
generator and carries nothing: it makes the hand-written expect/actual on
top unreadable, and a linker error or stack frame names something you
cannot search for.

The name was already there. cinterop generates a friendly wrapper beside
each bridge, and the formatter was reading it to build the doc comment;
`parseBridgeNames` now takes the name from the same place and both
`stripCinterop` and `marshalStub` rename their side together.

JNI escapes `_` in a method name as `_1`, because `_` is the separator in
`Java_<package>_<class>_<method>`. Without that, `FPDFText_CountChars`
resolves as method `CountChars` on class `FPDFText`, and it compiles, links
and dies on the first call — the same failure a missing `@JvmName` gives.
Almost every C API worth binding has underscores in it, so the mangling is
covered by a test rather than left to a future reader.

A bridge keeps its number wherever the rename would be unsafe: two bridges
that would share a name, one bridge reached from wrappers that disagree, a
name the generated file already uses, or anything that is not a plain
identifier. Passing no names reproduces the old output exactly.

Verified on the jvm example, which now resolves `my_add`/`my_scale` and
prints 5 and 40.0, and against pdfium in MikroMarkdown, whose 176 bridges
rename with no numbers left and whose tests pass with real symbols.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`java.library.path` named `jvmInterop/mymath/jniLibs/<abi>`, but a host
build writes to `jvmInterop/mymath/lib/<abi>` — only ABI-named directories
may sit under jniLibs, which is AGP's contract and the reason for the
split. A clean build therefore failed with "no examplestubs in
java.library.path", and the example only appeared to work because an older
plugin version had left a stub in jniLibs that later runs kept loading.

That stale copy is also why the rename looked broken at first: the run
picked up a dylib still exporting kniBridge0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant