name: Build Android on: workflow_dispatch: inputs: patch: description: "Optional patch number, matching the main build workflow." required: false type: string default: "0" workflow_call: inputs: source_tag: description: "Existing source tag for a standalone Android release." required: false type: string default: "" patch: description: "Optional patch number from the main build workflow." required: false type: string default: "0" outputs: version: description: "Android artifact version; combined releases match the desktop tag." value: ${{ jobs.build.outputs.version }} pull_request: paths: - ".github/workflows/build-android.yml" - ".github/workflows/build.yml" - ".github/workflows/release-mobile.yml" - ".github/workflows/sign-android.yml" - ".gitmodules" - "LICENSE" - "PRIVACY.md" - "apps/android/**" - "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json" - "apps/ios/licenses/**" - "apps/ios/scripts/generate_third_party_licenses.py" - "deps/submodules/minirtc" - "libs/wire/**" permissions: contents: read jobs: build: name: Build on Android (arm64) runs-on: ubuntu-24.04 timeout-minutes: 120 outputs: version: ${{ steps.package.outputs.version }} env: # Exercise the minimum supported version; local builds may use newer Xmake. XMAKE_MIN_VERSION: "3.1.1" CROSSDESK_SOURCE_TAG: ${{ inputs.source_tag || '' }} CROSSDESK_RELEASE_BUILD: ${{ inputs.source_tag && 'YES' || 'NO' }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 ref: ${{ inputs.source_tag || github.ref }} submodules: recursive persist-credentials: false - name: Set up Java uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" cache: pip # Python build dependencies are declared in this workflow. This file # also exists when source_tag checks out an older Android release. cache-dependency-path: .github/workflows/build-android.yml - name: Install native build tools run: | crossdesk_missing_packages=() for crossdesk_package in build-essential cmake ninja-build pkg-config autoconf automake libtool nasm; do if [[ "$(dpkg-query -W -f='${Status}' "${crossdesk_package}" 2>/dev/null || true)" != "install ok installed" ]]; then crossdesk_missing_packages+=("${crossdesk_package}") fi done if (( ${#crossdesk_missing_packages[@]} )); then sudo apt-get update sudo apt-get install -y --no-install-recommends "${crossdesk_missing_packages[@]}" fi python3 -m pip install setuptools wheel # Use an isolated SDK directory so the cache contains only this toolchain, # rather than every SDK/NDK version preinstalled on the runner. - name: Select Android SDK directory run: | echo "ANDROID_HOME=${RUNNER_TEMP}/crossdesk-android-sdk" >> "${GITHUB_ENV}" echo "ANDROID_SDK_ROOT=${RUNNER_TEMP}/crossdesk-android-sdk" >> "${GITHUB_ENV}" - name: Restore Android SDK id: android-sdk-cache uses: actions/cache/restore@v5 with: path: ${{ env.ANDROID_HOME }} key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-sdk-v1-cli12266719-api36-build36.0.0-ndk28.2.13676358 - name: Set up Android SDK if: steps.android-sdk-cache.outputs.cache-hit != 'true' uses: android-actions/setup-android@v3 with: cmdline-tools-version: "12266719" log-accepted-android-sdk-licenses: false packages: "platform-tools platforms;android-36 build-tools;36.0.0 ndk;28.2.13676358" # Save immediately after installation, even if a later APK build fails. - name: Save Android SDK if: steps.android-sdk-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: ${{ env.ANDROID_HOME }} key: ${{ steps.android-sdk-cache.outputs.cache-primary-key }} - name: Add Android tools to PATH run: | echo "${ANDROID_HOME}/cmdline-tools/16.0/bin" >> "${GITHUB_PATH}" echo "${ANDROID_HOME}/platform-tools" >> "${GITHUB_PATH}" - name: Install xmake uses: xmake-io/github-action-setup-xmake@v1 with: xmake-version: ${{ env.XMAKE_MIN_VERSION }} actions-cache-folder: .xmake-toolchain-cache actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }} - name: Cache Xmake package repository uses: actions/cache@v5 with: path: apps/android/.native/global/.xmake/repositories/xmake-repo key: ${{ runner.os }}-${{ runner.arch }}-android-xmake-repo-v1-${{ hashFiles('apps/android/scripts/build_native.py') }} # Cache installed packages only. build_native.py checks Xmake >= 3.1.1 and pins # its own package repository; never reuse another platform's build/config. - name: Cache native dependencies uses: actions/cache@v5 with: path: apps/android/.native/packages key: ${{ runner.os }}-${{ runner.arch }}-android-arm64-native-v1-${{ hashFiles('apps/android/scripts/build_native.py', 'apps/android/native/xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }} - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 with: cache-provider: basic cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Verify shared license catalog run: python3 -B apps/ios/scripts/generate_third_party_licenses.py - name: Resolve Android version shell: python env: PATCH_NUMBER: ${{ inputs.patch || '0' }} run: | import datetime import os from pathlib import Path import re import runpy resolver = Path("apps/android/scripts/release_version.py") if resolver.exists(): runpy.run_path(str(resolver), run_name="__main__") else: # Older standalone releases predate the resolver and Gradle properties. # Keep their original package version when retrying an existing tag. tag = os.environ["CROSSDESK_SOURCE_TAG"] match = re.fullmatch(r"android-v([0-9]+\.[0-9]+\.[0-9]+)-([0-9]{8})", tag) config = Path("apps/android/app/build.gradle").read_text() name = re.search(r"versionName\s+'([^']+)'", config) code = re.search(r"versionCode\s+([0-9]+)", config) if not match or not name or not code or match[1] != name[1]: raise ValueError("Legacy Android sources require a matching standalone release tag") datetime.datetime.strptime(match[2], "%Y%m%d") with open(os.environ["GITHUB_ENV"], "a") as stream: stream.write(f"ANDROID_VERSION_NAME={name[1]}\nANDROID_VERSION_CODE={code[1]}\n" f"ANDROID_ARTIFACT_VERSION={tag.removeprefix('android-')}\n") - name: Build APKs working-directory: apps/android run: | ./gradlew --no-daemon --stacktrace \ -PcrossdeskVersionName="${ANDROID_VERSION_NAME}" \ -PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \ :app:assembleDebug :app:assembleRelease - name: Verify APKs working-directory: apps/android run: | for APK in app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk; do python3 -B test/verify_apk.py "${APK}" \ --aapt2 "${ANDROID_HOME}/build-tools/36.0.0/aapt2" \ --version-name "${ANDROID_VERSION_NAME}" --version-code "${ANDROID_VERSION_CODE}" done "${ANDROID_HOME}/build-tools/36.0.0/apksigner" verify app/build/outputs/apk/debug/app-debug.apk - name: Package APKs with the resolved Android version id: package shell: python run: | import json import os from pathlib import Path import shutil apk_dir = Path("apps/android/app/build/outputs/apk") version = os.environ["ANDROID_ARTIFACT_VERSION"] for variant in ("debug", "release"): metadata = json.loads((apk_dir / variant / "output-metadata.json").read_text()) for element in metadata["elements"]: if (element["versionName"] != os.environ["ANDROID_VERSION_NAME"] or element["versionCode"] != int(os.environ["ANDROID_VERSION_CODE"])): raise ValueError(f"Unexpected Android {variant} version: {element}") output = Path(os.environ["RUNNER_TEMP"]) / "crossdesk-android" for variant, source in ( ("unsigned", "release/app-release-unsigned.apk"), ("debug", "debug/app-debug.apk"), ): destination = output / variant destination.mkdir(parents=True, exist_ok=True) shutil.copyfile(apk_dir / source, destination / f"crossdesk-android-arm64-{variant}-{version}.apk") with open(os.environ["GITHUB_OUTPUT"], "a") as stream: stream.write(f"version={version}\n") with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as stream: stream.write(f"Android {version}: arm64, API 26+, 16 KB aligned.\n\n" "Debug APK uses an ephemeral CI debug key; it is for testing and may require uninstalling an existing build. " "The Release APK is unsigned and needs your own signing before installation.\n\n" "Debug and Release APKs built and packaged; APK checks passed.\n") - name: Upload unsigned Release APK uses: actions/upload-artifact@v6 with: name: crossdesk-android-arm64-unsigned-${{ steps.package.outputs.version }} path: ${{ runner.temp }}/crossdesk-android/unsigned/*.apk if-no-files-found: error - name: Upload Debug APK uses: actions/upload-artifact@v6 with: name: crossdesk-android-arm64-debug-${{ steps.package.outputs.version }} path: ${{ runner.temp }}/crossdesk-android/debug/*.apk if-no-files-found: error retention-days: 14