Skip to content

Use odrcore's own JNI bindings instead of the hand-written wrapper - #515

Merged
andiwand merged 5 commits into
mainfrom
use-core-jni-bindings
Jul 26, 2026
Merged

Use odrcore's own JNI bindings instead of the hand-written wrapper#515
andiwand merged 5 commits into
mainfrom
use-core-jni-bindings

Conversation

@andiwand

@andiwand andiwand commented Jul 26, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

odrcore ships JNI bindings as of 5.7.0: a java API under app.opendocument.core and the matching libodr_jni.so, both built behind the recipe's with_jni option. This replaces the hand-written wrapper with them, pinned to 5.7.1.

What goes away

  • app/src/main/cpp/ in full (core_wrapper.{cpp,hpp}, tmpfile_hack.{cpp,hpp}), app/CMakeLists.txt, and the externalNativeBuild blocks. The app compiles no native code at all now.
  • CoreWrapper. It existed to flatten native failures into integer error codes (-2 encrypted, -5 unexpected format, …) and mirror them back into Core*Exception types; the bindings raise typed OdrExceptions directly, so both halves are dead. 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 exactly as it already does for OnlineLoader, and MetadataLoader calls Odr.mimetype directly.
  • The tmpfile() interposition. 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 remains 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.

Shipping both halves

with_jni=True in conanfile.txt produces libodr_jni.so 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 there is no externalNativeBuild. Verified in the apk:

lib/arm64-v8a/libc++_shared.so     lib/arm64-v8a/libodr_jni.so
lib/armeabi-v7a/libc++_shared.so   lib/armeabi-v7a/libodr_jni.so
lib/x86/libc++_shared.so           lib/x86/libodr_jni.so
lib/x86_64/libc++_shared.so        lib/x86_64/libodr_jni.so

The java half comes out of that same package: it sits in share/java/odr-core-java.jar, and the deployer puts it in build/conan/<arch>/libs while still skipping it when copying assets, so it is not shipped twice. app/build.gradle depends on the armv8 copy as a file, builtBy the conan install task.

No repository and no credentials are involved. The jar is also published to GitHub Packages, but that needs authentication even though the package is public — which F-Droid, who build this app from source in a clean environment, cannot supply. Taking it from the conan package keeps ./gradlew working with nothing configured, and closes a version-skew hole as a side effect: the classes and the .so are one ABI with no version negotiation, and they used to be pinned separately in libs.versions.toml and conanfile.txt with only a comment holding them together.

The 5.7.1 pin

The bindings as first released referenced java.lang.ref.Cleaner (android API 33) in NativeResource's static initializer and List.of (API 34) in Html; minSdk here is 26, so every core object died with NoClassDefFoundError on device — API 34 emulators passed and every older one failed. There is no consumer-side fix: core library desugaring does not cover java.lang.ref (verified on device, not just from the docs), and an app cannot ship a java.* class. opendocument-app/OpenDocument.core#621 fixes it upstream and is released in 5.7.1.

Two things move together: odrcore/5.7.1 in app/conanfile.txt, and the conan-odr-index submodule moved to the commit that adds the 5.7.1 recipe. The java half needs no separate bump — it comes out of the pinned package.

Verification

Everything below ran against the #621 bindings that 5.7.1 releases:

  • ./gradlew assembleProDebug, testProDebugUnitTest, lintProDebug, spotlessCheck — all green, from a clean and with GITHUB_ACTOR/GITHUB_TOKEN unset, which is the F-Droid case. The 100 binding classes land in the apk dex and the jar stays out of the assets.
  • ./gradlew connectedProDebugAndroidTest12/12 on a Pixel 6 Pro AVD (API 31), covering odt/docx/xlsx/pdf loading, password-protected documents (absent, wrong and correct password) and both edit-mode paths.

One regression was caught by that suite and is worth noting, because it is not obvious from the diff: serving pages from http://127.0.0.1:… instead of http://localhost:… silently breaks the WebView, since network_security_config.xml permits cleartext for the domain localhost only. The server binds to the loopback address and the URLs stay on localhost; both constants are now named and commented.

Notes for review

  • CoreLoader.host() is the one entry point for translating and publishing a file. RawLoader uses it for text files with keepDocument = false; it previously reached the core with a cachePath that was never set, which arrived native-side as a null path.
  • The proguard keeps move from CoreWrapper to app.opendocument.core.**libodr_jni.so resolves HtmlConfig and friends by name via FindClass/GetFieldID and constructs the OdrException subclasses itself.
  • CoreTest drives a real CoreLoader rather than statics, and asserts on thrown OdrExceptions where it used to assert on error codes.

andiwand and others added 3 commits July 26, 2026 13:42
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>
Claude-Session: https://claude.ai/code/session_016jT7GGUHvUnDvf8UMcQSLk
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>
Claude-Session: https://claude.ai/code/session_016jT7GGUHvUnDvf8UMcQSLk
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
andiwand marked this pull request as ready for review July 26, 2026 14:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28a3e173be

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread settings.gradle Outdated
andiwand and others added 2 commits July 26, 2026 17:17
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>
Claude-Session: https://claude.ai/code/session_016jT7GGUHvUnDvf8UMcQSLk
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>
Claude-Session: https://claude.ai/code/session_0151dSFxsWcv4mP7tiy7jwyf
@andiwand
andiwand merged commit 0466080 into main Jul 26, 2026
2 checks passed
@andiwand
andiwand deleted the use-core-jni-bindings branch July 26, 2026 15:36
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