Skip to content

Android

Android #24

Workflow file for this run

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