Android #24
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Android | |
| # Android builds run on published releases (matching release.yml), not on every | |
| # PR — the Rust cross-compile + Gradle build is slow and rarely needs per-PR | |
| # coverage. Use the manual "Run workflow" button (workflow_dispatch) to build a | |
| # signed APK on demand from any branch. | |
| on: | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| # Least privilege at the workflow level; the build job below elevates to | |
| # contents: write only where it needs to attach release assets. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: android-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| # Keep these in sync with docs/android.md and the local setup. | |
| # | |
| # API 36 (Android 16), not 34: the Tauri v2.11 Android template generates | |
| # `compileSdk = 36` / `targetSdk = 36`, so Gradle needs the matching platform | |
| # installed. It is also the Google Play floor — from 2026-08-31 new apps and | |
| # updates must target API 36 or the upload is rejected. | |
| ANDROID_PLATFORM: "platforms;android-36" | |
| ANDROID_BUILD_TOOLS: "build-tools;36.0.0" | |
| # NDK r27 LTS — Tauri v2's supported line. Bump deliberately. | |
| ANDROID_NDK_VERSION: "27.3.13750724" | |
| # 16 KB page-size alignment for the Rust .so files (a Google Play requirement | |
| # since 2025-11-01 for apps targeting Android 15+; GeoLibre targets 36). | |
| # NDK r28+ emits aligned segments by default, r27 does not, so ask explicitly. | |
| # | |
| # This MUST be the RUSTFLAGS env var rather than `target.<triple>.rustflags` | |
| # in a .cargo/config.toml: the Tauri CLI sets RUSTFLAGS itself when invoking | |
| # cargo for Android, and an env RUSTFLAGS overrides the config file outright, | |
| # so the config-file form is silently ignored and ships 4 KB-aligned libs. | |
| # Tauri appends to an inherited value, so setting it here survives. Verified | |
| # against real APKs: without it the shipped .so are 2**12, with it 2**14. | |
| # | |
| # Set at workflow level so the APK and AAB steps agree — a mismatch changes | |
| # the cargo fingerprint and forces a full rebuild between them. | |
| RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384" | |
| jobs: | |
| build: | |
| name: Build Android APK (release) | |
| runs-on: ubuntu-22.04 | |
| # Elevated here (not workflow-level) so only this job can write release | |
| # assets; workflow_dispatch runs never attach but the grant is harmless. | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22" | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - name: Set up JDK | |
| uses: actions/setup-java@v5 | |
| with: | |
| distribution: temurin | |
| java-version: "21" | |
| - name: Set up Android SDK | |
| # Third-party action pinned to a full commit SHA (v4.0.1) per the repo's | |
| # policy for non-official actions; a tag could be re-pointed. v4 runs on | |
| # Node.js 24 (v3 targeted the now-deprecated Node.js 20). | |
| uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 | |
| - name: Install Android NDK, platform, and build-tools | |
| run: | | |
| sdkmanager --install \ | |
| "platform-tools" \ | |
| "$ANDROID_PLATFORM" \ | |
| "$ANDROID_BUILD_TOOLS" \ | |
| "ndk;$ANDROID_NDK_VERSION" | |
| echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/$ANDROID_NDK_VERSION" >> "$GITHUB_ENV" | |
| - name: Install Rust stable with Android targets | |
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| targets: >- | |
| aarch64-linux-android, | |
| armv7-linux-androideabi, | |
| i686-linux-android, | |
| x86_64-linux-android | |
| - name: Cache Rust dependencies | |
| uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| with: | |
| workspaces: apps/geolibre-desktop/src-tauri -> target | |
| - name: Install frontend dependencies | |
| run: npm ci | |
| - name: Generate Android project | |
| # gen/android is not committed; regenerate it on the clean runner. The | |
| # Tauri CLI is resolved through the geolibre-desktop workspace. | |
| working-directory: apps/geolibre-desktop | |
| run: npx tauri android init | |
| - name: Apply GeoLibre launcher icons | |
| # `tauri android init` writes default Tauri icons; overwrite the generated | |
| # mipmaps with the GeoLibre launcher icons checked in under src-tauri/icons. | |
| working-directory: apps/geolibre-desktop | |
| run: cp -r src-tauri/icons/android/. src-tauri/gen/android/app/src/main/res/ | |
| - name: Build release APKs (per ABI) | |
| # Release (not --debug): the size-optimized + stripped Cargo profile keeps | |
| # each .so small. --split-per-abi emits one ~40 MB APK per architecture | |
| # instead of a single ~150 MB universal APK bundling all four ABIs. | |
| # Release APKs are unsigned by default; the next step signs them. | |
| working-directory: apps/geolibre-desktop | |
| run: npx tauri android build --apk --split-per-abi | |
| env: | |
| VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }} | |
| - name: Check for release keystore | |
| id: keystore | |
| # `secrets` cannot be used in a step-level `if:`, so presence is turned | |
| # into an output here and gated on below. Deliberately keyed on the | |
| # keystore rather than `event_name == 'release'` so a workflow_dispatch | |
| # run with the secrets set can still exercise the full Play path. | |
| env: | |
| KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| if [ -n "${KEYSTORE_BASE64:-}" ]; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::No release keystore — skipping the AAB build (it could not be signed or uploaded anyway)." | |
| fi | |
| - name: Build release AAB (universal) | |
| # Skipped without a release keystore: the AAB can only be signed and | |
| # uploaded on `sign_mode = release` runs, so building it otherwise is a | |
| # full Gradle bundle build for an artifact that is discarded. | |
| if: steps.keystore.outputs.present == 'true' | |
| # Google Play requires an Android App Bundle, not an APK, for new apps — | |
| # Play generates the per-device splits itself from this single artifact. | |
| # Deliberately NOT --split-per-abi: that would emit one AAB per ABI, and | |
| # Play wants the universal bundle containing all four. The per-ABI APKs | |
| # above remain the sideload/GitHub-release path. | |
| # The Rust objects are already built by the previous step, so this is | |
| # mostly a Gradle repackage rather than a second cross-compile. | |
| working-directory: apps/geolibre-desktop | |
| run: npx tauri android build --aab | |
| env: | |
| VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }} | |
| - name: Verify 16 KB page alignment | |
| # Play rejects apps targeting Android 15+ whose native libs are not | |
| # 16 KB-aligned. The RUSTFLAGS above are what produce the alignment; | |
| # this step proves they actually took effect, so a silent regression | |
| # (flags dropped, Tauri overriding them, NDK change) fails here rather | |
| # than at upload time. | |
| # | |
| # Inspects the .so *inside the built APKs* — the bytes Play receives — | |
| # rather than a path under gen/android. Do not be tempted back to | |
| # `find gen/android -path '*/release/*'`: Gradle's output dirs are | |
| # arm64Release / armRelease / x86Release / x86_64Release (capital R), | |
| # so that pattern matches nothing and the check never runs. | |
| run: | | |
| set -euo pipefail | |
| objdump="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objdump" | |
| checked=0 | |
| bad=0 | |
| workdir="$(mktemp -d)" | |
| trap 'rm -rf "$workdir"' EXIT | |
| # Unpack the native libs from every release APK *and* the AAB into one | |
| # tree, then check all of them. The AAB matters most — it is what Play | |
| # receives — and it stores libraries under `base/lib/<abi>/`, not | |
| # `lib/<abi>/`, so it needs its own extract pattern. Checking only the | |
| # APKs would leave the Play artifact unverified. | |
| # | |
| # Each archive extracts into its own subdirectory and is checked for a | |
| # non-empty result immediately. `unzip || true` is needed because | |
| # unzip exits 11 on "no matching files", but that is exactly the | |
| # symptom of an archive shipping no native code at all — a worse | |
| # regression than misalignment. The aggregate `checked -eq 0` guard | |
| # below cannot catch it, since the other archives still contribute | |
| # libraries and the total stays non-zero. | |
| archives=0 | |
| while IFS= read -r apk; do | |
| archives=$((archives + 1)) | |
| dest="$workdir/$archives" | |
| unzip -o -q "$apk" 'lib/*/*.so' -d "$dest" || true | |
| if [ "$(find "$dest" -name '*.so' 2>/dev/null | wc -l)" -eq 0 ]; then | |
| echo "::error::$apk contains no native libraries under lib/" | |
| bad=$((bad + 1)) | |
| fi | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -name '*release*.apk') | |
| if [ "$archives" -eq 0 ]; then | |
| echo "::error::No release APKs found to verify" | |
| exit 1 | |
| fi | |
| # The AAB is only built on release-keystore runs, so its absence here | |
| # is not an error — but when present it must be verified too. | |
| while IFS= read -r aab; do | |
| archives=$((archives + 1)) | |
| dest="$workdir/$archives" | |
| unzip -o -q "$aab" 'base/lib/*/*.so' -d "$dest" || true | |
| if [ "$(find "$dest" -name '*.so' 2>/dev/null | wc -l)" -eq 0 ]; then | |
| echo "::error::$aab contains no native libraries under base/lib/" | |
| bad=$((bad + 1)) | |
| fi | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/bundle/*' -name '*.aab') | |
| while IFS= read -r so; do | |
| checked=$((checked + 1)) | |
| # Per-file segment count. If objdump errors or emits no LOAD lines | |
| # for this .so (corrupt artifact, foreign arch, tool change), the | |
| # inner loop never runs, so without this the file would be counted | |
| # as "checked" while nothing was actually inspected. | |
| segments=0 | |
| # `objdump -p` prints one line per segment ending in `align 2**N` | |
| # (readelf wraps LOAD across two lines, which makes it far easier to | |
| # parse the wrong column). 2**14 == 16384 is the required minimum. | |
| # awk strips the `2**` prefix, not the shell: in a `${x#2**}` | |
| # expansion `*` is a glob, so that form silently yields `**14` and | |
| # every comparison below becomes a no-op that passes. | |
| while read -r exp; do | |
| segments=$((segments + 1)) | |
| # Treat unparseable output as a failure, never as a pass — a | |
| # tooling change that breaks the format must not read as "aligned". | |
| case "$exp" in | |
| '' | *[!0-9]*) | |
| echo "::error::$so: could not parse segment alignment ('$exp')" | |
| bad=$((bad + 1)) | |
| ;; | |
| *) | |
| if [ "$exp" -lt 14 ]; then | |
| echo "::error::$so has a LOAD segment aligned to 2**$exp (< 2**14)" | |
| bad=$((bad + 1)) | |
| fi | |
| ;; | |
| esac | |
| done < <("$objdump" -p "$so" | | |
| awk '$1 == "LOAD" { split($NF, a, /\*\*/); print a[2] }') | |
| if [ "$segments" -eq 0 ]; then | |
| echo "::error::$so: objdump reported no LOAD segments — nothing was verified" | |
| bad=$((bad + 1)) | |
| fi | |
| done < <(find "$workdir" -name '*.so') | |
| if [ "$checked" -eq 0 ]; then | |
| echo "::error::No .so files found inside the APKs — the check would pass vacuously" | |
| exit 1 | |
| fi | |
| if [ "$bad" -gt 0 ]; then | |
| # "problem(s)", not "segment(s)": $bad now also counts archives that | |
| # shipped no native libraries and files objdump could not read, not | |
| # just misaligned segments. | |
| echo "::error::$bad problem(s) found; Play will reject this build" | |
| exit 1 | |
| fi | |
| echo "All $checked native libraries are 16 KB-aligned." | |
| - name: Sign APKs and AAB | |
| id: sign | |
| # With release-keystore secrets set, the artifacts are signed for | |
| # distribution. Without them, they're signed with a throwaway debug | |
| # keystore so the CI APKs are still installable for testing (do NOT | |
| # publish those). Emits signed=release|debug so the release-upload step | |
| # can refuse to attach debug-signed APKs to a public GitHub Release. | |
| # | |
| # For Play, the keystore secrets hold the *upload* key, not the app | |
| # signing key: Play App Signing re-signs the bundle with the key Google | |
| # holds. A debug-signed AAB is useless — Play rejects it — so the AAB is | |
| # only produced when the release keystore is present. | |
| env: | |
| KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | |
| KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| build_tools="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)" | |
| store_pass_file="$RUNNER_TEMP/ks.pass" | |
| key_pass_file="$RUNNER_TEMP/key.pass" | |
| if [ -n "${KEYSTORE_BASE64:-}" ]; then | |
| # Fail fast if the keystore secret is set but its companions are not, | |
| # instead of a cryptic apksigner error later. | |
| if [ -z "${KEYSTORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ]; then | |
| echo "::error::ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_ALIAS must be set when ANDROID_KEYSTORE_BASE64 is provided" | |
| exit 1 | |
| fi | |
| echo "Signing with the release keystore from secrets." | |
| echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" | |
| keystore="$RUNNER_TEMP/release.jks" | |
| printf '%s' "$KEYSTORE_PASSWORD" > "$store_pass_file" | |
| printf '%s' "${KEY_PASSWORD:-$KEYSTORE_PASSWORD}" > "$key_pass_file" | |
| alias="$KEY_ALIAS" | |
| sign_mode=release | |
| else | |
| echo "::warning::No ANDROID_KEYSTORE_BASE64 secret set — signing with a throwaway debug keystore. Installable for testing only, NOT for distribution." | |
| sign_mode=debug | |
| keystore="$RUNNER_TEMP/debug.jks" | |
| "$JAVA_HOME/bin/keytool" -genkeypair -v -keystore "$keystore" \ | |
| -storepass android -keypass android -alias androiddebugkey \ | |
| -keyalg RSA -keysize 2048 -validity 10000 \ | |
| -dname "CN=Android Debug,O=Android,C=US" | |
| printf '%s' android > "$store_pass_file" | |
| printf '%s' android > "$key_pass_file" | |
| alias=androiddebugkey | |
| fi | |
| out="$RUNNER_TEMP/apks"; mkdir -p "$out" | |
| found=0 | |
| while IFS= read -r unsigned; do | |
| found=1 | |
| # e.g. app-arm64-v8a-release-unsigned.apk -> geolibre-arm64-v8a.apk | |
| abi="$(basename "$unsigned" | sed -E 's/^app-(.*)-release-unsigned\.apk$/\1/')" | |
| aligned="$RUNNER_TEMP/aligned-$abi.apk" | |
| signed="$out/geolibre-android-$abi.apk" | |
| # -P 16, not -p: the 16 KB requirement has two independent axes. | |
| # The RUSTFLAGS above fix the ELF segment alignment *inside* the | |
| # .so; this fixes the .so's byte offset *within the APK zip*, which | |
| # is what lets the OS mmap it directly under extractNativeLibs=false. | |
| # Lowercase -p only guarantees 4 KB. AGP currently happens to emit | |
| # 16 KB-aligned offsets anyway, so -p passes today by luck; -P 16 | |
| # states the requirement so a future AGP change cannot silently | |
| # regress it. (-P needs build-tools 35+; this workflow pins 36.) | |
| "$build_tools/zipalign" -P 16 -f 4 "$unsigned" "$aligned" | |
| # Pass passwords via files (pass:file:) so they never appear in the | |
| # process argument list / CI logs. | |
| "$build_tools/apksigner" sign --ks "$keystore" \ | |
| --ks-pass "file:$store_pass_file" --key-pass "file:$key_pass_file" \ | |
| --ks-key-alias "$alias" --out "$signed" "$aligned" | |
| "$build_tools/apksigner" verify "$signed" | |
| # Verify the zip-entry axis on the final artifact. The ELF axis is | |
| # checked before signing; this is the one zipalign controls, and | |
| # checking it here (post-sign) covers the bytes Play receives. | |
| "$build_tools/zipalign" -c -P 16 -v 4 "$signed" > /dev/null || { | |
| echo "::error::$signed is not 16 KB zip-aligned; Play will reject it" | |
| rm -f "$store_pass_file" "$key_pass_file" | |
| exit 1 | |
| } | |
| echo "signed $signed ($(du -h "$signed" | cut -f1))" | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -name '*release-unsigned.apk') | |
| if [ "$found" -eq 0 ]; then | |
| rm -f "$store_pass_file" "$key_pass_file" | |
| echo "::error::No release-unsigned APKs found"; exit 1 | |
| fi | |
| # AAB for Google Play. jarsigner, not apksigner: apksigner only handles | |
| # APKs. Only signed with a real upload key — a debug-signed bundle is | |
| # rejected by Play, so shipping one would just be a confusing artifact. | |
| if [ "$sign_mode" = release ]; then | |
| aab_out="$RUNNER_TEMP/aab"; mkdir -p "$aab_out" | |
| # Scope to outputs/bundle/. A bare `-name '*.aab'` is NOT enough: | |
| # the build also leaves AGP's internal | |
| # app/build/intermediates/intermediary_bundle/.../intermediary-bundle.aab | |
| # so an unscoped match plus `head -1` depends on find's traversal | |
| # order and can select the intermediate — signing and uploading the | |
| # wrong artifact to Play. Verified locally: two .aab exist after a | |
| # successful build. | |
| # | |
| # `outputs/bundle` are literal lowercase directories, so this does | |
| # not reintroduce the camelCase trap (`universalRelease/` is matched | |
| # by the wildcard, never spelled out). | |
| mapfile -t aab_candidates < <(find \ | |
| apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/bundle/*' -name '*.aab' | sort) | |
| if [ "${#aab_candidates[@]}" -eq 0 ]; then | |
| rm -f "$store_pass_file" "$key_pass_file" | |
| echo "::error::No AAB found under outputs/bundle/"; exit 1 | |
| fi | |
| if [ "${#aab_candidates[@]}" -gt 1 ]; then | |
| # Ambiguity must not be resolved by picking one arbitrarily. | |
| rm -f "$store_pass_file" "$key_pass_file" | |
| echo "::error::Expected exactly one AAB, found ${#aab_candidates[@]}:" | |
| printf '::error:: %s\n' "${aab_candidates[@]}" | |
| exit 1 | |
| fi | |
| aab="${aab_candidates[0]}" | |
| echo "Using AAB: $aab" | |
| signed_aab="$aab_out/geolibre-android.aab" | |
| cp "$aab" "$signed_aab" | |
| "$JAVA_HOME/bin/jarsigner" -keystore "$keystore" \ | |
| -storepass:file "$store_pass_file" -keypass:file "$key_pass_file" \ | |
| -sigalg SHA256withRSA -digestalg SHA-256 "$signed_aab" "$alias" | |
| "$JAVA_HOME/bin/jarsigner" -verify "$signed_aab" | |
| echo "signed $signed_aab ($(du -h "$signed_aab" | cut -f1))" | |
| else | |
| echo "::warning::Skipping AAB signing — no release keystore. The Play upload artifact is not produced on this run." | |
| fi | |
| rm -f "$store_pass_file" "$key_pass_file" | |
| # Emit the outcome only after every artifact has signed and verified, | |
| # so a downstream always() step can never read signed=release on a | |
| # failure. | |
| echo "signed=$sign_mode" >> "$GITHUB_OUTPUT" | |
| - name: Upload signed APKs | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: geolibre-android-release-apks | |
| path: ${{ runner.temp }}/apks/*.apk | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Upload Play AAB | |
| # Separate artifact from the APKs: this is the file you upload to the | |
| # Play Console, and it only exists on release-keystore runs. Deliberately | |
| # NOT attached to the GitHub Release — an .aab is not user-installable | |
| # and would only confuse people looking for a sideload download. | |
| if: steps.sign.outputs.signed == 'release' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: geolibre-android-play-aab | |
| path: ${{ runner.temp }}/aab/*.aab | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Attach APKs to GitHub Release | |
| # Only on a published release, and only when the APKs were signed with the | |
| # real release keystore — never publish debug-signed builds as official | |
| # downloads. workflow_dispatch runs still get the CI artifact above. | |
| if: github.event_name == 'release' && steps.sign.outputs.signed == 'release' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ github.event.release.tag_name }} | |
| run: gh release upload "$TAG" "$RUNNER_TEMP"/apks/*.apk --clobber | |
| - name: Note skipped release upload | |
| # Surface why a release run did not attach APKs (missing keystore secrets), | |
| # so it does not look like a silent failure. | |
| if: github.event_name == 'release' && steps.sign.outputs.signed != 'release' | |
| run: | | |
| echo "::warning::APKs were debug-signed (no ANDROID_KEYSTORE_BASE64 secret) and were NOT attached to the release. They are available as the CI artifact for testing only." |