From 228cc0dbfe8d518186903f0e2d0fd17550b16631 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 28 Jul 2026 07:20:52 +0200 Subject: [PATCH 1/3] feat(android): package the bindings as an AAR and test them on a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-` 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) Claude-Session: https://claude.ai/code/session_01QqTNzXTxzfeCA2w1ErQzax --- .github/config/conan/profiles/android-armv7 | 2 + .github/config/conan/profiles/android-armv8 | 2 + .github/config/conan/profiles/android-x86 | 2 + .github/config/conan/profiles/android-x86_64 | 2 + .../{android-35-x86_64 => android.jinja} | 26 +- .github/workflows/android.yml | 219 ++++++++++++++++ .github/workflows/build_test.yml | 8 +- .gitignore | 6 + AGENTS.md | 1 + android/AGENTS.md | 53 ++++ android/README.md | 107 ++++++++ android/build.gradle.kts | 192 ++++++++++++++ android/build_native.py | 147 +++++++++++ android/consumer-rules.pro | 11 + android/gradle.properties | 7 + android/gradle/libs.versions.toml | 16 ++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48460 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + android/gradlew | 248 ++++++++++++++++++ android/gradlew.bat | 82 ++++++ android/settings.gradle.kts | 18 ++ android/src/androidTest/AndroidManifest.xml | 11 + .../app/opendocument/core/DocumentTest.java | 126 +++++++++ .../app/opendocument/core/HttpServerTest.java | 128 +++++++++ .../app/opendocument/core/LoggerTest.java | 138 ++++++++++ .../app/opendocument/core/OdrAndroidTest.java | 60 +++++ .../app/opendocument/core/TestSupport.java | 42 +++ android/src/main/AndroidManifest.xml | 6 + .../opendocument/core/android/OdrAndroid.java | 99 +++++++ jni/AGENTS.md | 15 +- jni/CMakeLists.txt | 37 ++- jni/README.md | 4 + jni/java/app/opendocument/core/Color.java | 6 +- .../app/opendocument/core/TestFiles.java | 27 +- scripts/conan_lock | 12 +- 35 files changed, 1834 insertions(+), 35 deletions(-) create mode 100644 .github/config/conan/profiles/android-armv7 create mode 100644 .github/config/conan/profiles/android-armv8 create mode 100644 .github/config/conan/profiles/android-x86 create mode 100644 .github/config/conan/profiles/android-x86_64 rename .github/config/conan/profiles/{android-35-x86_64 => android.jinja} (51%) create mode 100644 .github/workflows/android.yml create mode 100644 android/AGENTS.md create mode 100644 android/README.md create mode 100644 android/build.gradle.kts create mode 100644 android/build_native.py create mode 100644 android/consumer-rules.pro create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 android/src/androidTest/AndroidManifest.xml create mode 100644 android/src/androidTest/java/app/opendocument/core/DocumentTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/HttpServerTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/LoggerTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/TestSupport.java create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/java/app/opendocument/core/android/OdrAndroid.java rename jni/{tests => testfixtures}/app/opendocument/core/TestFiles.java (81%) diff --git a/.github/config/conan/profiles/android-armv7 b/.github/config/conan/profiles/android-armv7 new file mode 100644 index 000000000..2d2758f0a --- /dev/null +++ b/.github/config/conan/profiles/android-armv7 @@ -0,0 +1,2 @@ +{% set arch = "armv7" %} +{% include "android.jinja" %} diff --git a/.github/config/conan/profiles/android-armv8 b/.github/config/conan/profiles/android-armv8 new file mode 100644 index 000000000..d860e7fe4 --- /dev/null +++ b/.github/config/conan/profiles/android-armv8 @@ -0,0 +1,2 @@ +{% set arch = "armv8" %} +{% include "android.jinja" %} diff --git a/.github/config/conan/profiles/android-x86 b/.github/config/conan/profiles/android-x86 new file mode 100644 index 000000000..88eae9154 --- /dev/null +++ b/.github/config/conan/profiles/android-x86 @@ -0,0 +1,2 @@ +{% set arch = "x86" %} +{% include "android.jinja" %} diff --git a/.github/config/conan/profiles/android-x86_64 b/.github/config/conan/profiles/android-x86_64 new file mode 100644 index 000000000..c1b3b7d59 --- /dev/null +++ b/.github/config/conan/profiles/android-x86_64 @@ -0,0 +1,2 @@ +{% set arch = "x86_64" %} +{% include "android.jinja" %} diff --git a/.github/config/conan/profiles/android-35-x86_64 b/.github/config/conan/profiles/android.jinja similarity index 51% rename from .github/config/conan/profiles/android-35-x86_64 rename to .github/config/conan/profiles/android.jinja index 2f98cd06a..7c851aa89 100644 --- a/.github/config/conan/profiles/android-35-x86_64 +++ b/.github/config/conan/profiles/android.jinja @@ -1,7 +1,15 @@ +{# Shared body of the `android-*` profiles; the includer sets `arch`. + Not usable on its own — always include it from a per-ABI profile. + + `api_level` is the *minimum* android version the native code runs on, and it + is baked into the target triple, so it has to match the java floor of the AAR + (`android/`, minSdk 26) — a higher value produces libraries the linker + refuses to load on older devices. #} {% set android_home = os.getenv("ANDROID_HOME") %} {% set ndk_version = "28.1.13356709" %} -{% set api_level = "35" %} -{% set arch = "x86_64" %} +{% set api_level = "26" %} +{% set host_tag = "darwin-x86_64" if platform.system() == "Darwin" else "linux-x86_64" %} +{% set toolchain = android_home + "/ndk/" + ndk_version + "/toolchains/llvm/prebuilt/" + host_tag %} {% set cc = { "armv7": "armv7a-linux-androideabi" + api_level + "-clang", "armv8": "aarch64-linux-android" + api_level + "-clang", @@ -27,10 +35,10 @@ tools.cmake.cmaketoolchain:extra_variables={'CMAKE_CXX_COMPILER_LAUNCHER': 'ccac # Cross compile toolchain evn vars are required to build # libffi, libgettext, libiconv, libxml2 and other autotools packages for Android # https://github.com/conan-io/conan/issues/16493 -AR={{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar -AS={{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-as -RANLIB={{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib -CC=ccache {{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/{{cc}} -CXX=ccache {{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/{{cc}}++ -LD={{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/ld -STRIP={{android_home}}/ndk/{{ndk_version}}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip +AR={{toolchain}}/bin/llvm-ar +AS={{toolchain}}/bin/llvm-as +RANLIB={{toolchain}}/bin/llvm-ranlib +CC=ccache {{toolchain}}/bin/{{cc}} +CXX=ccache {{toolchain}}/bin/{{cc}}++ +LD={{toolchain}}/bin/ld +STRIP={{toolchain}}/bin/llvm-strip diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..2182c8dde --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,219 @@ +name: android + +on: + push: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: version to publish (e.g. 6.0.0-test1) + required: true + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +# See the cache-key comment in `build_test.yml` for why the keys look like this. +env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 1G + CCACHE_KEY_SUFFIX: r1 + CONAN_HOME: ${{ github.workspace }}/.conan2 + CONAN_KEY_SUFFIX: r1 + NDK_VERSION: 28.1.13356709 + +jobs: + # One cross build per ABI, uploaded as an artifact so the AAR job below can + # assemble all of them without cross compiling four times in one runner. + native: + runs-on: ubuntu-24.04 + env: + CACHE_FLAVOR: aar + strategy: + fail-fast: false + matrix: + architecture: [armv7, armv8, x86, x86_64] + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: checkout conan-odr-index + run: git submodule update --init --depth 1 conan-odr-index + + - name: install ccache + run: | + sudo apt install ccache + ccache -V + + - name: setup python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: 3.14 + - name: install python dependencies + run: pip install conan + + - name: install NDK + run: yes | ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${NDK_VERSION}" + + - name: conan cache key + shell: bash + run: echo "CONAN_CACHE_KEY=$(git rev-parse HEAD:conan-odr-index)-${{ hashFiles('conanfile.py', '.github/config/conan/**') }}" >> "$GITHUB_ENV" + + - name: cache conan + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CONAN_HOME }} + key: conan-${{ env.CACHE_FLAVOR }}-android-${{ matrix.architecture }}-${{ env.CONAN_KEY_SUFFIX }}-${{ env.CONAN_CACHE_KEY }} + restore-keys: | + conan-${{ env.CACHE_FLAVOR }}-android-${{ matrix.architecture }}-${{ env.CONAN_KEY_SUFFIX }}- + + - name: export conan-odr-index + run: python conan-odr-index/scripts/conan_export_all_packages.py --selection-config conan-odr-index/defaults.yaml + - name: conan config + run: conan config install .github/config/conan + + - name: restore ccache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-${{ env.CACHE_FLAVOR }}-android-${{ matrix.architecture }}-${{ env.CCACHE_KEY_SUFFIX }}-${{ github.run_id }} + restore-keys: | + ccache-${{ env.CACHE_FLAVOR }}-android-${{ matrix.architecture }}-${{ env.CCACHE_KEY_SUFFIX }}- + + # Cross compiling the bindings is the cheap half of the guard: #628 was a + # compile error in jni/src that only android sees, and no host job can. + - name: build + run: > + python android/build_native.py + --abi ${{ matrix.architecture }} + --build-profile ubuntu-24.04-clang-18 + + - name: save ccache + if: github.ref == 'refs/heads/main' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-${{ env.CACHE_FLAVOR }}-android-${{ matrix.architecture }}-${{ env.CCACHE_KEY_SUFFIX }}-${{ github.run_id }} + + - name: upload native libraries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: android-native-${{ matrix.architecture }} + path: android/native/prebuilt + if-no-files-found: error + + aar: + needs: native + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: setup java + uses: actions/setup-java@f4f1212c880fdec8162ea9a6493f4495191887b4 # v5 + with: + distribution: temurin + java-version: 21 + + - name: setup gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + # every ABI's artifact carries the same assets/, so they merge into the + # one prebuilt tree the gradle build reads + - name: download native libraries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: android-native-* + merge-multiple: true + path: android/native/prebuilt + + - name: get version + if: github.event_name != 'push' + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "${{ github.event_name }}" = release ]; then + echo "ODR_VERSION=${GITHUB_REF_NAME:1}" >> $GITHUB_ENV + else + echo "ODR_VERSION=${INPUT_VERSION}" >> $GITHUB_ENV + fi + + # -Podr.abis= : the libraries are the artifacts downloaded above, so the + # build must not try to cross compile them again + - name: lint + working-directory: android + run: ./gradlew lint -Podr.abis= + + - name: assemble + working-directory: android + run: ./gradlew assembleRelease -Podr.abis= + + - name: upload aar + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: odr-core-android + path: android/build/outputs/aar/*.aar + if-no-files-found: error + + - name: publish + if: github.event_name != 'push' + working-directory: android + run: ./gradlew publishReleasePublicationToGitHubPackagesRepository -Podr.abis= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The other half of the guard, and the only one that sees what a device sees: + # API 26 is the floor OpenDocument.droid ships to, where a java.* method the + # JDK has and android does not throws at the call (#621). + instrumented: + needs: native + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + api-level: [26, 36] + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: setup java + uses: actions/setup-java@f4f1212c880fdec8162ea9a6493f4495191887b4 # v5 + with: + distribution: temurin + java-version: 21 + + - name: setup gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + # the emulator is x86_64, and nothing here needs the other ABIs + - name: download native libraries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: android-native-x86_64 + path: android/native/prebuilt + + - name: enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: instrumented tests + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: ${{ matrix.api-level >= 30 && 'google_apis' || 'default' }} + working-directory: android + script: ./gradlew connectedDebugAndroidTest -Podr.abis= + + - name: upload test report + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: instrumented-report-${{ matrix.api-level }} + path: android/build/reports/androidTests diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index cc86d447d..e9fb4b36f 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -43,7 +43,9 @@ jobs: - { os: macos-15, build_profile: macos-15-armv8-clang-14, host_profile: macos-15-armv8-clang-14 } - { os: macos-26, build_profile: macos-26-armv8-clang-14, host_profile: macos-26-armv8-clang-14, bindings: true } - { os: windows-2022, build_profile: windows-2022-msvc-1940, host_profile: windows-2022-msvc-1940 } - - { os: ubuntu-24.04, build_profile: ubuntu-24.04-clang-18, host_profile: android-35-x86_64, ndk_version: 28.1.13356709 } + # android is covered by `android.yml`, which cross compiles the core + # *and* the JNI bindings for every ABI the AAR ships and runs them on + # an emulator steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -75,10 +77,6 @@ jobs: - name: install python dependencies run: pip install conan ${{ matrix.bindings && 'pytest' || '' }} - - name: install NDK - if: startsWith(matrix.host_profile, 'android') - run: yes | ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install 'ndk;${{ matrix.ndk_version }}' - - name: conan cache key shell: bash run: echo "CONAN_CACHE_KEY=$(git rev-parse HEAD:conan-odr-index)-${{ hashFiles('conanfile.py', '.github/config/conan/**') }}" >> "$GITHUB_ENV" diff --git a/.gitignore b/.gitignore index 662417869..140a129c4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,12 @@ build/ cmake-build-*/ jni/target/ jni/.flattened-pom.xml +## Android AAR (gradle); `build/` above already covers android/build and +## android/native/build +android/.gradle/ +android/.kotlin/ +android/local.properties +android/native/prebuilt/ .clangd CMakeUserPresets.json # machine-specific Conan cache paths, generated by scripts/gen-vscode-env.py diff --git a/AGENTS.md b/AGENTS.md index fc2b43d95..331b7b39a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme | `cli/src/` | CLI tools: `translate`, `back_translate`, `meta`, `server`. | | `python/` | Python bindings (`pyodr`, pybind11); see [`python/AGENTS.md`](python/AGENTS.md). | | `jni/` | JNI bindings (Java package `app.opendocument.core`); see [`jni/AGENTS.md`](jni/AGENTS.md). | +| `android/` | The bindings packaged as an AAR (`odr-core-android`) + the instrumented tests; see [`android/AGENTS.md`](android/AGENTS.md). | | `tools/pdf/` | Dev tooling (not built): PDF encoding-data generators, see `tools/pdf/README.md`. | | `test/src/` | GoogleTest suites; data in `test/data` (git submodules). | | `offline/documentation/MS-*/` | Vendored Microsoft spec text (see [Specs](#specs)). | diff --git a/android/AGENTS.md b/android/AGENTS.md new file mode 100644 index 000000000..65740c052 --- /dev/null +++ b/android/AGENTS.md @@ -0,0 +1,53 @@ +# AGENTS.md — the android AAR + +Packages the JNI bindings (`../jni`) as `app.opendocument:odr-core-android` and +is where android specific behaviour is tested. Read [`../jni/AGENTS.md`](../jni/AGENTS.md) +first — the java API and its android constraints live there. User facing docs: +[`README.md`](README.md). + +## Layout + +| Path | What | +|------|------| +| `build.gradle.kts` | The library module: sources from `../jni/java`, prebuilt native libs and assets, lint, publishing. Single project — `rootProject.name` *is* the artifactId. | +| `build_native.py` | conan + cmake per ABI → `native/prebuilt/{jniLibs,assets}`. Invoked by the `buildNative` gradle task and directly by CI. | +| `src/main/java/.../android/OdrAndroid.java` | The only android specific production code: extracts the bundled assets and registers them with `GlobalParams`. | +| `src/androidTest/` | Instrumented suite, JUnit 4 + androidx.test, inputs from `../jni/testfixtures`. | +| `consumer-rules.pro` | Keeps `app.opendocument.core.**` — JNI resolves it by name, R8 cannot see that. | + +## Why it exists + +Two android-only failure modes had shipped before this: a `jni/src` compile +error only the NDK sees (#628), and java APIs that exist on the JDK but not on +android's class library (#621). Both are cheap to catch and impossible to catch +host side, so the workflow does: + +1. **cross compile every ABI** — covers the first outright; +2. **lint `NewApi` against minSdk 26** over `../jni/java` — covers android.\* and + the java.\* APIs core library desugaring cannot backport (`Cleaner`), but + *not* the desugarable ones (`List.of`, `Optional.isEmpty`), so it is a filter, + not a proof; +3. **the instrumented suite on an API 26 emulator** — the only check that sees + what a device sees, and the one that settles the rest. + +A new binding is therefore only covered once something in `src/androidTest` +calls it. + +## Rules + +- **Do not duplicate the java API here.** `main` compiles `../jni/java` + verbatim; the AAR and the maven jar are the same classes, and anything + android-only goes into the `app.opendocument.core.android` package. +- **Shared test inputs stay in `../jni/testfixtures`**, which both suites + compile — so it is limited to what android API 26 offers (no `Path.of`, no + `Files.writeString`, no `String.formatted`). +- **`native/` is build output**, never committed; the artifacts CI downloads + land in the same place. `-Podr.abis=` (empty) is what tells the build the + libraries are already there. +- Keep AGP and gradle in step with OpenDocument.droid — it is the consumer that + finds the incompatibilities first. +- The manifest stays permission-free; permissions the server needs are the + app's call, and documented in `README.md` instead. +- Assets keep the `core/odrcore` + `core/libmagic` layout of the droid conan + deployer, so an app can move between the two packagings without touching its + extraction code. diff --git a/android/README.md b/android/README.md new file mode 100644 index 000000000..d750ca064 --- /dev/null +++ b/android/README.md @@ -0,0 +1,107 @@ +# odr-core-android — the AAR + +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//libodr_jni.so the bindings with the core linked in +├── jni//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 +``` + +ABIs: `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`. minSdk 26. + +## Using it + +```gradle +repositories { + maven { + url = uri("https://maven.pkg.github.com/opendocument-app/OpenDocument.core") + credentials { + username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR") + password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN") + } + } +} + +dependencies { + implementation "app.opendocument:odr-core-android:" +} +``` + +GitHub Packages needs a token with `read:packages` even for public packages. Do +not depend on `odr-core-java` as well — the AAR carries the same classes. + +```java +import app.opendocument.core.*; +import app.opendocument.core.android.OdrAndroid; + +OdrAndroid.init(context); // extracts the bundled assets, loads the library + +DecodedFile file = Odr.open(path); +HtmlService service = Html.translate(file, cacheDir.getPath(), new HtmlConfig()); +Html html = service.bringOffline(outputDir.getPath()); +``` + +`OdrAndroid.init` is idempotent and cheap after the first call: the assets are +unpacked once per library build, into the app's no-backup storage, and the +library is pointed at them via `GlobalParams`. + +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. + +## Building + +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: + +```bash +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 directly +``` + +Anything the script needs but cannot guess is a gradle property: +`-Podr.conan=` (a conan outside PATH, e.g. in a virtualenv), +`-Podr.buildProfile=` (the conan profile of *this* machine), +`-Podr.python=`. + +The odrcore build is a normal one — `ODR_JNI=ON`, static core linked into +`libodr_jni.so` — driven by the `android-` conan profiles in +`.github/config/conan/profiles`, which pin the NDK and API 26. + +## Testing + +```bash +./gradlew lint # NewApi against minSdk 26, over ../jni/java too +./gradlew connectedDebugAndroidTest # on a running emulator or device +``` + +The instrumented suite (`src/androidTest`) is the part that sees what a device +sees: it loads the native library, extracts and reads the bundled assets, +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 builder 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. + +## Relation to the conan package + +OpenDocument.droid does not consume this artifact: it builds odrcore from the +conan package (`with_jni=True`) and deploys `libodr_jni.so`, `odr-core-java.jar` +and the assets out of it, which keeps the two halves in lockstep by +construction and the build free of credentials, as f-droid requires. That path +is unaffected by anything here — the AAR is a second packaging of the same +build, for consumers that just want a dependency, and the reason android now +gets compiled and exercised on every push. diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..8c4d95136 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,192 @@ +plugins { + alias(libs.plugins.android.library) + `maven-publish` +} + +// The version is injected on release, the way the maven jar takes -Drevision. +// Everything else is a snapshot, which nothing consumes by version. +val odrVersion = + providers.gradleProperty("odr.version") + .orElse(providers.environmentVariable("ODR_VERSION")) + .filter { it.isNotEmpty() } + .map { it.removePrefix("v") } + .getOrElse("0.0.0-SNAPSHOT") + +// Which ABIs `build_native.py` is asked for. Set it to nothing +// (`-Podr.abis=`) when the libraries were built elsewhere and dropped into +// native/prebuilt, which is what CI does with its per-ABI build artifacts. +val nativeAbis = + providers.gradleProperty("odr.abis") + .getOrElse("armv7,armv8,x86,x86_64") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + +val buildNative = + tasks.register("buildNative") { + description = "Builds libodr_jni.so for odr.abis into native/prebuilt" + commandLine( + buildList { + add(providers.gradleProperty("odr.python").getOrElse("python3")) + add(file("build_native.py").absolutePath) + nativeAbis.forEach { + add("--abi") + add(it) + } + providers.gradleProperty("odr.conan").orNull?.let { + add("--conan") + add(it) + } + providers.gradleProperty("odr.buildProfile").orNull?.let { + add("--build-profile") + add(it) + } + } + ) + // conan and cmake do their own up-to-date checking, and modelling the + // whole C++ tree as inputs here would only duplicate it badly — a task + // that declares no outputs runs every time, which is what we want + enabled = nativeAbis.isNotEmpty() + } + +// An AAR without the native library or without its assets builds and publishes +// happily and then fails at runtime in whatever app picked it up, so make the +// absence a build error. +val checkNative = + tasks.register("checkNative") { + dependsOn(buildNative) + val prebuilt = layout.projectDirectory.dir("native/prebuilt") + val required = listOf("jniLibs", "assets").map { prebuilt.dir(it).asFile } + doLast { + val missing = required.filter { it.list().isNullOrEmpty() } + if (missing.isNotEmpty()) { + throw GradleException( + "nothing in ${missing.joinToString()} — run android/build_native.py, " + + "or pass -Podr.abis= to have the build run it" + ) + } + } + } + +tasks.named("preBuild") { dependsOn(checkNative) } + +android { + namespace = "app.opendocument.core" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // the instrumented apk carries the bindings and the 8 MB libmagic database, + // and pushing that onto a cold emulator outlasts ddmlib's default timeout, + // which surfaces as a ShellCommandUnresponsiveException rather than as a + // failing test + installation { timeOutInMs = 10 * 60 * 1000 } + + sourceSets { + named("main") { + // the same java API the maven jar is built from, so the two + // artifacts cannot drift apart + java.srcDir("../jni/java") + jniLibs.srcDir("native/prebuilt/jniLibs") + assets.srcDir("native/prebuilt/assets") + } + named("androidTest") { + // the inputs the host junit suite builds inline, shared verbatim + java.srcDir("../jni/testfixtures") + } + } + + lint { + // The static half of the android guard, over the java API of the maven + // jar too, since that is the same source set. NewApi covers android.* + // and the java.* APIs that core library desugaring cannot backport — + // `java.lang.ref.Cleaner` of #621 among them — but it stays quiet about + // the desugarable ones (`List.of`, `Optional.isEmpty`), so the + // instrumented run on an API 26 emulator is what actually settles it. + abortOnError = true + warningsAsErrors = true + checkTestSources = true + // consumed by github code scanning, so findings show up inline on PRs + sarifReport = true + + // "a newer compileSdk is available" is a calendar event, not a defect, + // and as an error it breaks CI the day google ships an SDK + disable += "GradleDependency" + // the java API is shared with the desktop jar, where + // `System.load()` is how a caller points the JVM at a + // library outside java.library.path; android never takes that branch + disable += "UnsafeDynamicallyLoadedCode" + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +dependencies { + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) +} + +publishing { + publications { + register("release") { + groupId = "app.opendocument" + artifactId = "odr-core-android" + version = odrVersion + + afterEvaluate { from(components["release"]) } + + pom { + name = "odr-core-android" + description = + "Android bindings for OpenDocument.core — decode office documents " + + "(ODF, OOXML, legacy MS binary, PDF, CSV, ...) and render them to HTML" + url = "https://github.com/opendocument-app/OpenDocument.core" + licenses { + license { + name = "MPL-2.0" + url = "https://www.mozilla.org/en-US/MPL/2.0/" + } + } + scm { + connection = "scm:git:https://github.com/opendocument-app/OpenDocument.core.git" + developerConnection = + "scm:git:git@github.com:opendocument-app/OpenDocument.core.git" + url = "https://github.com/opendocument-app/OpenDocument.core" + } + } + } + } + + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/opendocument-app/OpenDocument.core") + credentials { + username = + providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")) + .orNull + password = + providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + .orNull + } + } + } +} diff --git a/android/build_native.py b/android/build_native.py new file mode 100644 index 000000000..a11c51fff --- /dev/null +++ b/android/build_native.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Build the native half of the AAR: `libodr_jni.so` per android ABI, plus the +runtime assets, laid out the way `build.gradle.kts` expects. + + android/build_native.py --abi x86_64 --abi armv8 + +Each ABI gets its own conan install (`android-` host profile) and cmake +build under `android/native/build/`, and the results are copied into +`--output` (default `android/native/prebuilt`), which the gradle build reads as +its jniLibs and assets source sets: + + prebuilt/jniLibs//libodr_jni.so the bindings, core linked in + prebuilt/jniLibs//libc++_shared.so from the NDK, see below + prebuilt/assets/core/odrcore/* css/js of the html renderer + prebuilt/assets/core/libmagic/magic.mgc libmagic database + +`libc++_shared.so` has to be shipped because the android profiles build against +the shared c++ runtime and nothing else in a consuming app pulls it in; the +asset layout mirrors what OpenDocument.droid's conan deployer already produces. +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +ANDROID_ROOT = REPO_ROOT / "android" +PROFILE_DIR = REPO_ROOT / ".github" / "config" / "conan" / "profiles" + +# conan architecture -> android ABI directory name, and the NDK toolchain triple +# `libc++_shared.so` lives under +ABIS = { + "armv7": ("armeabi-v7a", "arm-linux-androideabi"), + "armv8": ("arm64-v8a", "aarch64-linux-android"), + "x86": ("x86", "i686-linux-android"), + "x86_64": ("x86_64", "x86_64-linux-android"), +} + + +def run(command: list[str], **kwargs) -> None: + print("+ " + " ".join(str(part) for part in command), flush=True) + subprocess.run([str(part) for part in command], check=True, **kwargs) + + +def ndk_path(conan: str, host_profile: str, build_profile: str) -> Path: + """The NDK the host profile points at, straight out of the rendered profile.""" + result = subprocess.run( + [conan, "profile", "show", "--format=json", + "--profile:host", host_profile, "--profile:build", build_profile], + check=True, + capture_output=True, + text=True, + ) + path = json.loads(result.stdout)["host"]["conf"].get("tools.android:ndk_path") + if not path: + raise SystemExit(f"'{host_profile}' does not set tools.android:ndk_path") + return Path(path) + + +def libcxx_shared(ndk: Path, triple: str) -> Path: + matches = sorted(ndk.glob(f"toolchains/llvm/prebuilt/*/sysroot/usr/lib/{triple}/libc++_shared.so")) + if not matches: + raise SystemExit(f"no libc++_shared.so for {triple} under {ndk}") + return matches[0] + + +def strip(ndk: Path, library: Path) -> None: + """The NDK compiles with `-g` in every configuration, so a release build of + the bindings is ~70 MB until it is stripped.""" + matches = sorted(ndk.glob("toolchains/llvm/prebuilt/*/bin/llvm-strip")) + if not matches: + raise SystemExit(f"no llvm-strip under {ndk}") + run([matches[0], "--strip-unneeded", library]) + + +def build(architecture: str, conan: str, build_profile: str, output: Path) -> None: + abi, triple = ABIS[architecture] + host_profile = str(PROFILE_DIR / f"android-{architecture}") + build_dir = ANDROID_ROOT / "native" / "build" / architecture + cmake_dir = build_dir / "cmake" + + run([conan, "install", REPO_ROOT, + "--options", "&:with_jni=True", + "--profile:host", host_profile, + "--profile:build", build_profile, + "--output-folder", build_dir, + "--build", "missing"]) + + run(["cmake", "-B", cmake_dir, "-S", REPO_ROOT, + "-DCMAKE_TOOLCHAIN_FILE=" + str(build_dir / "conan_toolchain.cmake"), + "-DCMAKE_BUILD_TYPE=Release", + # one self-contained library: the core is linked into the bindings + # rather than shipped as a second .so the app would have to load + "-DBUILD_SHARED_LIBS=OFF", + "-DODR_JNI=ON", + "-DODR_CLI=OFF", + "-DODR_TEST=OFF", + "-DODR_WITH_LIBMAGIC=ON", + "-DODR_WITH_HTTP_SERVER=ON", + "-DODR_BUNDLE_ASSETS=ON"]) + run(["cmake", "--build", cmake_dir, "--target", "odr_jni", + "--parallel", str(os.cpu_count() or 1)]) + + ndk = ndk_path(conan, host_profile, build_profile) + jni_libs = output / "jniLibs" / abi + jni_libs.mkdir(parents=True, exist_ok=True) + for source in (cmake_dir / "jni" / "libodr_jni.so", libcxx_shared(ndk, triple)): + target = jni_libs / source.name + shutil.copy2(source, target) + strip(ndk, target) + + # architecture independent, so the last ABI built simply wins + data = cmake_dir / "data" + assets = output / "assets" / "core" + shutil.rmtree(assets, ignore_errors=True) + shutil.copytree(data, assets / "odrcore", ignore=shutil.ignore_patterns("magic.mgc")) + (assets / "libmagic").mkdir(parents=True, exist_ok=True) + shutil.copy2(data / "magic.mgc", assets / "libmagic" / "magic.mgc") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--abi", dest="abis", action="append", choices=sorted(ABIS), + help="conan architecture to build; repeatable, defaults to all") + parser.add_argument("--conan", default=os.environ.get("ODR_CONAN", "conan"), + help="conan executable (ODR_CONAN)") + parser.add_argument("--build-profile", default="default", + help="conan build profile, i.e. the profile of this machine") + parser.add_argument("--output", type=Path, default=ANDROID_ROOT / "native" / "prebuilt", + help="where to lay out jniLibs/ and assets/") + args = parser.parse_args() + + if not os.environ.get("ANDROID_HOME"): + raise SystemExit("ANDROID_HOME is not set; the android profiles resolve the NDK from it") + + for architecture in args.abis or sorted(ABIS): + build(architecture, args.conan, args.build_profile, args.output.resolve()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 000000000..8399eb8b3 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,11 @@ +# The native library resolves the java side by name: `FindClass`, +# `GetMethodID`, `GetFieldID` and enum ordinals (see `jni/src/jni_convert.hpp` +# and `jni_style.cpp`). R8 has no way to see those references, so anything it +# renames or strips turns into a NoSuchMethodError from a native frame at +# runtime — keep the whole binding surface instead. +-keep class app.opendocument.core.** { *; } + +# Native methods and the classes declaring them. +-keepclasseswithmembernames class * { + native ; +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..3762d8160 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,7 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g +org.gradle.configuration-cache=true + +# nothing here is kotlin, and AGP 9 would otherwise hand every consumer a +# kotlin-stdlib dependency +android.builtInKotlin=false diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 000000000..3a1b64994 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,16 @@ +[versions] +# kept in step with OpenDocument.droid, which consumes this artifact +agp = "9.3.1" + +junit = "4.13.2" +androidxTest = "1.7.0" +androidxTestJunit = "1.3.0" + +[libraries] +junit = { module = "junit:junit", version.ref = "junit" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTest" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTest" } +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } + +[plugins] +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..94f4c1424df07f74d6e889be0ccf878326ced454 GIT binary patch literal 48460 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 000000000..a51ec4f58 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 000000000..ba6730437 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + google() + mavenCentral() + } +} + +// also the maven artifactId of the AAR +rootProject.name = "odr-core-android" diff --git a/android/src/androidTest/AndroidManifest.xml b/android/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..cf58ac995 --- /dev/null +++ b/android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/android/src/androidTest/java/app/opendocument/core/DocumentTest.java b/android/src/androidTest/java/app/opendocument/core/DocumentTest.java new file mode 100644 index 000000000..aa845e0ee --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/DocumentTest.java @@ -0,0 +1,126 @@ +package app.opendocument.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Decoding and rendering on a device, mirroring the host suite in {@code jni/tests}. What is android + * specific here is the runtime: every one of these calls crosses into the same java API on an + * android class library, where a method the JDK has and android does not fails only when it is + * reached (#621). + */ +@RunWith(AndroidJUnit4.class) +public class DocumentTest { + private Path tempDir; + + @Before + public void setUp() throws IOException { + TestSupport.initialize(); + tempDir = TestSupport.tempDir("document"); + } + + private Document openDocument() throws IOException { + Path odt = TestFiles.odtFile(tempDir); + return Odr.open(odt.toString()).asDocumentFile().document(); + } + + private static List walkText(Element element) { + List parts = new ArrayList<>(); + if (element.type() == ElementType.TEXT) { + parts.add(element.asText().content()); + } + for (Element child : element.children()) { + parts.addAll(walkText(child)); + } + return parts; + } + + @Test + public void elementTree() throws IOException { + Document document = openDocument(); + assertEquals(DocumentType.TEXT, document.documentType()); + assertEquals(FileType.OPENDOCUMENT_TEXT, document.fileType()); + + Element root = document.rootElement(); + assertEquals(ElementType.ROOT, root.type()); + + List text = walkText(root); + assertTrue(text.contains(TestFiles.ODT_FIRST_PARAGRAPH)); + // exercises non-BMP characters across the JNI string conversion + assertTrue(text.contains(TestFiles.ODT_SECOND_PARAGRAPH)); + } + + @Test + public void elementNavigation() throws IOException { + Element root = openDocument().rootElement(); + + Element first = root.firstChild(); + assertNotNull(first); + assertTrue(first.parent().isSame(root)); + Element second = first.nextSibling(); + assertNotNull(second); + assertTrue(second.previousSibling().isSame(first)); + assertNotNull(first.documentPath().toString()); + } + + @Test + public void documentFilesystem() throws IOException { + assertTrue(openDocument().asFilesystem().isFile("/content.xml")); + } + + @Test + public void fileMeta() throws IOException { + try (DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString())) { + FileMeta meta = file.fileMeta(); + assertEquals(FileType.OPENDOCUMENT_TEXT, meta.type); + assertFalse(meta.passwordEncrypted); + assertEquals(EncryptionState.NOT_ENCRYPTED, file.encryptionState()); + } + } + + @Test + public void translateToHtml() throws IOException { + Path cache = Files.createDirectories(tempDir.resolve("cache")); + Path output = Files.createDirectories(tempDir.resolve("output")); + + DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); + HtmlService service = Html.translate(file, cache.toString(), new HtmlConfig()); + Html html = service.bringOffline(output.toString()); + + List pages = html.pages(); + assertEquals(1, pages.size()); + // the renderer reads the css/js the AAR ships, so this only passes with the + // extracted assets in place + String content = read(Paths.get(pages.get(0).path)); + assertTrue(content.contains(TestFiles.ODT_FIRST_PARAGRAPH)); + } + + @Test + public void translateCsv() throws IOException { + Path cache = Files.createDirectories(tempDir.resolve("csv-cache")); + DecodedFile file = Odr.open(TestFiles.csvFile(tempDir).toString()); + HtmlService service = Html.translate(file, cache.toString(), new HtmlConfig()); + + List views = service.listViews(); + assertEquals(1, views.size()); + assertTrue(views.get(0).writeHtml().html.contains("alpha")); + } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/HttpServerTest.java b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.java new file mode 100644 index 000000000..cf45c2bbd --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.java @@ -0,0 +1,128 @@ +package app.opendocument.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * The HTTP server on a device. It is the one part of the bindings that runs the library on threads + * cpp-httplib started rather than on a java one, which is where the android JNI implementation is + * least forgiving (#628). + */ +@RunWith(AndroidJUnit4.class) +public class HttpServerTest { + private Path tempDir; + + @Before + public void setUp() throws IOException { + TestSupport.initialize(); + tempDir = TestSupport.tempDir("http-server"); + } + + private static String fetch(String url) throws Exception { + long deadline = System.nanoTime() + 5_000_000_000L; + while (true) { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(1000); + connection.setReadTimeout(1000); + // no keep-alive connection may outlive the request: cpp-httplib's + // listen() only returns once its workers are done + connection.setRequestProperty("Connection", "close"); + try { + assertEquals(200, connection.getResponseCode()); + try (InputStream body = connection.getInputStream()) { + return read(body); + } + } catch (IOException e) { + if (System.nanoTime() > deadline) { + throw e; + } + Thread.sleep(50); + } finally { + connection.disconnect(); + } + } + } + + private static String read(InputStream input) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + for (int count = input.read(chunk); count != -1; count = input.read(chunk)) { + buffer.write(chunk, 0, count); + } + return new String(buffer.toByteArray(), StandardCharsets.UTF_8); + } + + @Test + public void serveFile() throws Exception { + assumeTrue("built without the HTTP server", Odr.hasHttpServer()); + + HttpServer server = new HttpServer(); + + String cachePath = Files.createDirectories(tempDir.resolve("doc-cache")).toString(); + DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); + HtmlConfig htmlConfig = new HtmlConfig(); + htmlConfig.embedImages = false; + htmlConfig.relativeResourcePaths = false; + HtmlService service = Html.translate(file, cachePath, htmlConfig); + server.connectService(service, "doc"); + List views = service.listViews(); + assertEquals(1, views.size()); + + int port = server.bind("127.0.0.1", 0); + AtomicReference listenError = new AtomicReference<>(); + Thread thread = + new Thread( + () -> { + try { + server.listen(); + } catch (Throwable t) { + listenError.set(t); + } + }); + thread.setDaemon(true); + thread.start(); + try { + String body = fetch("http://127.0.0.1:" + port + "/file/doc/" + views.get(0).path()); + assertTrue(body.contains(TestFiles.ODT_FIRST_PARAGRAPH)); + } catch (Exception e) { + if (listenError.get() != null) { + throw new AssertionError("listen failed", listenError.get()); + } + throw e; + } finally { + server.stop(); + thread.join(5000); + } + assertFalse(thread.isAlive()); + } + + @Test + public void bindReportsWhatItGot() { + assumeTrue("built without the HTTP server", Odr.hasHttpServer()); + + HttpServer server = new HttpServer(); + // a literal address on purpose: "localhost" resolves to both ::1 and + // 127.0.0.1, so a second bind would land on the other one + int port = server.bind("127.0.0.1", 0); + assertNotEquals(0, port); + server.stop(); + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/LoggerTest.java b/android/src/androidTest/java/app/opendocument/core/LoggerTest.java new file mode 100644 index 000000000..f6a7345bd --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/LoggerTest.java @@ -0,0 +1,138 @@ +package app.opendocument.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * A java {@link ILogger} driven from native code, on ART. The sink is reached through a global + * reference and cached method handles, and the calls arrive on whatever thread the library works on + * — the two places where the android JNI implementation is stricter than the JDK's (#628). + */ +@RunWith(AndroidJUnit4.class) +public class LoggerTest { + private Path tempDir; + + @Before + public void setUp() throws IOException { + TestSupport.initialize(); + tempDir = TestSupport.tempDir("logger"); + } + + private static final class Collecting implements ILogger { + final List messages = Collections.synchronizedList(new ArrayList()); + final List threads = Collections.synchronizedList(new ArrayList()); + volatile int flushes = 0; + private final LogLevel level; + + Collecting(LogLevel level) { + this.level = level; + } + + @Override + public boolean willLog(LogLevel level) { + return level.ordinal() >= this.level.ordinal(); + } + + @Override + public void log(long epochMillis, LogLevel level, String message, SourceLocation location) { + messages.add(message); + threads.add(Thread.currentThread().getName()); + } + + @Override + public void flush() { + flushes++; + } + } + + @Test + public void customSinkReceivesMessages() { + Collecting sink = new Collecting(LogLevel.WARNING); + try (Logger logger = new Logger(sink)) { + assertFalse(logger.willLog(LogLevel.DEBUG)); + logger.log(LogLevel.DEBUG, "dropped"); + logger.log(LogLevel.ERROR, "kept"); + logger.flush(); + } + + assertEquals(Collections.singletonList("kept"), sink.messages); + assertEquals(1, sink.flushes); + } + + @Test + public void customSinkIsUsableWhileOpening() throws IOException { + Path odt = TestFiles.odtFile(tempDir); + Collecting sink = new Collecting(LogLevel.VERBOSE); + try (Logger logger = new Logger(sink); + DecodedFile file = Odr.open(odt.toString(), logger)) { + assertEquals(FileType.OPENDOCUMENT_TEXT, file.fileType()); + } + assertNotNull(sink.messages); + } + + @Test + public void sinkIsReachedFromABackgroundThread() throws Exception { + Collecting sink = new Collecting(LogLevel.VERBOSE); + CountDownLatch done = new CountDownLatch(1); + try (Logger logger = new Logger(sink)) { + Thread thread = + new Thread( + () -> { + logger.log(LogLevel.ERROR, "from a worker"); + done.countDown(); + }, + "odr-log-worker"); + thread.start(); + assertTrue(done.await(10, TimeUnit.SECONDS)); + thread.join(); + } + + assertEquals(Collections.singletonList("from a worker"), sink.messages); + assertEquals(Collections.singletonList("odr-log-worker"), sink.threads); + } + + @Test + public void aThrowingSinkDoesNotDerailTheOperation() throws IOException { + Path odt = TestFiles.odtFile(tempDir); + ILogger sink = + new ILogger() { + @Override + public boolean willLog(LogLevel level) { + return true; + } + + @Override + public void log( + long epochMillis, LogLevel level, String message, SourceLocation location) { + throw new IllegalStateException("sink is broken"); + } + + @Override + public void flush() { + throw new IllegalStateException("sink is broken"); + } + }; + + // the exception is described and cleared on the native side; leaving it + // pending would abort the next JNI call instead, which on ART kills the + // process rather than failing the test + try (Logger logger = new Logger(sink); + DecodedFile file = Odr.open(odt.toString(), logger)) { + assertEquals(FileType.OPENDOCUMENT_TEXT, file.fileType()); + } + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java new file mode 100644 index 000000000..d1a51c108 --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java @@ -0,0 +1,60 @@ +package app.opendocument.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** The AAR's own contract: the native library loads and the bundled assets end up on disk. */ +@RunWith(AndroidJUnit4.class) +public class OdrAndroidTest { + @Before + public void setUp() throws IOException { + TestSupport.initialize(); + } + + @Test + public void nativeLibraryLoads() { + // reaching the native side at all means the .so, its ABI and libc++_shared + // are all where the packaging put them + assertFalse(Odr.identify().isEmpty()); + assertNotNull(Odr.version()); + assertNotNull(Odr.commitHash()); + } + + @Test + public void assetsAreExtracted() { + File data = new File(GlobalParams.odrCoreDataPath()); + assertTrue(data + " is not a directory", data.isDirectory()); + assertTrue(new File(data, "document.css").isFile()); + assertTrue(new File(data, "document.js").isFile()); + + File magic = new File(GlobalParams.libmagicDatabasePath()); + assertTrue(magic + " is not a file", magic.isFile()); + } + + @Test + public void initIsIdempotent() throws IOException { + String dataPath = GlobalParams.odrCoreDataPath(); + TestSupport.initialize(); + assertEquals(dataPath, GlobalParams.odrCoreDataPath()); + } + + @Test + public void detectsTypeWithTheBundledMagicDatabase() throws IOException { + Path directory = TestSupport.tempDir("magic"); + Path odt = TestFiles.odtFile(directory); + + // goes through libmagic, so it only works when the database extracted above + // is the one the native library actually opened + assertEquals("application/vnd.oasis.opendocument.text", Odr.mimetype(odt.toString())); + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/TestSupport.java b/android/src/androidTest/java/app/opendocument/core/TestSupport.java new file mode 100644 index 000000000..ad2c4d6fe --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/TestSupport.java @@ -0,0 +1,42 @@ +package app.opendocument.core; + +import android.content.Context; +import androidx.test.platform.app.InstrumentationRegistry; +import app.opendocument.core.android.OdrAndroid; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +/** Shared setup of the instrumented suite: the initialised library and a scratch directory. */ +final class TestSupport { + static Context context() { + return InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + /** The library, with its bundled assets extracted and registered. */ + static void initialize() throws IOException { + OdrAndroid.init(context()); + } + + /** An empty directory under the app cache, named after the caller. */ + static Path tempDir(String name) throws IOException { + File directory = new File(context().getCacheDir(), name); + delete(directory); + if (!directory.mkdirs()) { + throw new IOException("could not create " + directory); + } + return directory.toPath(); + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + delete(child); + } + } + file.delete(); + } + + private TestSupport() {} +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a0f48283f --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + diff --git a/android/src/main/java/app/opendocument/core/android/OdrAndroid.java b/android/src/main/java/app/opendocument/core/android/OdrAndroid.java new file mode 100644 index 000000000..7bb03810b --- /dev/null +++ b/android/src/main/java/app/opendocument/core/android/OdrAndroid.java @@ -0,0 +1,99 @@ +package app.opendocument.core.android; + +import android.content.Context; +import android.content.res.AssetManager; +import app.opendocument.core.GlobalParams; +import app.opendocument.core.Odr; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Makes the library usable on android: the renderer reads its CSS/JS and the libmagic database as + * plain files, and an APK holds them as assets, which are not files. + * + *

Call {@link #init} once before anything else touches the library: + * + *

{@code
+ * OdrAndroid.init(context);
+ * DecodedFile file = Odr.open(path);
+ * }
+ */ +public final class OdrAndroid { + /** Asset directory this AAR ships its runtime data under. */ + private static final String ASSETS = "core"; + + private static boolean initialized; + + /** + * Extracts the bundled runtime data and points the library at it. Repeated calls are cheap: the + * data is extracted once per version of the library and reused afterwards. + * + * @param context any context; only the app's private storage and its assets are used + * @throws IOException if the assets cannot be extracted + */ + public static synchronized void init(Context context) throws IOException { + if (initialized) { + return; + } + + // Keyed by the exact library build: an app that updates must not keep + // reading the assets of the version it had before, and there is no reason + // to back these up or restore them onto another device. + File root = new File(context.getNoBackupFilesDir(), "odr-core/" + Odr.commitHash()); + File marker = new File(root, ".complete"); + if (!marker.isFile()) { + deleteRecursively(root); + extract(context.getAssets(), ASSETS, root); + if (!marker.createNewFile()) { + throw new IOException("could not mark " + root + " as complete"); + } + } + + GlobalParams.setOdrCoreDataPath(new File(root, "odrcore").getAbsolutePath()); + GlobalParams.setLibmagicDatabasePath(new File(root, "libmagic/magic.mgc").getAbsolutePath()); + initialized = true; + } + + private static void extract(AssetManager assets, String path, File target) throws IOException { + String[] children = assets.list(path); + if (children == null || children.length == 0) { + copy(assets, path, target); + return; + } + if (!target.isDirectory() && !target.mkdirs()) { + throw new IOException("could not create " + target); + } + for (String child : children) { + extract(assets, path + "/" + child, new File(target, child)); + } + } + + private static void copy(AssetManager assets, String path, File target) throws IOException { + File parent = target.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("could not create " + parent); + } + byte[] buffer = new byte[1 << 16]; + try (InputStream input = assets.open(path); + OutputStream output = new FileOutputStream(target)) { + for (int read = input.read(buffer); read != -1; read = input.read(buffer)) { + output.write(buffer, 0, read); + } + } + } + + private static void deleteRecursively(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + file.delete(); + } + + private OdrAndroid() {} +} diff --git a/jni/AGENTS.md b/jni/AGENTS.md index 15a77f09e..4d6d24a3f 100644 --- a/jni/AGENTS.md +++ b/jni/AGENTS.md @@ -11,8 +11,9 @@ package `app.opendocument.core`. Mirrors the surface of the python bindings | `CMakeLists.txt` | Builds `libodr_jni` + `odr-core-java.jar`; included from the root build via `ODR_JNI`, or standalone against an installed `odrcore`. | | `pom.xml` | Maven distribution of the Java classes only (`app.opendocument:odr-core-java`); published to GitHub Packages on release via `.github/workflows/maven.yml`. Keep `--release`/`-Xlint` in sync with `CMAKE_JAVA_COMPILE_FLAGS`. | | `src/` | JNI sources, one `jni_*` unit per public-API area; `odr_jni.hpp` (strings, exceptions, handles) and `jni_convert.hpp` (struct/POJO marshalling) are the helpers. | -| `java/app/opendocument/core/` | Java API: enums, POJOs (styles, metas, `HtmlConfig`), and handle-backed wrappers extending `NativeResource`. | +| `java/app/opendocument/core/` | Java API: enums, POJOs (styles, metas, `HtmlConfig`), and handle-backed wrappers extending `NativeResource`. Also compiled as-is into the AAR (`../android`). | | `tests/` | JUnit 5 suite, run via ctest (`odr_jni_junit`); inputs are generated inline (tmp files, zip-built minimal ODT) — no fixture files. | +| `testfixtures/` | `TestFiles`, the inline input builder, shared with the instrumented suite of the AAR — hence limited to what android API 26 offers. | ## Design @@ -62,12 +63,16 @@ package `app.opendocument.core`. Mirrors the surface of the python bindings hence the `PhantomReference` reaper), `List.of`/`Set.of`/`Map.of` (API 34), `Optional.isEmpty` (API 33), `java.time` (API 26 only in part). Core library desugaring does not cover `java.lang.ref`, and an app cannot shim a `java.*` - class, so the fix always has to happen here. + class, so the fix always has to happen here. This is no longer only a rule to + remember: `../android` cross compiles the bindings, lints `java/` against + minSdk 26 and runs an instrumented suite on an API 26 emulator on every push. - C++ sources follow the repo clang-format; Java follows the google-java-format style (2-space indent), no enforced formatter yet. -- Tests must stay hermetic: build inputs inline in `tests/.../TestFiles.java`; +- Tests must stay hermetic: build inputs inline in + `testfixtures/.../TestFiles.java`; HTML-rendering tests `assumeTrue(TestFiles.hasCoreData())` (skips when assets are missing). Use `127.0.0.1`, not `localhost`, for the HTTP server (the JVM prefers `::1`). -- Build/test loop: see `jni/README.md`; CI lives in - `.github/workflows/jni.yml`. +- Build/test loop: see `jni/README.md`; CI builds the bindings in the `build` + job of `.github/workflows/build_test.yml` (host) and in + `.github/workflows/android.yml` (android). diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index ad3659695..dea2c69e2 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -20,9 +20,21 @@ else () set(ODR_JNI_ODR_TARGET odr) endif () -find_package(Java 11 REQUIRED COMPONENTS Development) -find_package(JNI REQUIRED) -include(UseJava) +# On android the NDK sysroot ships `jni.h` and the JNI symbols come from the +# runtime, so there is nothing to find or link against. A JDK stays optional +# there rather than unused: the android odrcore package ships +# `odr-core-java.jar` next to `libodr_jni.so` and OpenDocument.droid takes both +# out of it, while `android/` (the AAR) builds the native half alone, with the +# android toolchain compiling `java/` itself. +if (ANDROID) + find_package(Java 11 COMPONENTS Development) +else () + find_package(Java 11 REQUIRED COMPONENTS Development) + find_package(JNI REQUIRED) +endif () +if (Java_FOUND) + include(UseJava) +endif () add_library(odr_jni SHARED "src/odr_jni.cpp" @@ -34,12 +46,20 @@ add_library(odr_jni SHARED "src/jni_http_server.cpp" "src/jni_style.cpp" ) -target_include_directories(odr_jni PRIVATE ${JNI_INCLUDE_DIRS}) +if (NOT ANDROID) + target_include_directories(odr_jni PRIVATE ${JNI_INCLUDE_DIRS}) +endif () target_link_libraries(odr_jni PRIVATE ${ODR_JNI_ODR_TARGET}) if (ODR_WITH_HTTP_SERVER) target_compile_definitions(odr_jni PRIVATE ODR_WITH_HTTP_SERVER) endif () +install(TARGETS odr_jni LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" COMPONENT jni) + +if (NOT Java_FOUND) + return() +endif () + set(CMAKE_JAVA_COMPILE_FLAGS --release 17 -Xlint:all) add_jar(odr_java @@ -136,10 +156,12 @@ add_jar(odr_java OUTPUT_NAME odr-core-java ) -install(TARGETS odr_jni LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" COMPONENT jni) install_jar(odr_java DESTINATION "${CMAKE_INSTALL_DATADIR}/java" COMPONENT jni) -if (ODR_TEST) +# The junit suite loads `libodr_jni` into the JVM that runs it, so it is only +# meaningful for a host build; the android build is covered by the instrumented +# tests of the AAR (`android/`). +if (ODR_TEST AND NOT ANDROID) include(FetchContent) FetchContent_Declare(junit_console URL "https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.10.2/junit-platform-console-standalone-1.10.2.jar" @@ -158,7 +180,8 @@ if (ODR_TEST) "tests/app/opendocument/core/HttpServerTest.java" "tests/app/opendocument/core/LoggerTest.java" "tests/app/opendocument/core/MetaTest.java" - "tests/app/opendocument/core/TestFiles.java" + # shared with the instrumented suite of the AAR, see `android/` + "testfixtures/app/opendocument/core/TestFiles.java" INCLUDE_JARS odr_java "${ODR_JNI_JUNIT_JAR}" OUTPUT_NAME odr-core-java-tests ) diff --git a/jni/README.md b/jni/README.md index b83b17d6f..939061f8b 100644 --- a/jni/README.md +++ b/jni/README.md @@ -30,6 +30,10 @@ on release (`.github/workflows/maven.yml`, built from `pom.xml`). The artifact contains **only the Java API** — consumers build the native `odr_jni` library themselves for their target platform (see below) and provide it at runtime. +On android there is a second, self-contained artifact: +`app.opendocument:odr-core-android`, an AAR with these same classes plus the +native library for every ABI and the runtime assets. See [`../android`](../android/README.md). + ```gradle repositories { maven { diff --git a/jni/java/app/opendocument/core/Color.java b/jni/java/app/opendocument/core/Color.java index 8daf782a5..dfa4cdf80 100644 --- a/jni/java/app/opendocument/core/Color.java +++ b/jni/java/app/opendocument/core/Color.java @@ -1,5 +1,7 @@ package app.opendocument.core; +import java.util.Locale; + /** An RGBA color. Mirrors {@code odr::Color}; channels are 0-255. */ public final class Color { public final int red; @@ -42,6 +44,8 @@ public int hashCode() { @Override public String toString() { - return String.format("Color(%d, %d, %d, %d)", red, green, blue, alpha); + // ROOT, not the default locale: a device set to a locale with its own + // digits would render this unparseable + return String.format(Locale.ROOT, "Color(%d, %d, %d, %d)", red, green, blue, alpha); } } diff --git a/jni/tests/app/opendocument/core/TestFiles.java b/jni/testfixtures/app/opendocument/core/TestFiles.java similarity index 81% rename from jni/tests/app/opendocument/core/TestFiles.java rename to jni/testfixtures/app/opendocument/core/TestFiles.java index 9530a873d..b69cd443f 100644 --- a/jni/tests/app/opendocument/core/TestFiles.java +++ b/jni/testfixtures/app/opendocument/core/TestFiles.java @@ -4,11 +4,18 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.zip.CRC32; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -/** Builds minimal test input files from inline content (mirrors python/tests/conftest.py). */ +/** + * Builds minimal test input files from inline content (mirrors python/tests/conftest.py). + * + *

Shared by the host junit suite ({@code jni/tests}) and the instrumented suite of the AAR + * ({@code android/}), so it stays within what android API 26 offers — no {@code Path.of}, no {@code + * Files.writeString}, no {@code String.formatted}. + */ final class TestFiles { static final String ODT_FIRST_PARAGRAPH = "Hello from odr-core-java!"; static final String ODT_SECOND_PARAGRAPH = "Second paragraph äöü 😀"; @@ -27,8 +34,10 @@ final class TestFiles { - """ - .formatted(ODT_FIRST_PARAGRAPH, ODT_SECOND_PARAGRAPH); + """; + + private static final String ODT_CONTENT = + String.format(ODT_CONTENT_XML, ODT_FIRST_PARAGRAPH, ODT_SECOND_PARAGRAPH); private static final String ODT_STYLES_XML = """ @@ -68,7 +77,7 @@ final class TestFiles { /** Whether the odr core data (CSS/JS assets) is available for rendering. */ static boolean hasCoreData() { String path = GlobalParams.odrCoreDataPath(); - return path != null && !path.isEmpty() && Files.isDirectory(Path.of(path)); + return path != null && !path.isEmpty() && Files.isDirectory(Paths.get(path)); } /** A minimal OpenDocument text file built from inline XML. */ @@ -87,7 +96,7 @@ static Path odtFile(Path directory) throws IOException { zip.write(mimetype); zip.closeEntry(); - writeEntry(zip, "content.xml", ODT_CONTENT_XML); + writeEntry(zip, "content.xml", ODT_CONTENT); writeEntry(zip, "styles.xml", ODT_STYLES_XML); writeEntry(zip, "META-INF/manifest.xml", ODT_MANIFEST_XML); } @@ -96,16 +105,20 @@ static Path odtFile(Path directory) throws IOException { static Path csvFile(Path directory) throws IOException { Path path = directory.resolve("table.csv"); - Files.writeString(path, "name,value\nalpha,1\nbeta,2\n"); + write(path, "name,value\nalpha,1\nbeta,2\n"); return path; } static Path txtFile(Path directory) throws IOException { Path path = directory.resolve("note.txt"); - Files.writeString(path, "hello text file\nsecond line\n"); + write(path, "hello text file\nsecond line\n"); return path; } + private static void write(Path path, String content) throws IOException { + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + } + private static void writeEntry(ZipOutputStream zip, String name, String content) throws IOException { zip.putNextEntry(new ZipEntry(name)); diff --git a/scripts/conan_lock b/scripts/conan_lock index e1b016ae1..e9631b7b3 100755 --- a/scripts/conan_lock +++ b/scripts/conan_lock @@ -2,7 +2,17 @@ conan lock create . +# `android.jinja` is the shared body of the `android-*` profiles, not a profile +# of its own, and the android profiles only ever act as host profiles. for profile in .github/config/conan/profiles/*; do + case "${profile}" in + *.jinja | */android-*) continue ;; + esac conan lock create . --profile:build "${profile}" --profile:host "${profile}" done -conan lock create . --profile:build ".github/config/conan/profiles/ubuntu-24.04-clang-18" --profile:host ".github/config/conan/profiles/android-35-x86_64" + +for architecture in armv7 armv8 x86 x86_64; do + conan lock create . \ + --profile:build ".github/config/conan/profiles/ubuntu-24.04-clang-18" \ + --profile:host ".github/config/conan/profiles/android-${architecture}" +done From b0cd6622f5708d171b90f83edf394fcae9ca529e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 28 Jul 2026 07:41:31 +0200 Subject: [PATCH 2/3] refactor(android)!: write the AAR's own code in kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QqTNzXTxzfeCA2w1ErQzax --- .github/workflows/format.yml | 22 +++ android/AGENTS.md | 11 +- android/README.md | 49 ++++--- android/build.gradle.kts | 17 +++ android/gradle.properties | 6 +- android/gradle/libs.versions.toml | 5 + .../app/opendocument/core/DocumentTest.java | 126 ---------------- .../app/opendocument/core/DocumentTest.kt | 118 +++++++++++++++ .../app/opendocument/core/HttpServerTest.java | 128 ---------------- .../app/opendocument/core/HttpServerTest.kt | 109 ++++++++++++++ .../app/opendocument/core/LoggerTest.java | 138 ------------------ .../java/app/opendocument/core/LoggerTest.kt | 131 +++++++++++++++++ .../app/opendocument/core/OdrAndroidTest.java | 60 -------- .../app/opendocument/core/OdrAndroidTest.kt | 57 ++++++++ .../app/opendocument/core/TestSupport.java | 42 ------ .../java/app/opendocument/core/TestSupport.kt | 28 ++++ .../opendocument/core/android/OdrAndroid.java | 99 ------------- .../opendocument/core/android/OdrAndroid.kt | 87 +++++++++++ jni/AGENTS.md | 2 +- 19 files changed, 620 insertions(+), 615 deletions(-) delete mode 100644 android/src/androidTest/java/app/opendocument/core/DocumentTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/DocumentTest.kt delete mode 100644 android/src/androidTest/java/app/opendocument/core/HttpServerTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt delete mode 100644 android/src/androidTest/java/app/opendocument/core/LoggerTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/LoggerTest.kt delete mode 100644 android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java create mode 100644 android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt delete mode 100644 android/src/androidTest/java/app/opendocument/core/TestSupport.java create mode 100644 android/src/androidTest/java/app/opendocument/core/TestSupport.kt delete mode 100644 android/src/main/java/app/opendocument/core/android/OdrAndroid.java create mode 100644 android/src/main/java/app/opendocument/core/android/OdrAndroid.kt diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index fc3986b73..fd379a9b4 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -13,3 +13,25 @@ jobs: run: CLANG_FORMAT=clang-format-18 ./scripts/format - run: git diff --exit-code --name-only + + # The kotlin of `android/`, which clang-format above does not see. spotless + # does not go through the android build, so this needs java and the sdk on the + # runner and nothing else — no NDK, no conan, no native libraries. + spotless: + runs-on: ubuntu-24.04 + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: setup java + uses: actions/setup-java@f4f1212c880fdec8162ea9a6493f4495191887b4 # v5 + with: + distribution: temurin + java-version: 21 + + - name: setup gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: spotless + working-directory: android + run: ./gradlew spotlessCheck -Podr.abis= diff --git a/android/AGENTS.md b/android/AGENTS.md index 65740c052..62a05ea79 100644 --- a/android/AGENTS.md +++ b/android/AGENTS.md @@ -11,7 +11,7 @@ first — the java API and its android constraints live there. User facing docs: |------|------| | `build.gradle.kts` | The library module: sources from `../jni/java`, prebuilt native libs and assets, lint, publishing. Single project — `rootProject.name` *is* the artifactId. | | `build_native.py` | conan + cmake per ABI → `native/prebuilt/{jniLibs,assets}`. Invoked by the `buildNative` gradle task and directly by CI. | -| `src/main/java/.../android/OdrAndroid.java` | The only android specific production code: extracts the bundled assets and registers them with `GlobalParams`. | +| `src/main/java/.../android/OdrAndroid.kt` | The only android specific production code: extracts the bundled assets and registers them with `GlobalParams`. | | `src/androidTest/` | Instrumented suite, JUnit 4 + androidx.test, inputs from `../jni/testfixtures`. | | `consumer-rules.pro` | Keeps `app.opendocument.core.**` — JNI resolves it by name, R8 cannot see that. | @@ -38,6 +38,15 @@ calls it. - **Do not duplicate the java API here.** `main` compiles `../jni/java` verbatim; the AAR and the maven jar are the same classes, and anything android-only goes into the `app.opendocument.core.android` package. +- **What this module writes is kotlin; what it borrows is java.** `../jni/java` + and `../jni/testfixtures` are compiled by CMake's `add_jar` for the maven jar + and the host junit suite, which have no kotlin toolchain, so they stay java — + the kotlin here is only `OdrAndroid` and `src/androidTest`. Anything crossing + back to a java caller keeps its java shape: `@JvmStatic` so `OdrAndroid.init` + stays a static call, `@Throws` so the `IOException` stays checked. +- **Formatting is ktfmt** (kotlinlang style) via spotless, the same version + OpenDocument.droid runs. `./gradlew spotlessApply`; CI checks it in + `.github/workflows/format.yml`, which needs neither the NDK nor conan. - **Shared test inputs stay in `../jni/testfixtures`**, which both suites compile — so it is limited to what android API 26 offers (no `Path.of`, no `Files.writeString`, no `String.formatted`). diff --git a/android/README.md b/android/README.md index d750ca064..d335aa3ac 100644 --- a/android/README.md +++ b/android/README.md @@ -33,20 +33,25 @@ dependencies { } ``` -GitHub Packages needs a token with `read:packages` even for public packages. Do -not depend on `odr-core-java` as well — the AAR carries the same classes. +GitHub Packages needs a token with `read:packages` even for public packages — +see [Publishing](#publishing) for what that rules out. Do not depend on +`odr-core-java` as well: the AAR carries the same classes. -```java -import app.opendocument.core.*; -import app.opendocument.core.android.OdrAndroid; +```kotlin +import app.opendocument.core.* +import app.opendocument.core.android.OdrAndroid -OdrAndroid.init(context); // extracts the bundled assets, loads the library +OdrAndroid.init(context) // extracts the bundled assets, loads the library -DecodedFile file = Odr.open(path); -HtmlService service = Html.translate(file, cacheDir.getPath(), new HtmlConfig()); -Html html = service.bringOffline(outputDir.getPath()); +val file = Odr.open(path) +val service = Html.translate(file, cacheDir.path, HtmlConfig()) +val html = service.bringOffline(outputDir.path) ``` +The java API is java and stays callable as such (`OdrAndroid.init(context)` is +a static method, and it still throws a checked `IOException`); only this +module's own code — `OdrAndroid` and the instrumented suite — is kotlin. + `OdrAndroid.init` is idempotent and cheap after the first call: the assets are unpacked once per library build, into the app's no-backup storage, and the library is pointed at them via `GlobalParams`. @@ -84,6 +89,7 @@ The odrcore build is a normal one — `ODR_JNI=ON`, static core linked into ```bash ./gradlew lint # NewApi against minSdk 26, over ../jni/java too ./gradlew connectedDebugAndroidTest # on a running emulator or device +./gradlew spotlessApply # ktfmt, kotlinlang style ``` The instrumented suite (`src/androidTest`) is the part that sees what a device @@ -96,12 +102,21 @@ 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. -## Relation to the conan package +## Publishing + +Releases go to GitHub Packages, like the maven jar. That is enough for +consumers who can hold a token, and **not** enough for the one this is +ultimately built for: GitHub Packages demands `read:packages` even to read a +public artifact, and f-droid builds from source with no credentials at all. +Making the AAR the base of OpenDocument.droid therefore means publishing it to +Maven Central first, which is a separate piece of work — the `app.opendocument` +namespace has to be verified, and every artifact signed. + +Until then OpenDocument.droid keeps building odrcore from the conan package +(`with_jni=True`), deploying `libodr_jni.so`, `odr-core-java.jar` and the assets +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. -OpenDocument.droid does not consume this artifact: it builds odrcore from the -conan package (`with_jni=True`) and deploys `libodr_jni.so`, `odr-core-java.jar` -and the assets out of it, which keeps the two halves in lockstep by -construction and the build free of credentials, as f-droid requires. That path -is unaffected by anything here — the AAR is a second packaging of the same -build, for consumers that just want a dependency, and the reason android now -gets compiled and exercised on every push. +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. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8c4d95136..08e084fb6 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,8 +1,25 @@ plugins { alias(libs.plugins.android.library) + alias(libs.plugins.spotless) `maven-publish` } +// ktfmt in the kotlinlang flavor, the same formatter and version +// OpenDocument.droid runs, so a file can move between the two repos unchanged. +// ./gradlew spotlessApply to fix, spotlessCheck to verify (the format workflow +// does). Only this module's own kotlin: ../jni/java is java, formatted by hand +// to the google-java-format style, and reformatting it from here would rewrite +// the maven jar's sources as a side effect of an android build. +spotless { + kotlin { + target("src/*/java/**/*.kt") + + ktfmt(libs.versions.ktfmt.get()).kotlinlangStyle() + trimTrailingWhitespace() + endWithNewline() + } +} + // The version is injected on release, the way the maven jar takes -Drevision. // Everything else is a snapshot, which nothing consumes by version. val odrVersion = diff --git a/android/gradle.properties b/android/gradle.properties index 3762d8160..338d1e790 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,6 +2,6 @@ android.useAndroidX=true org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g org.gradle.configuration-cache=true -# nothing here is kotlin, and AGP 9 would otherwise hand every consumer a -# kotlin-stdlib dependency -android.builtInKotlin=false +# AGP 9 brings its own kotlin, so there is no plugin and no version to pin here. +# It puts kotlin-stdlib on the AAR's POM, which OpenDocument.droid — kotlin +# itself, and the consumer this is built for — already depends on. diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3a1b64994..a9cb9e5b6 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -2,6 +2,10 @@ # kept in step with OpenDocument.droid, which consumes this artifact agp = "9.3.1" +# as is the formatter, so a file can move between the two repos unchanged +spotless = "8.8.0" +ktfmt = "0.64" + junit = "4.13.2" androidxTest = "1.7.0" androidxTestJunit = "1.3.0" @@ -14,3 +18,4 @@ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "andro [plugins] android-library = { id = "com.android.library", version.ref = "agp" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/android/src/androidTest/java/app/opendocument/core/DocumentTest.java b/android/src/androidTest/java/app/opendocument/core/DocumentTest.java deleted file mode 100644 index aa845e0ee..000000000 --- a/android/src/androidTest/java/app/opendocument/core/DocumentTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package app.opendocument.core; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Decoding and rendering on a device, mirroring the host suite in {@code jni/tests}. What is android - * specific here is the runtime: every one of these calls crosses into the same java API on an - * android class library, where a method the JDK has and android does not fails only when it is - * reached (#621). - */ -@RunWith(AndroidJUnit4.class) -public class DocumentTest { - private Path tempDir; - - @Before - public void setUp() throws IOException { - TestSupport.initialize(); - tempDir = TestSupport.tempDir("document"); - } - - private Document openDocument() throws IOException { - Path odt = TestFiles.odtFile(tempDir); - return Odr.open(odt.toString()).asDocumentFile().document(); - } - - private static List walkText(Element element) { - List parts = new ArrayList<>(); - if (element.type() == ElementType.TEXT) { - parts.add(element.asText().content()); - } - for (Element child : element.children()) { - parts.addAll(walkText(child)); - } - return parts; - } - - @Test - public void elementTree() throws IOException { - Document document = openDocument(); - assertEquals(DocumentType.TEXT, document.documentType()); - assertEquals(FileType.OPENDOCUMENT_TEXT, document.fileType()); - - Element root = document.rootElement(); - assertEquals(ElementType.ROOT, root.type()); - - List text = walkText(root); - assertTrue(text.contains(TestFiles.ODT_FIRST_PARAGRAPH)); - // exercises non-BMP characters across the JNI string conversion - assertTrue(text.contains(TestFiles.ODT_SECOND_PARAGRAPH)); - } - - @Test - public void elementNavigation() throws IOException { - Element root = openDocument().rootElement(); - - Element first = root.firstChild(); - assertNotNull(first); - assertTrue(first.parent().isSame(root)); - Element second = first.nextSibling(); - assertNotNull(second); - assertTrue(second.previousSibling().isSame(first)); - assertNotNull(first.documentPath().toString()); - } - - @Test - public void documentFilesystem() throws IOException { - assertTrue(openDocument().asFilesystem().isFile("/content.xml")); - } - - @Test - public void fileMeta() throws IOException { - try (DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString())) { - FileMeta meta = file.fileMeta(); - assertEquals(FileType.OPENDOCUMENT_TEXT, meta.type); - assertFalse(meta.passwordEncrypted); - assertEquals(EncryptionState.NOT_ENCRYPTED, file.encryptionState()); - } - } - - @Test - public void translateToHtml() throws IOException { - Path cache = Files.createDirectories(tempDir.resolve("cache")); - Path output = Files.createDirectories(tempDir.resolve("output")); - - DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); - HtmlService service = Html.translate(file, cache.toString(), new HtmlConfig()); - Html html = service.bringOffline(output.toString()); - - List pages = html.pages(); - assertEquals(1, pages.size()); - // the renderer reads the css/js the AAR ships, so this only passes with the - // extracted assets in place - String content = read(Paths.get(pages.get(0).path)); - assertTrue(content.contains(TestFiles.ODT_FIRST_PARAGRAPH)); - } - - @Test - public void translateCsv() throws IOException { - Path cache = Files.createDirectories(tempDir.resolve("csv-cache")); - DecodedFile file = Odr.open(TestFiles.csvFile(tempDir).toString()); - HtmlService service = Html.translate(file, cache.toString(), new HtmlConfig()); - - List views = service.listViews(); - assertEquals(1, views.size()); - assertTrue(views.get(0).writeHtml().html.contains("alpha")); - } - - private static String read(Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } -} diff --git a/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt b/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt new file mode 100644 index 000000000..259feedd3 --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt @@ -0,0 +1,118 @@ +package app.opendocument.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Decoding and rendering on a device, mirroring the host suite in `jni/tests`. What is android + * specific here is the runtime: every one of these calls crosses into the same java API on an + * android class library, where a method the JDK has and android does not fails only when it is + * reached (#621). + */ +@RunWith(AndroidJUnit4::class) +class DocumentTest { + private lateinit var tempDir: Path + + @Before + fun setUp() { + TestSupport.initialize() + tempDir = TestSupport.tempDir("document") + } + + private fun openDocument(): Document { + val odt = TestFiles.odtFile(tempDir) + return Odr.open(odt.toString()).asDocumentFile().document() + } + + private fun walkText(element: Element): List = buildList { + if (element.type() == ElementType.TEXT) { + add(element.asText().content()) + } + for (child in element.children()) { + addAll(walkText(child)) + } + } + + @Test + fun elementTree() { + val document = openDocument() + assertEquals(DocumentType.TEXT, document.documentType()) + assertEquals(FileType.OPENDOCUMENT_TEXT, document.fileType()) + + val root = document.rootElement() + assertEquals(ElementType.ROOT, root.type()) + + val text = walkText(root) + assertTrue(text.contains(TestFiles.ODT_FIRST_PARAGRAPH)) + // exercises non-BMP characters across the JNI string conversion + assertTrue(text.contains(TestFiles.ODT_SECOND_PARAGRAPH)) + } + + @Test + fun elementNavigation() { + val root = openDocument().rootElement() + + val first = root.firstChild() + assertNotNull(first) + assertTrue(first.parent().isSame(root)) + val second = first.nextSibling() + assertNotNull(second) + assertTrue(second.previousSibling().isSame(first)) + assertNotNull(first.documentPath().toString()) + } + + @Test + fun documentFilesystem() { + assertTrue(openDocument().asFilesystem().isFile("/content.xml")) + } + + @Test + fun fileMeta() { + Odr.open(TestFiles.odtFile(tempDir).toString()).use { file -> + val meta = file.fileMeta() + assertEquals(FileType.OPENDOCUMENT_TEXT, meta.type) + assertFalse(meta.passwordEncrypted) + assertEquals(EncryptionState.NOT_ENCRYPTED, file.encryptionState()) + } + } + + @Test + fun translateToHtml() { + val cache = Files.createDirectories(tempDir.resolve("cache")) + val output = Files.createDirectories(tempDir.resolve("output")) + + val file = Odr.open(TestFiles.odtFile(tempDir).toString()) + val service = Html.translate(file, cache.toString(), HtmlConfig()) + val html = service.bringOffline(output.toString()) + + val pages = html.pages() + assertEquals(1, pages.size) + // the renderer reads the css/js the AAR ships, so this only passes with the + // extracted assets in place + val content = read(Paths.get(pages[0].path)) + assertTrue(content.contains(TestFiles.ODT_FIRST_PARAGRAPH)) + } + + @Test + fun translateCsv() { + val cache = Files.createDirectories(tempDir.resolve("csv-cache")) + val file = Odr.open(TestFiles.csvFile(tempDir).toString()) + val service = Html.translate(file, cache.toString(), HtmlConfig()) + + val views = service.listViews() + assertEquals(1, views.size) + assertTrue(views[0].writeHtml().html.contains("alpha")) + } + + private fun read(path: Path): String = String(Files.readAllBytes(path), StandardCharsets.UTF_8) +} diff --git a/android/src/androidTest/java/app/opendocument/core/HttpServerTest.java b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.java deleted file mode 100644 index cf45c2bbd..000000000 --- a/android/src/androidTest/java/app/opendocument/core/HttpServerTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package app.opendocument.core; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * The HTTP server on a device. It is the one part of the bindings that runs the library on threads - * cpp-httplib started rather than on a java one, which is where the android JNI implementation is - * least forgiving (#628). - */ -@RunWith(AndroidJUnit4.class) -public class HttpServerTest { - private Path tempDir; - - @Before - public void setUp() throws IOException { - TestSupport.initialize(); - tempDir = TestSupport.tempDir("http-server"); - } - - private static String fetch(String url) throws Exception { - long deadline = System.nanoTime() + 5_000_000_000L; - while (true) { - HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); - connection.setConnectTimeout(1000); - connection.setReadTimeout(1000); - // no keep-alive connection may outlive the request: cpp-httplib's - // listen() only returns once its workers are done - connection.setRequestProperty("Connection", "close"); - try { - assertEquals(200, connection.getResponseCode()); - try (InputStream body = connection.getInputStream()) { - return read(body); - } - } catch (IOException e) { - if (System.nanoTime() > deadline) { - throw e; - } - Thread.sleep(50); - } finally { - connection.disconnect(); - } - } - } - - private static String read(InputStream input) throws IOException { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] chunk = new byte[8192]; - for (int count = input.read(chunk); count != -1; count = input.read(chunk)) { - buffer.write(chunk, 0, count); - } - return new String(buffer.toByteArray(), StandardCharsets.UTF_8); - } - - @Test - public void serveFile() throws Exception { - assumeTrue("built without the HTTP server", Odr.hasHttpServer()); - - HttpServer server = new HttpServer(); - - String cachePath = Files.createDirectories(tempDir.resolve("doc-cache")).toString(); - DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); - HtmlConfig htmlConfig = new HtmlConfig(); - htmlConfig.embedImages = false; - htmlConfig.relativeResourcePaths = false; - HtmlService service = Html.translate(file, cachePath, htmlConfig); - server.connectService(service, "doc"); - List views = service.listViews(); - assertEquals(1, views.size()); - - int port = server.bind("127.0.0.1", 0); - AtomicReference listenError = new AtomicReference<>(); - Thread thread = - new Thread( - () -> { - try { - server.listen(); - } catch (Throwable t) { - listenError.set(t); - } - }); - thread.setDaemon(true); - thread.start(); - try { - String body = fetch("http://127.0.0.1:" + port + "/file/doc/" + views.get(0).path()); - assertTrue(body.contains(TestFiles.ODT_FIRST_PARAGRAPH)); - } catch (Exception e) { - if (listenError.get() != null) { - throw new AssertionError("listen failed", listenError.get()); - } - throw e; - } finally { - server.stop(); - thread.join(5000); - } - assertFalse(thread.isAlive()); - } - - @Test - public void bindReportsWhatItGot() { - assumeTrue("built without the HTTP server", Odr.hasHttpServer()); - - HttpServer server = new HttpServer(); - // a literal address on purpose: "localhost" resolves to both ::1 and - // 127.0.0.1, so a second bind would land on the other one - int port = server.bind("127.0.0.1", 0); - assertNotEquals(0, port); - server.stop(); - } -} diff --git a/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt new file mode 100644 index 000000000..81ee66c23 --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt @@ -0,0 +1,109 @@ +package app.opendocument.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The HTTP server on a device. It is the one part of the bindings that runs the library on threads + * cpp-httplib started rather than on a java one, which is where the android JNI implementation is + * least forgiving (#628). + */ +@RunWith(AndroidJUnit4::class) +class HttpServerTest { + private lateinit var tempDir: Path + + @Before + fun setUp() { + TestSupport.initialize() + tempDir = TestSupport.tempDir("http-server") + } + + private fun fetch(url: String): String { + val deadline = System.nanoTime() + 5_000_000_000L + while (true) { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connectTimeout = 1000 + connection.readTimeout = 1000 + // no keep-alive connection may outlive the request: cpp-httplib's + // listen() only returns once its workers are done + connection.setRequestProperty("Connection", "close") + try { + // an AssertionError, unlike an IOException, is not worth retrying + assertEquals(200, connection.responseCode) + return connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + } catch (e: IOException) { + if (System.nanoTime() > deadline) { + throw e + } + Thread.sleep(50) + } finally { + connection.disconnect() + } + } + } + + @Test + fun serveFile() { + assumeTrue("built without the HTTP server", Odr.hasHttpServer()) + + val server = HttpServer() + + val cachePath = Files.createDirectories(tempDir.resolve("doc-cache")).toString() + val file = Odr.open(TestFiles.odtFile(tempDir).toString()) + val htmlConfig = HtmlConfig() + htmlConfig.embedImages = false + htmlConfig.relativeResourcePaths = false + val service = Html.translate(file, cachePath, htmlConfig) + server.connectService(service, "doc") + val views = service.listViews() + assertEquals(1, views.size) + + val port = server.bind("127.0.0.1", 0) + val listenError = AtomicReference() + val thread = Thread { + try { + server.listen() + } catch (t: Throwable) { + listenError.set(t) + } + } + thread.isDaemon = true + thread.start() + try { + val body = fetch("http://127.0.0.1:$port/file/doc/${views[0].path()}") + assertTrue(body.contains(TestFiles.ODT_FIRST_PARAGRAPH)) + } catch (e: Exception) { + listenError.get()?.let { throw AssertionError("listen failed", it) } + throw e + } finally { + server.stop() + thread.join(5000) + } + assertFalse(thread.isAlive) + } + + @Test + fun bindReportsWhatItGot() { + assumeTrue("built without the HTTP server", Odr.hasHttpServer()) + + val server = HttpServer() + // a literal address on purpose: "localhost" resolves to both ::1 and + // 127.0.0.1, so a second bind would land on the other one + val port = server.bind("127.0.0.1", 0) + assertNotEquals(0, port) + server.stop() + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/LoggerTest.java b/android/src/androidTest/java/app/opendocument/core/LoggerTest.java deleted file mode 100644 index f6a7345bd..000000000 --- a/android/src/androidTest/java/app/opendocument/core/LoggerTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package app.opendocument.core; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.IOException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * A java {@link ILogger} driven from native code, on ART. The sink is reached through a global - * reference and cached method handles, and the calls arrive on whatever thread the library works on - * — the two places where the android JNI implementation is stricter than the JDK's (#628). - */ -@RunWith(AndroidJUnit4.class) -public class LoggerTest { - private Path tempDir; - - @Before - public void setUp() throws IOException { - TestSupport.initialize(); - tempDir = TestSupport.tempDir("logger"); - } - - private static final class Collecting implements ILogger { - final List messages = Collections.synchronizedList(new ArrayList()); - final List threads = Collections.synchronizedList(new ArrayList()); - volatile int flushes = 0; - private final LogLevel level; - - Collecting(LogLevel level) { - this.level = level; - } - - @Override - public boolean willLog(LogLevel level) { - return level.ordinal() >= this.level.ordinal(); - } - - @Override - public void log(long epochMillis, LogLevel level, String message, SourceLocation location) { - messages.add(message); - threads.add(Thread.currentThread().getName()); - } - - @Override - public void flush() { - flushes++; - } - } - - @Test - public void customSinkReceivesMessages() { - Collecting sink = new Collecting(LogLevel.WARNING); - try (Logger logger = new Logger(sink)) { - assertFalse(logger.willLog(LogLevel.DEBUG)); - logger.log(LogLevel.DEBUG, "dropped"); - logger.log(LogLevel.ERROR, "kept"); - logger.flush(); - } - - assertEquals(Collections.singletonList("kept"), sink.messages); - assertEquals(1, sink.flushes); - } - - @Test - public void customSinkIsUsableWhileOpening() throws IOException { - Path odt = TestFiles.odtFile(tempDir); - Collecting sink = new Collecting(LogLevel.VERBOSE); - try (Logger logger = new Logger(sink); - DecodedFile file = Odr.open(odt.toString(), logger)) { - assertEquals(FileType.OPENDOCUMENT_TEXT, file.fileType()); - } - assertNotNull(sink.messages); - } - - @Test - public void sinkIsReachedFromABackgroundThread() throws Exception { - Collecting sink = new Collecting(LogLevel.VERBOSE); - CountDownLatch done = new CountDownLatch(1); - try (Logger logger = new Logger(sink)) { - Thread thread = - new Thread( - () -> { - logger.log(LogLevel.ERROR, "from a worker"); - done.countDown(); - }, - "odr-log-worker"); - thread.start(); - assertTrue(done.await(10, TimeUnit.SECONDS)); - thread.join(); - } - - assertEquals(Collections.singletonList("from a worker"), sink.messages); - assertEquals(Collections.singletonList("odr-log-worker"), sink.threads); - } - - @Test - public void aThrowingSinkDoesNotDerailTheOperation() throws IOException { - Path odt = TestFiles.odtFile(tempDir); - ILogger sink = - new ILogger() { - @Override - public boolean willLog(LogLevel level) { - return true; - } - - @Override - public void log( - long epochMillis, LogLevel level, String message, SourceLocation location) { - throw new IllegalStateException("sink is broken"); - } - - @Override - public void flush() { - throw new IllegalStateException("sink is broken"); - } - }; - - // the exception is described and cleared on the native side; leaving it - // pending would abort the next JNI call instead, which on ART kills the - // process rather than failing the test - try (Logger logger = new Logger(sink); - DecodedFile file = Odr.open(odt.toString(), logger)) { - assertEquals(FileType.OPENDOCUMENT_TEXT, file.fileType()); - } - } -} diff --git a/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt b/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt new file mode 100644 index 000000000..27e1c7a44 --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt @@ -0,0 +1,131 @@ +package app.opendocument.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.nio.file.Path +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A java [ILogger] driven from native code, on ART. The sink is reached through a global reference + * and cached method handles, and the calls arrive on whatever thread the library works on — the two + * places where the android JNI implementation is stricter than the JDK's (#628). + */ +@RunWith(AndroidJUnit4::class) +class LoggerTest { + private lateinit var tempDir: Path + + @Before + fun setUp() { + TestSupport.initialize() + tempDir = TestSupport.tempDir("logger") + } + + private class Collecting(private val level: LogLevel) : ILogger { + val messages: MutableList = Collections.synchronizedList(ArrayList()) + val threads: MutableList = Collections.synchronizedList(ArrayList()) + @Volatile var flushes = 0 + + override fun willLog(level: LogLevel): Boolean = level.ordinal >= this.level.ordinal + + override fun log( + epochMillis: Long, + level: LogLevel, + message: String, + location: SourceLocation, + ) { + messages.add(message) + threads.add(Thread.currentThread().name) + } + + override fun flush() { + flushes++ + } + } + + @Test + fun customSinkReceivesMessages() { + val sink = Collecting(LogLevel.WARNING) + Logger(sink).use { logger -> + assertFalse(logger.willLog(LogLevel.DEBUG)) + logger.log(LogLevel.DEBUG, "dropped") + logger.log(LogLevel.ERROR, "kept") + logger.flush() + } + + assertEquals(listOf("kept"), sink.messages) + assertEquals(1, sink.flushes) + } + + @Test + fun customSinkIsUsableWhileOpening() { + val odt = TestFiles.odtFile(tempDir) + val sink = Collecting(LogLevel.VERBOSE) + Logger(sink).use { logger -> + Odr.open(odt.toString(), logger).use { file -> + assertEquals(FileType.OPENDOCUMENT_TEXT, file.fileType()) + } + } + assertNotNull(sink.messages) + } + + @Test + fun sinkIsReachedFromABackgroundThread() { + val sink = Collecting(LogLevel.VERBOSE) + val done = CountDownLatch(1) + Logger(sink).use { logger -> + val thread = + Thread( + { + logger.log(LogLevel.ERROR, "from a worker") + done.countDown() + }, + "odr-log-worker", + ) + thread.start() + assertTrue(done.await(10, TimeUnit.SECONDS)) + thread.join() + } + + assertEquals(listOf("from a worker"), sink.messages) + assertEquals(listOf("odr-log-worker"), sink.threads) + } + + @Test + fun aThrowingSinkDoesNotDerailTheOperation() { + val odt = TestFiles.odtFile(tempDir) + val sink = + object : ILogger { + override fun willLog(level: LogLevel): Boolean = true + + override fun log( + epochMillis: Long, + level: LogLevel, + message: String, + location: SourceLocation, + ) { + throw IllegalStateException("sink is broken") + } + + override fun flush() { + throw IllegalStateException("sink is broken") + } + } + + // the exception is described and cleared on the native side; leaving it + // pending would abort the next JNI call instead, which on ART kills the + // process rather than failing the test + Logger(sink).use { logger -> + Odr.open(odt.toString(), logger).use { file -> + assertEquals(FileType.OPENDOCUMENT_TEXT, file.fileType()) + } + } + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java deleted file mode 100644 index d1a51c108..000000000 --- a/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package app.opendocument.core; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** The AAR's own contract: the native library loads and the bundled assets end up on disk. */ -@RunWith(AndroidJUnit4.class) -public class OdrAndroidTest { - @Before - public void setUp() throws IOException { - TestSupport.initialize(); - } - - @Test - public void nativeLibraryLoads() { - // reaching the native side at all means the .so, its ABI and libc++_shared - // are all where the packaging put them - assertFalse(Odr.identify().isEmpty()); - assertNotNull(Odr.version()); - assertNotNull(Odr.commitHash()); - } - - @Test - public void assetsAreExtracted() { - File data = new File(GlobalParams.odrCoreDataPath()); - assertTrue(data + " is not a directory", data.isDirectory()); - assertTrue(new File(data, "document.css").isFile()); - assertTrue(new File(data, "document.js").isFile()); - - File magic = new File(GlobalParams.libmagicDatabasePath()); - assertTrue(magic + " is not a file", magic.isFile()); - } - - @Test - public void initIsIdempotent() throws IOException { - String dataPath = GlobalParams.odrCoreDataPath(); - TestSupport.initialize(); - assertEquals(dataPath, GlobalParams.odrCoreDataPath()); - } - - @Test - public void detectsTypeWithTheBundledMagicDatabase() throws IOException { - Path directory = TestSupport.tempDir("magic"); - Path odt = TestFiles.odtFile(directory); - - // goes through libmagic, so it only works when the database extracted above - // is the one the native library actually opened - assertEquals("application/vnd.oasis.opendocument.text", Odr.mimetype(odt.toString())); - } -} diff --git a/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt new file mode 100644 index 000000000..585c8f081 --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt @@ -0,0 +1,57 @@ +package app.opendocument.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** The AAR's own contract: the native library loads and the bundled assets end up on disk. */ +@RunWith(AndroidJUnit4::class) +class OdrAndroidTest { + @Before + fun setUp() { + TestSupport.initialize() + } + + @Test + fun nativeLibraryLoads() { + // reaching the native side at all means the .so, its ABI and libc++_shared + // are all where the packaging put them + assertFalse(Odr.identify().isEmpty()) + assertNotNull(Odr.version()) + assertNotNull(Odr.commitHash()) + } + + @Test + fun assetsAreExtracted() { + val data = File(GlobalParams.odrCoreDataPath()) + assertTrue("$data is not a directory", data.isDirectory) + assertTrue(File(data, "document.css").isFile) + assertTrue(File(data, "document.js").isFile) + + val magic = File(GlobalParams.libmagicDatabasePath()) + assertTrue("$magic is not a file", magic.isFile) + } + + @Test + fun initIsIdempotent() { + val dataPath = GlobalParams.odrCoreDataPath() + TestSupport.initialize() + assertEquals(dataPath, GlobalParams.odrCoreDataPath()) + } + + @Test + fun detectsTypeWithTheBundledMagicDatabase() { + val directory = TestSupport.tempDir("magic") + val odt = TestFiles.odtFile(directory) + + // goes through libmagic, so it only works when the database extracted above + // is the one the native library actually opened + assertEquals("application/vnd.oasis.opendocument.text", Odr.mimetype(odt.toString())) + } +} diff --git a/android/src/androidTest/java/app/opendocument/core/TestSupport.java b/android/src/androidTest/java/app/opendocument/core/TestSupport.java deleted file mode 100644 index ad2c4d6fe..000000000 --- a/android/src/androidTest/java/app/opendocument/core/TestSupport.java +++ /dev/null @@ -1,42 +0,0 @@ -package app.opendocument.core; - -import android.content.Context; -import androidx.test.platform.app.InstrumentationRegistry; -import app.opendocument.core.android.OdrAndroid; -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; - -/** Shared setup of the instrumented suite: the initialised library and a scratch directory. */ -final class TestSupport { - static Context context() { - return InstrumentationRegistry.getInstrumentation().getTargetContext(); - } - - /** The library, with its bundled assets extracted and registered. */ - static void initialize() throws IOException { - OdrAndroid.init(context()); - } - - /** An empty directory under the app cache, named after the caller. */ - static Path tempDir(String name) throws IOException { - File directory = new File(context().getCacheDir(), name); - delete(directory); - if (!directory.mkdirs()) { - throw new IOException("could not create " + directory); - } - return directory.toPath(); - } - - private static void delete(File file) { - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) { - delete(child); - } - } - file.delete(); - } - - private TestSupport() {} -} diff --git a/android/src/androidTest/java/app/opendocument/core/TestSupport.kt b/android/src/androidTest/java/app/opendocument/core/TestSupport.kt new file mode 100644 index 000000000..8bb2ba1ba --- /dev/null +++ b/android/src/androidTest/java/app/opendocument/core/TestSupport.kt @@ -0,0 +1,28 @@ +package app.opendocument.core + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import app.opendocument.core.android.OdrAndroid +import java.io.File +import java.io.IOException +import java.nio.file.Path + +/** Shared setup of the instrumented suite: the initialised library and a scratch directory. */ +internal object TestSupport { + fun context(): Context = InstrumentationRegistry.getInstrumentation().targetContext + + /** The library, with its bundled assets extracted and registered. */ + fun initialize() { + OdrAndroid.init(context()) + } + + /** An empty directory under the app cache, named after the caller. */ + fun tempDir(name: String): Path { + val directory = File(context().cacheDir, name) + directory.deleteRecursively() + if (!directory.mkdirs()) { + throw IOException("could not create $directory") + } + return directory.toPath() + } +} diff --git a/android/src/main/java/app/opendocument/core/android/OdrAndroid.java b/android/src/main/java/app/opendocument/core/android/OdrAndroid.java deleted file mode 100644 index 7bb03810b..000000000 --- a/android/src/main/java/app/opendocument/core/android/OdrAndroid.java +++ /dev/null @@ -1,99 +0,0 @@ -package app.opendocument.core.android; - -import android.content.Context; -import android.content.res.AssetManager; -import app.opendocument.core.GlobalParams; -import app.opendocument.core.Odr; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -/** - * Makes the library usable on android: the renderer reads its CSS/JS and the libmagic database as - * plain files, and an APK holds them as assets, which are not files. - * - *

Call {@link #init} once before anything else touches the library: - * - *

{@code
- * OdrAndroid.init(context);
- * DecodedFile file = Odr.open(path);
- * }
- */ -public final class OdrAndroid { - /** Asset directory this AAR ships its runtime data under. */ - private static final String ASSETS = "core"; - - private static boolean initialized; - - /** - * Extracts the bundled runtime data and points the library at it. Repeated calls are cheap: the - * data is extracted once per version of the library and reused afterwards. - * - * @param context any context; only the app's private storage and its assets are used - * @throws IOException if the assets cannot be extracted - */ - public static synchronized void init(Context context) throws IOException { - if (initialized) { - return; - } - - // Keyed by the exact library build: an app that updates must not keep - // reading the assets of the version it had before, and there is no reason - // to back these up or restore them onto another device. - File root = new File(context.getNoBackupFilesDir(), "odr-core/" + Odr.commitHash()); - File marker = new File(root, ".complete"); - if (!marker.isFile()) { - deleteRecursively(root); - extract(context.getAssets(), ASSETS, root); - if (!marker.createNewFile()) { - throw new IOException("could not mark " + root + " as complete"); - } - } - - GlobalParams.setOdrCoreDataPath(new File(root, "odrcore").getAbsolutePath()); - GlobalParams.setLibmagicDatabasePath(new File(root, "libmagic/magic.mgc").getAbsolutePath()); - initialized = true; - } - - private static void extract(AssetManager assets, String path, File target) throws IOException { - String[] children = assets.list(path); - if (children == null || children.length == 0) { - copy(assets, path, target); - return; - } - if (!target.isDirectory() && !target.mkdirs()) { - throw new IOException("could not create " + target); - } - for (String child : children) { - extract(assets, path + "/" + child, new File(target, child)); - } - } - - private static void copy(AssetManager assets, String path, File target) throws IOException { - File parent = target.getParentFile(); - if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { - throw new IOException("could not create " + parent); - } - byte[] buffer = new byte[1 << 16]; - try (InputStream input = assets.open(path); - OutputStream output = new FileOutputStream(target)) { - for (int read = input.read(buffer); read != -1; read = input.read(buffer)) { - output.write(buffer, 0, read); - } - } - } - - private static void deleteRecursively(File file) { - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) { - deleteRecursively(child); - } - } - file.delete(); - } - - private OdrAndroid() {} -} diff --git a/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt b/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt new file mode 100644 index 000000000..f5cdca69c --- /dev/null +++ b/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt @@ -0,0 +1,87 @@ +package app.opendocument.core.android + +import android.content.Context +import android.content.res.AssetManager +import app.opendocument.core.GlobalParams +import app.opendocument.core.Odr +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Makes the library usable on android: the renderer reads its CSS/JS and the libmagic database as + * plain files, and an APK holds them as assets, which are not files. + * + * Call [init] once before anything else touches the library: + * ``` + * OdrAndroid.init(context) + * val file = Odr.open(path) + * ``` + */ +object OdrAndroid { + /** Asset directory this AAR ships its runtime data under. */ + private const val ASSETS = "core" + + /** The libmagic database alone is 8 MB, so the default 8 KB would be a lot of syscalls. */ + private const val BUFFER_SIZE = 64 * 1024 + + private var initialized = false + + /** + * Extracts the bundled runtime data and points the library at it. Repeated calls are cheap: the + * data is extracted once per version of the library and reused afterwards. + * + * @param context any context; only the app's private storage and its assets are used + * @throws IOException if the assets cannot be extracted + */ + @JvmStatic + @Synchronized + @Throws(IOException::class) + fun init(context: Context) { + if (initialized) { + return + } + + // Keyed by the exact library build: an app that updates must not keep + // reading the assets of the version it had before, and there is no reason + // to back these up or restore them onto another device. + val root = File(context.noBackupFilesDir, "odr-core/${Odr.commitHash()}") + val marker = File(root, ".complete") + if (!marker.isFile) { + root.deleteRecursively() + extract(context.assets, ASSETS, root) + if (!marker.createNewFile()) { + throw IOException("could not mark $root as complete") + } + } + + GlobalParams.setOdrCoreDataPath(File(root, "odrcore").absolutePath) + GlobalParams.setLibmagicDatabasePath(File(root, "libmagic/magic.mgc").absolutePath) + initialized = true + } + + /** An asset directory lists its children; an asset file lists nothing. */ + private fun extract(assets: AssetManager, path: String, target: File) { + val children = assets.list(path) + if (children.isNullOrEmpty()) { + copy(assets, path, target) + return + } + if (!target.isDirectory && !target.mkdirs()) { + throw IOException("could not create $target") + } + for (child in children) { + extract(assets, "$path/$child", File(target, child)) + } + } + + private fun copy(assets: AssetManager, path: String, target: File) { + val parent = target.parentFile + if (parent != null && !parent.isDirectory && !parent.mkdirs()) { + throw IOException("could not create $parent") + } + assets.open(path).use { input -> + FileOutputStream(target).use { output -> input.copyTo(output, BUFFER_SIZE) } + } + } +} diff --git a/jni/AGENTS.md b/jni/AGENTS.md index 4d6d24a3f..9d96bd712 100644 --- a/jni/AGENTS.md +++ b/jni/AGENTS.md @@ -11,7 +11,7 @@ package `app.opendocument.core`. Mirrors the surface of the python bindings | `CMakeLists.txt` | Builds `libodr_jni` + `odr-core-java.jar`; included from the root build via `ODR_JNI`, or standalone against an installed `odrcore`. | | `pom.xml` | Maven distribution of the Java classes only (`app.opendocument:odr-core-java`); published to GitHub Packages on release via `.github/workflows/maven.yml`. Keep `--release`/`-Xlint` in sync with `CMAKE_JAVA_COMPILE_FLAGS`. | | `src/` | JNI sources, one `jni_*` unit per public-API area; `odr_jni.hpp` (strings, exceptions, handles) and `jni_convert.hpp` (struct/POJO marshalling) are the helpers. | -| `java/app/opendocument/core/` | Java API: enums, POJOs (styles, metas, `HtmlConfig`), and handle-backed wrappers extending `NativeResource`. Also compiled as-is into the AAR (`../android`). | +| `java/app/opendocument/core/` | Java API: enums, POJOs (styles, metas, `HtmlConfig`), and handle-backed wrappers extending `NativeResource`. Also compiled as-is into the AAR (`../android`) — which is kotlin, but this stays java: `add_jar` below has no kotlin toolchain. | | `tests/` | JUnit 5 suite, run via ctest (`odr_jni_junit`); inputs are generated inline (tmp files, zip-built minimal ODT) — no fixture files. | | `testfixtures/` | `TestFiles`, the inline input builder, shared with the instrumented suite of the AAR — hence limited to what android API 26 offers. | From e9496c7d64589685b69823a3ade228962e58686a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 28 Jul 2026 07:46:13 +0200 Subject: [PATCH 3/3] fix(android): store the gradle wrapper jar unmangled, gate publishing on the device suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.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) Claude-Session: https://claude.ai/code/session_01QqTNzXTxzfeCA2w1ErQzax --- .gitattributes | 1 + .github/workflows/android.yml | 67 +++++++++++++++------- android/README.md | 4 +- android/gradle/wrapper/gradle-wrapper.jar | Bin 48460 -> 48462 bytes 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/.gitattributes b/.gitattributes index 8be71bd74..04506b176 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,3 +14,4 @@ *.png binary *.jpg binary *.pdf binary +*.jar binary diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2182c8dde..020b27f3b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -105,9 +105,6 @@ jobs: aar: needs: native runs-on: ubuntu-24.04 - permissions: - contents: read - packages: write steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -130,17 +127,6 @@ jobs: merge-multiple: true path: android/native/prebuilt - - name: get version - if: github.event_name != 'push' - env: - INPUT_VERSION: ${{ inputs.version }} - run: | - if [ "${{ github.event_name }}" = release ]; then - echo "ODR_VERSION=${GITHUB_REF_NAME:1}" >> $GITHUB_ENV - else - echo "ODR_VERSION=${INPUT_VERSION}" >> $GITHUB_ENV - fi - # -Podr.abis= : the libraries are the artifacts downloaded above, so the # build must not try to cross compile them again - name: lint @@ -158,13 +144,6 @@ jobs: path: android/build/outputs/aar/*.aar if-no-files-found: error - - name: publish - if: github.event_name != 'push' - working-directory: android - run: ./gradlew publishReleasePublicationToGitHubPackagesRepository -Podr.abis= - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # The other half of the guard, and the only one that sees what a device sees: # API 26 is the floor OpenDocument.droid ships to, where a java.* method the # JDK has and android does not throws at the call (#621). @@ -217,3 +196,49 @@ jobs: with: name: instrumented-report-${{ matrix.api-level }} path: android/build/reports/androidTests + + # Nothing reaches GitHub Packages that the device suite has not run: an AAR + # that assembles and lints is not evidence the bindings work on a device, + # which is the whole point of the two jobs above. + publish: + if: github.event_name != 'push' + needs: [aar, instrumented] + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: setup java + uses: actions/setup-java@f4f1212c880fdec8162ea9a6493f4495191887b4 # v5 + with: + distribution: temurin + java-version: 21 + + - name: setup gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: download native libraries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: android-native-* + merge-multiple: true + path: android/native/prebuilt + + - name: get version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "${{ github.event_name }}" = release ]; then + echo "ODR_VERSION=${GITHUB_REF_NAME:1}" >> $GITHUB_ENV + else + echo "ODR_VERSION=${INPUT_VERSION}" >> $GITHUB_ENV + fi + + - name: publish + working-directory: android + run: ./gradlew publishReleasePublicationToGitHubPackagesRepository -Podr.abis= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/android/README.md b/android/README.md index d335aa3ac..de45edb30 100644 --- a/android/README.md +++ b/android/README.md @@ -104,7 +104,9 @@ ships to — and on a current API level. ## Publishing -Releases go to GitHub Packages, like the maven jar. That is enough for +Releases go to GitHub Packages, like the maven jar, 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. That is enough for consumers who can hold a token, and **not** enough for the one this is ultimately built for: GitHub Packages demands `read:packages` even to read a public artifact, and f-droid builds from source with no credentials at all. diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 94f4c1424df07f74d6e889be0ccf878326ced454..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch delta 23 dcmX@}i|O1irVTN2jJ%s;h8vuBP2`T^p delta 19 bcmX^2i|NcSrVTN2n`7nJlQyR|ytDxTVQ>i%