The JNI bindings (../jni) packaged for android: the java API, the native
library for every ABI, and the runtime data the renderer needs, in one artifact.
odr-core-android.aar
├── classes.jar app.opendocument.core (../jni/java) + OdrAndroid
├── jni/<abi>/libodr_jni.so the bindings with the core linked in
├── jni/<abi>/libc++_shared.so the c++ runtime they were built against
└── proguard.txt keeps the classes JNI resolves by name
ABIs: arm64-v8a, armeabi-v7a, x86, x86_64. minSdk 26.
repositories {
mavenCentral()
}
dependencies {
implementation "app.opendocument:odr-core-android:<version>"
}The same artifact is also on GitHub Packages, but prefer Central: GitHub
Packages needs a token with read:packages even for public packages — see
Publishing for what that rules out. Do not depend on
odr-core-java as well: the AAR carries the same classes.
import app.opendocument.core.*
val file = Odr.open(path)
val service = Html.translate(file, cacheDir.path, HtmlConfig())
val html = service.bringOffline(outputDir.path)Nothing needs initialising: the renderer's css and js are part of the native
library. OdrAndroid.init(context) is a deprecated no-op — it used to unpack
the assets that carried them — and stays callable, checked IOException and
all, so apps written against the older AAR keep compiling.
Serving the rendered HTML through HttpServer needs two things from the app,
neither of which a library may decide on its own: android.permission.INTERNET,
and permission for plain HTTP on loopback (android blocks cleartext from API 28
on) — a networkSecurityConfig with a domain-config for 127.0.0.1 is the
narrow way to grant it.
HttpServer.listen() blocks, so it runs on a thread of the app's. stop() and
close() return only once that thread is back out of it, so tearing the server
down needs no join or timeout of its own — and close() on its own is enough,
it stops the server before freeing it. A server that outlives one document is
best left listening with clear() between workloads: rebinding costs a port.
The AAR needs the native libraries first; build_native.py cross compiles them
through conan and cmake and lays them out where the gradle build reads them:
export ANDROID_HOME=~/Library/Android/sdk
./gradlew assembleRelease # builds all four ABIs on the way
./gradlew assembleRelease -Podr.abis=x86_64 # just one, for the emulator
./gradlew assembleRelease -Podr.abis= # none: use what is already there
python build_native.py --abi x86_64 # or drive it directlyAnything the script needs but cannot guess is a gradle property:
-Podr.conan=<path> (a conan outside PATH, e.g. in a virtualenv),
-Podr.buildProfile=<profile> (the conan profile of this machine),
-Podr.python=<path>.
The odrcore build is a normal one — ODR_JNI=ON, static core linked into
libodr_jni.so — driven by the android-<arch> conan profiles in
.github/config/conan/profiles, which pin the NDK and API 26.
The libraries ship unstripped — 60-72 MB per ABI, most of the AAR — so that a
consuming app's ndk.debugSymbolLevel can hand play what it needs to symbolicate
a crash inside the core. Devices never see it: play serves APKs built from the
stripped copies, so the weight is on maven central and developer builds only.
It takes both build_native.py not stripping and the
packaging.jniLibs.keepDebugSymbols rule in build.gradle.kts. Without the
second the first is invisible.
./gradlew lint # NewApi against minSdk 26, over ../jni/java too
./gradlew connectedDebugAndroidTest # on a running emulator or device
./gradlew spotlessApply # ktfmt, kotlinlang styleThe instrumented suite (src/androidTest) is the part that sees what a device
sees: it loads the native library, decodes and renders documents, drives a java log sink from native code, and
serves a document over HTTP. Its inputs come from ../jni/testfixtures, the
same ones the host junit suite uses.
CI (.github/workflows/android.yml) cross compiles each ABI, assembles and
lints the AAR, and runs the suite on API 26 — the floor OpenDocument.droid
ships to — and on a current API level.
Releases go to Maven Central and GitHub Packages, and only once the instrumented suite has passed on every API level — an AAR that assembles and lints is no evidence that it works on a device.
Maven Central is the one that matters. GitHub Packages demands read:packages
even to read a public artifact, which rules out the consumer this is ultimately
built for: f-droid builds from source with no credentials at all. It stays
published there only because the maven jar is, and dropping it would break
whoever already reads it.
app.opendocument is an established namespace on Central — pdf2htmlex-android
and wvware-android have been there since 2024 — so this is a new artifact under
an old name, not a new namespace. Central asks for more than GitHub Packages
does, and the module supplies it: sources and javadoc jars, a POM carrying
developers, and a PGP signature over every file. Signing is conditional on a
key being configured, so publishToMavenLocal and the GitHub Packages publish
still work without one; an unsigned upload is rejected by the portal, so the
release path stays guarded either way.
Publishing uploads a deployment to the portal and stops. Releasing it is a deliberate click in the portal UI, because Central never forgets a version — that click is the last point at which a bad artifact can be dropped rather than lived with.
For now OpenDocument.droid keeps building odrcore from the conan package
(with_jni=True), deploying libodr_jni.so and odr-core-java.jar out of it. That path is unaffected by anything here, and it is the reason the
two halves cannot drift: they come out of one build.
So the AAR is, for now, a second packaging of that same build — for consumers that just want a dependency, and the reason android gets compiled and exercised on every push at all.