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/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..020b27f3b --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,244 @@ +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 + 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 + + # -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 + + # 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 + + # 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/.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/.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/.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..62a05ea79 --- /dev/null +++ b/android/AGENTS.md @@ -0,0 +1,62 @@ +# 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.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. | + +## 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. +- **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`). +- **`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..de45edb30 --- /dev/null +++ b/android/README.md @@ -0,0 +1,124 @@ +# 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 — +see [Publishing](#publishing) for what that rules out. Do not depend on +`odr-core-java` as well: the AAR carries the same classes. + +```kotlin +import app.opendocument.core.* +import app.opendocument.core.android.OdrAndroid + +OdrAndroid.init(context) // extracts the bundled assets, loads the library + +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`. + +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 +./gradlew spotlessApply # ktfmt, kotlinlang style +``` + +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. + +## Publishing + +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. +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. + +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 new file mode 100644 index 000000000..08e084fb6 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,209 @@ +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 = + 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..338d1e790 --- /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 + +# 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 new file mode 100644 index 000000000..a9cb9e5b6 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,21 @@ +[versions] +# 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" + +[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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..b1b8ef56b Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a9db11550 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 000000000..249efbb03 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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.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.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.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.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.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/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.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 15a77f09e..9d96bd712 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`) — 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. | ## 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