Skip to content

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

Merged
andiwand merged 3 commits into
mainfrom
feat/android-aar
Jul 28, 2026
Merged

feat(android): package the bindings as an AAR and test them on a device#629
andiwand merged 3 commits into
mainfrom
feat/android-aar

Conversation

@andiwand

@andiwand andiwand commented Jul 28, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

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 — the build_test matrix's single android entry only configured conan for one ABI, it never built the bindings, let alone ran them.

This adds android/, a gradle library module packaging the JNI bindings as app.opendocument:odr-core-android, and .github/workflows/android.yml, which on every push:

  1. cross compiles all four ABIs (armv7, armv8, x86, x86_64) — catches the first outright;
  2. lints NewApi over ../jni/java against minSdk 26 — catches android.* and the java.* APIs core library desugaring cannot backport (Cleaner), but not the desugarable ones, so it is a filter, not a proof;
  3. assembles + lints the AAR, and runs an instrumented suite on an API 26 emulator (the floor OpenDocument.droid ships to) and on API 36 — the only check that sees what a device sees.

A binding is only covered once something in src/androidTest calls it; the suite currently decodes and renders documents, reads the bundled assets, drives a java log sink from native code, and serves a document over HTTP.

The 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
├── assets/core/odrcore/*              css/js of the html renderer
├── assets/core/libmagic/magic.mgc     libmagic database
└── proguard.txt                       keeps the classes JNI resolves by name

main compiles ../jni/java verbatim, so the AAR and the maven jar are the same classes and cannot drift. The only android-specific production code is OdrAndroid.init(context): an APK holds the runtime data as assets and the renderer wants files, so it unpacks them into no-backup storage keyed by Odr.commitHash() and registers them with GlobalParams. build_native.py drives conan + cmake per ABI into native/prebuilt, where the gradle build reads them; CI runs it as one job per ABI and hands the results to the AAR and emulator jobs as artifacts.

OdrAndroid and the instrumented suite are Kotlin, matching OpenDocument.droid. What the module 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. The public surface keeps its Java shape — OdrAndroid is an object with @JvmStatic init and @Throws(IOException::class), so OdrAndroid.init(context) is still a static call throwing a checked exception. The one cost: the AAR's POM now declares kotlin-stdlib where it previously had no dependencies. odr-core-java is unaffected — still pure Java, still dependency-free. Formatting is spotless + ktfmt 0.64 kotlinlang, the same version droid runs, checked in the format workflow.

For now this is a second packaging of the same build, not a new path. OpenDocument.droid keeps building odrcore from the conan package (with_jni=True), which keeps the two halves in lockstep by construction. The intent is for the AAR to become droid's base, but that needs Maven Central first: GitHub Packages demands read:packages even to read a public artifact, and f-droid builds from source with no credentials. Verifying the app.opendocument namespace and signing the artifacts is separate work, not in this PR.

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 value 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.

Verification

Built and run locally on an API 31 arm64 emulator: 16/16 instrumented tests pass, lint is clean against minSdk 26 with warningsAsErrors, and the release AAR assembles with all its assets and both prebuilt ABIs. CI is the first exercise of the four-ABI cross build and the API 26 emulator.

Docs: android/README.md (usage, building, testing) and android/AGENTS.md.

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>
Claude-Session: https://claude.ai/code/session_01QqTNzXTxzfeCA2w1ErQzax

@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: 228cc0dbfe

ℹ️ 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 .github/workflows/android.yml
andiwand and others added 2 commits July 28, 2026 07:41
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>
Claude-Session: https://claude.ai/code/session_01QqTNzXTxzfeCA2w1ErQzax
… 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>
Claude-Session: https://claude.ai/code/session_01QqTNzXTxzfeCA2w1ErQzax
@andiwand
andiwand merged commit b1a097a into main Jul 28, 2026
26 checks passed
@andiwand
andiwand deleted the feat/android-aar branch July 28, 2026 19:40
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