Skip to content

fix(jni): keep the java API loadable on android API 26 - #621

Merged
andiwand merged 1 commit into
mainfrom
jni-native-resource-api26
Jul 26, 2026
Merged

fix(jni): keep the java API loadable on android API 26#621
andiwand merged 1 commit into
mainfrom
jni-native-resource-api26

Conversation

@andiwand

Copy link
Copy Markdown
Member

The JNI bindings cannot be loaded at all on OpenDocument.droid (minSdk = 26). Two java APIs used by the bindings only exist on much newer android, and both fail on device rather than at build time.

java.lang.ref.Cleaner — android API 33

NativeResource creates a Cleaner in its static initializer, so every handle type — DecodedFile, Document, HtmlService, HttpServer, Element, … — dies the first time it is touched:

java.lang.NoClassDefFoundError: Failed resolution of: Ljava/lang/ref/Cleaner;
  at app.opendocument.core.NativeResource.<clinit>(NativeResource.java:16)

There is no consumer-side fix: core library desugaring (desugar_jdk_libs_nio 2.1.5) does not cover java.lang.ref — I verified that on device, not just from the docs — and an app cannot ship a class in a java.* package, android's class loader rejects it.

NativeResource now does what Cleaner does internally: one PhantomReference per resource, enqueued on a shared ReferenceQueue, drained by a daemon thread.

  • The pending references are kept in a set. A phantom reference that is itself collected is never enqueued, and the native object behind it would leak instead of being freed — Cleaner holds its Cleanables for the same reason.
  • close() and the post-mortem path both go through one compareAndSet, so a resource is destroyed exactly once and close() stays idempotent.
  • No change to the public contract: still AutoCloseable, still freed at the latest when the wrapper is collected.

The record Destroyer goes with it — D8 desugars records, but there is no reason to depend on that.

List.of — android API 34

Two calls in Html, on the offline-output path, now use Collections.unmodifiableList(Arrays.asList(...)).

Documentation

jni/AGENTS.md gains the API 26 floor as an explicit design rule, listing the traps that are easy to reach for from --release 17: Cleaner, List.of/Set.of/Map.of, Optional.isEmpty.

Verification

javac --release 17 -Xlint:all clean, and — the part that actually matters, since this is a runtime-only failure — the patched java API was built into the artifact odr.droid consumes and run through its instrumented suite on a Pixel 6 Pro AVD (API 31): 12/12 green, covering odt/docx/xlsx/pdf loading, password-protected documents and both edit-mode paths. Before the fix the Cleaner error took down every test that touches the core.

The JNI JUnit suite in jni/tests is unaffected by the change in behaviour and runs on the JDK, where both APIs exist either way; CI covers it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016jT7GGUHvUnDvf8UMcQSLk

OpenDocument.droid has `minSdk = 26`, and android's `java.*` is far behind
what the `--release 17` compiler accepts. Two APIs in the bindings only
exist on much newer android, and both fail on device rather than at build
time:

- `java.lang.ref.Cleaner` (android API 33) sits in `NativeResource`'s static
  initializer, so *every* handle type - DecodedFile, Document, HtmlService,
  HttpServer, Element - dies with `NoClassDefFoundError: java.lang.ref.
  Cleaner` the first time it is touched. Core library desugaring does not
  cover `java.lang.ref`, and an app cannot supply a `java.*` class itself, so
  this can only be fixed here.
- `List.of` (android API 34) in `Html`, on the offline-output path.

`NativeResource` now does what a Cleaner does internally: a
`PhantomReference` per resource, enqueued on a shared `ReferenceQueue` and
drained by a daemon thread. The pending references are held in a set,
because a phantom reference that is collected itself never gets enqueued and
would leak the native object instead of freeing it. `close()` and the
post-mortem path both go through the same `compareAndSet`, so a resource is
still destroyed exactly once and `close()` stays idempotent - no change to
the public contract.

`Html` builds its unmodifiable lists with `Collections.unmodifiableList(
Arrays.asList(...))` instead.

AGENTS.md gains the API 26 floor as an explicit design rule, with the traps
that are easy to reach for (`Cleaner`, `List.of`/`Set.of`/`Map.of`,
`Optional.isEmpty`).

Verified by building the patched java API into the artifact odr.droid
consumes and running its instrumented suite on a Pixel 6 Pro AVD (API 31):
12/12 green, where the Cleaner failure previously took down every test that
touches the core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jT7GGUHvUnDvf8UMcQSLk
@andiwand
andiwand merged commit 50021e1 into main Jul 26, 2026
17 of 18 checks passed
@andiwand
andiwand deleted the jni-native-resource-api26 branch July 26, 2026 14:39
andiwand added a commit to opendocument-app/OpenDocument.droid that referenced this pull request Jul 26, 2026
odrcore's java bindings used java.lang.ref.Cleaner, which only exists from
API 33, so every instrumented test below that failed to load the classes
with NoClassDefFoundError while API 34 passed. v5.7.1 carries the upstream
fix (opendocument-app/OpenDocument.core#621).

The conan recipe, the odr-core-java maven artifact and the index submodule
move together: the java classes and libodr_jni.so are one ABI with no
version negotiation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jT7GGUHvUnDvf8UMcQSLk
andiwand added a commit to opendocument-app/OpenDocument.droid that referenced this pull request Jul 26, 2026
)

* Replace the hand-written JNI wrapper with odrcore's own bindings

odrcore 5.7.0 ships JNI bindings: a java API under `app.opendocument.core`
published to github packages as `app.opendocument:odr-core-java`, and the
matching `libodr_jni.so` that the recipe builds behind its `with_jni`
option. That makes `src/main/cpp/core_wrapper.cpp` redundant, so the whole
`app/src/main/cpp` tree and the CMake build around it are gone.

The java artifact carries no native code, so the .so still has to be built
and shipped here: `with_jni=True` in conanfile.txt produces it per
architecture, and conandeployer.py copies it into `build/conan/<arch>/
jniLibs/<abi>` together with the `libc++_shared.so` it links - nothing else
pulls the c++ runtime into the apk now that the app compiles no native code
of its own.

CoreWrapper is gone with the C++ it wrapped. It existed to flatten native
failures into integer error codes and mirror them back into exception
types; the bindings raise typed OdrExceptions instead. Its remaining job
was process-wide state, which moves into CoreLoader: the one-time core
setup, the single http server that RawLoader also publishes text files on,
and the open document that retranslate edits. LoaderService hands RawLoader
the CoreLoader the same way it already does for OnlineLoader, and
MetadataLoader calls Odr.mimetype directly.

The tmpfile() interposition in tmpfile_hack.cpp goes away with it. It only
ever worked because the app linked the core into a library it compiled
itself, and libodr_jni.so is a prebuilt. Nothing in the android option set
calls tmpfile() anymore either - only wvware did, and it is compiled out -
so what is left is core resolving std::filesystem::temp_directory_path(),
which falls back to a /tmp that does not exist on android. Setting TMPDIR
before the first native call covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Wire CI and the docs for the github packages dependency

The bindings artifact lives on github packages, which authenticates every
read even for public packages. Both workflows now ask for the packages: read
permission and hand GITHUB_ACTOR/GITHUB_TOKEN to the gradle steps, including
the one inside the emulator runner. README gets the personal access token a
local build needs.

CLAUDE.md described the native wrapper that no longer exists: the JNI
interface, the cpp directory, the GetFieldID contract that forced @JvmField
onto CoreWrapper's fields, and the claim that renaming the namespace means
touching JNI symbols - none of which is true now that the bindings live in
odrcore's own app.opendocument.core package. It gains the two constraints
that replace them: the maven artifact and the conan pin are one version, and
everything the bindings touch has to exist on android API 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Bump odrcore to 5.7.1 for the android API 26 JNI fix

odrcore's java bindings used java.lang.ref.Cleaner, which only exists from
API 33, so every instrumented test below that failed to load the classes
with NoClassDefFoundError while API 34 passed. v5.7.1 carries the upstream
fix (opendocument-app/OpenDocument.core#621).

The conan recipe, the odr-core-java maven artifact and the index submodule
move together: the java classes and libodr_jni.so are one ABI with no
version negotiation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Take odr-core-java from the conan package instead of github packages

The jar was resolved from github packages, which needs authentication even
though the package is public. F-Droid builds this app from source in a clean
environment with no credentials, so the dependency would have failed there
with a 401 and broken the F-Droid distribution.

The odrcore conan package already ships the jar in share/java, right next to
the libodr_jni.so it belongs to - the deployer was throwing it away. Deploy
it to build/conan/<arch>/libs instead and depend on it as a file.

That also removes the version skew this had by construction: the java classes
and the native library are one ABI with no version negotiation, and they were
pinned separately in libs.versions.toml and conanfile.txt with only a comment
holding them together. Both halves now come out of the same package.

Verified with a clean build with GITHUB_ACTOR and GITHUB_TOKEN unset:
assembleProDebug, testProDebugUnitTest, lintProDebug and spotlessCheck all
pass, the 100 binding classes land in the apk dex, and the jar is still kept
out of the assets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the edit mode tests 30s to see contenteditable

testODTEditMode and testDOCXEditMode are the flakiest tests in the suite: they
fail on API 26 and 30 across unrelated branches, including a dependabot action
bump, while the same commit passes elsewhere. The document loads fine in those
runs - every other test in the job passes - the webview just needs longer than
10s to report contenteditable on a cold CI emulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andiwand added a commit that referenced this pull request Jul 28, 2026
…ce (#629)

* feat(android): package the bindings as an AAR and test them on a device

Two android-only failure modes shipped in a row: #628, a compile error in
`jni/src` that only the NDK sees, and #621, a `java.lang.ref.Cleaner` that
exists on the JDK and not on android's class library. Both were found by
OpenDocument.droid, because it was the only thing that ever cross compiled
this, and the build_test matrix's single android entry only ever configured
conan for one ABI — it never built the bindings, let alone ran them.

`android/` closes that: a gradle library module that packages the JNI
bindings as `app.opendocument:odr-core-android`, and `.github/workflows/
android.yml`, which on every push cross compiles all four ABIs, lints
`../jni/java` against minSdk 26, assembles the AAR, and runs an instrumented
suite on API 26 (the floor OpenDocument.droid ships to) and on API 36.

The AAR carries `classes.jar` (the same `../jni/java` the maven jar is built
from, plus `OdrAndroid`), `libodr_jni.so` and `libc++_shared.so` per ABI, the
renderer's CSS/JS and the libmagic database as assets, and a consumer proguard
rule keeping the classes JNI resolves by name. `OdrAndroid.init(context)`
unpacks the assets into no-backup storage, keyed by `Odr.commitHash()`, and
registers them with `GlobalParams` — an APK holds them as assets, and the
renderer wants files.

`build_native.py` drives conan + cmake per ABI into `native/prebuilt`, where
the gradle build reads them; CI runs it once per ABI as a separate job and
hands the results to the AAR and emulator jobs as artifacts.

This is a second packaging of the same build, not a new path: OpenDocument.droid
keeps consuming the conan package (`with_jni=True`), which needs no credentials,
as f-droid requires. The AAR is for consumers that just want a dependency.

Along the way:

* The four `android-<arch>` conan profiles replace `android-35-x86_64` and
  share one `android.jinja` body. API level drops 35 -> 26 to match the java
  floor (it is baked into the target triple, so a higher one produces
  libraries older devices refuse to load) and the NDK toolchain path is
  resolved per host, so the profiles work on macOS too. `scripts/conan_lock`
  locks each of them against the linux build profile.
* `jni/CMakeLists.txt` builds `odr_jni` without a JDK when targeting android:
  the NDK sysroot ships `jni.h` and the symbols come from the runtime. A JDK
  found there still builds the jar, which is what the conan package deploys
  next to the library; the junit suite stays host-only.
* `TestFiles` moves to `jni/testfixtures`, compiled by both suites, so it is
  limited to what API 26 offers — `String.formatted`, `Path.of` and
  `Files.writeString` are gone from it.
* `Color.toString` formats with `Locale.ROOT`; a device set to a locale with
  its own digits rendered it unparseable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(android)!: write the AAR's own code in kotlin

OpenDocument.droid is kotlin throughout, and it is the consumer this AAR is
built for, so the module it will depend on should not be the odd one out. The
two things this module writes itself move over: `OdrAndroid` and the
instrumented suite.

What it *borrows* stays java, and has to. `../jni/java` and
`../jni/testfixtures` are compiled by CMake's `add_jar` for the maven jar and
the host junit suite, neither of which has a kotlin toolchain — so the java API
is unchanged, and `src/androidTest` compiles kotlin against the java
`TestFiles` in the same package, which resolves the package-private members
fine.

The public surface keeps its java shape: `OdrAndroid` is an `object` with
`@JvmStatic init`, so `OdrAndroid.init(context)` is still a static call, and
`@Throws(IOException::class)` keeps the exception checked for a java caller.

Kotlin earns its place mostly by deleting code: `File.deleteRecursively`,
`InputStream.copyTo` and `use` replace three hand-rolled helpers in
`OdrAndroid`, and `readBytes()` replaces the buffer loop in `HttpServerTest`.
Net -76 lines across the six files, with the same tests.

BREAKING CHANGE: `odr-core-android` now declares a `kotlin-stdlib` dependency
(2.2.10, AGP 9's built-in kotlin). The POM had none before, which is what
`android.builtInKotlin=false` was protecting; for OpenDocument.droid it costs
nothing, and it is the price of the module being kotlin at all. Nothing about
`odr-core-java` changes — that jar is still pure java with no dependencies.

Also adds spotless + ktfmt 0.64 in the kotlinlang flavor, the same formatter
and version OpenDocument.droid runs, scoped to this module's own kotlin so an
android build never rewrites the maven jar's sources. `spotlessCheck` runs in
the format workflow, which needs neither the NDK nor conan.

Verified on an API 31 emulator: 16/16 instrumented tests pass, lint is clean
against minSdk 26, and the release AAR assembles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(android): store the gradle wrapper jar unmangled, gate publishing on the device suite

`.gitattributes` declares `* text eol=lf`, so committing the wrapper jar
normalized the two CR bytes out of it: the blob is 48460 bytes where the
jar gradle publishes is 48462, and `setup-gradle`'s wrapper validation
rejects the checkout outright. Declare `*.jar binary` and renormalize;
the blob now hashes to gradle's published
497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7.

Publishing moves out of `aar` into its own job needing both `aar` and
`instrumented`. As siblings off `native`, `aar` could push a release to
GitHub Packages before — or despite — the emulator suite, which is the
one check that sees what a device sees.

Co-Authored-By: Claude Opus 5 (1M context) <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