diff --git a/.github/actions/setup-android-build/action.yml b/.github/actions/setup-android-build/action.yml index eaf9f18..e4d9d63 100644 --- a/.github/actions/setup-android-build/action.yml +++ b/.github/actions/setup-android-build/action.yml @@ -1,7 +1,7 @@ name: Set up Android build description: > Installs the pinned NDK and CMake and restores the Gradle and native build caches. - Shared by the debug and release CI jobs in android.yml and the android-apk release job, + Shared by the debug and release CI jobs in android.yml and the android release job, so the toolchain versions are pinned in one place. inputs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61fefff..c3a5ab9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -298,10 +298,17 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ── Job 2e: Android universal APK ───────────────────────────────────────── - android-apk: - name: Android (APK) + android: + name: Android needs: source-tarball runs-on: ubuntu-latest + # The secrets context is unavailable in every if condition, job-level and step-level + # alike, so presence is computed here as booleans: each expression result is the + # string "true" or "false", which is all the guards need, and no secret material + # lands in any step's environment. + env: + HAS_UPLOAD_KEYSTORE: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 != '' }} + HAS_PLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }} steps: - uses: actions/checkout@v4 @@ -312,11 +319,26 @@ jobs: with: cxx-cache-prefix: ndk-release - - name: Build release APK + # The keystore secret itself is scoped to this one step; later steps branch on the + # HAWKEYE_UPLOAD_KEYSTORE path this step exports rather than re-reading the secret, + # so no other step, including third-party actions, ever sees the keystore. A fork + # without secrets skips this step, builds the unsigned APK, and skips Play, which + # is exactly the pre-signing behavior. + - name: Decode upload keystore + if: env.HAS_UPLOAD_KEYSTORE == 'true' + env: + UPLOAD_KEYSTORE_B64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }} + run: | + printf '%s' "$UPLOAD_KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/upload-keystore.jks" + echo "HAWKEYE_UPLOAD_KEYSTORE=$RUNNER_TEMP/upload-keystore.jks" >> "$GITHUB_ENV" + + - name: Build release APK and AAB working-directory: android env: VERSION: ${{ needs.source-tarball.outputs.version }} - run: ./gradlew assembleRelease -PhawkeyeVersionName="$VERSION" + HAWKEYE_UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} + HAWKEYE_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }} + run: ./gradlew assembleRelease bundleRelease -PhawkeyeVersionName="$VERSION" - name: Verify and stage APK id: apk @@ -324,19 +346,44 @@ jobs: VERSION: ${{ needs.source-tarball.outputs.version }} run: | set -euo pipefail - SRC=$(android/scripts/verify-release-apk.sh android/app/build/outputs/apk/release "$VERSION") - # Dropping "-unsigned" when a signing config lands means updating the asset name - # in docs/installation.md, docs/developer/releasing.md, docs/troubleshooting.md, - # and README.md. - NAME="hawkeye-${VERSION}-android-unsigned.apk" + if [ -n "${HAWKEYE_UPLOAD_KEYSTORE:-}" ]; then + SRC=$(android/scripts/verify-release-apk.sh android/app/build/outputs/apk/release "$VERSION" --signed) + NAME="hawkeye-${VERSION}-android.apk" + else + SRC=$(android/scripts/verify-release-apk.sh android/app/build/outputs/apk/release "$VERSION") + NAME="hawkeye-${VERSION}-android-unsigned.apk" + fi cp "$SRC" "$NAME" echo "file=${NAME}" >> "$GITHUB_OUTPUT" + - name: Verify AAB + run: | + if [ -n "${HAWKEYE_UPLOAD_KEYSTORE:-}" ]; then + android/scripts/verify-release-bundle.sh android/app/build/outputs/bundle/release/app-release.aab --signed + else + android/scripts/verify-release-bundle.sh android/app/build/outputs/bundle/release/app-release.aab + fi + - name: Upload APK to release run: gh release upload "${{ github.ref_name }}" "${{ steps.apk.outputs.file }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The AAB goes to Google Play only; it is useless to sideloaders, so it is not + # attached to the GitHub release. Prerelease tags upload too: rc builds are what + # the internal track is for, and the version code scheme orders them correctly. + # Promotion beyond internal testing stays manual in the Play Console. The service + # account secret is scoped to this step, the only one that consumes it. + - name: Upload AAB to Play internal testing + if: env.HAS_PLAY_SERVICE_ACCOUNT == 'true' + uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.px4.hawkeye.android + releaseFiles: android/app/build/outputs/bundle/release/app-release.aab + track: internal + status: completed + # ── Job 3: Update Homebrew tap ───────────────────────────────────────────── update-tap: name: Update Homebrew tap diff --git a/README.md b/README.md index 5178a0b..1759a97 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ On first launch, Windows SmartScreen may warn that the binary is from an unknown ### Android -Download `hawkeye--android-unsigned.apk` from the [latest release](https://github.com/PX4/Hawkeye/releases/latest). It requires Android 10 or newer and bundles both `arm64-v8a` and `x86_64`. +Download `hawkeye--android.apk` from the [latest release](https://github.com/PX4/Hawkeye/releases/latest) and install it. It requires Android 10 or newer and bundles both `arm64-v8a` and `x86_64`. -The APK is unsigned and cannot be installed as downloaded. Sign it with your own key first; see the [Android README](./android/README.md) for the commands. +Releases also go to the Google Play internal test track; ask a maintainer to add you to the tester list for automatic updates. ### Source builds diff --git a/android/README.md b/android/README.md index 5087db2..a3f2026 100644 --- a/android/README.md +++ b/android/README.md @@ -245,9 +245,9 @@ Raylib 5.5 is fetched automatically by CMake on the first build. Built ABIs: `ar ./gradlew assembleRelease -PhawkeyeVersionName=0.4.0 ``` -`hawkeyeVersionName` sets the APK `versionName`, and `versionCode` is derived from it as `major * 1000000 + minor * 1000 + patch`, so `0.4.0` becomes `4000` and `1.2.3` becomes `1002003`. The value is parsed strictly: anything that isn't `MAJOR.MINOR.PATCH` with an optional suffix, or that has a component outside `0..999`, fails the build rather than producing a misleading version code. Omit the property and the build falls back to `0.0.0-dev` with version code `1`, which is why `assembleDebug` needs no flags. The release workflow passes the value from the git tag; see [Releasing](https://px4.github.io/Hawkeye/developer/releasing) for the full picture. +`hawkeyeVersionName` sets the APK `versionName`, and `versionCode` is derived from it as `major * 100000000 + minor * 100000 + patch * 100 + rc`, where the rc component is `99` for a final version, `N` for an `rcN` suffix, and `0` for `dev` and `ci`, so `0.4.0-rc1` becomes `400001` and `0.4.0` becomes `400099`. The value is parsed strictly: anything that isn't `MAJOR.MINOR.PATCH` with an optional `rcN` suffix, that has a component outside `0..999`, or whose major exceeds `20` fails the build rather than producing a misleading version code. Omit the property and the build falls back to `0.0.0-dev` with version code `1`, which is why `assembleDebug` needs no flags. The release workflow passes the value from the git tag; see [Releasing](https://px4.github.io/Hawkeye/developer/releasing) for the full picture. -The output is `app/build/outputs/apk/release/app-release-unsigned.apk`. There is no signing config in the project, so the APK is unsigned and cannot be installed until you sign it yourself (see below). +The output is `app/build/outputs/apk/release/app-release-unsigned.apk`, unless the upload keystore environment variables are set (see [Release signing](#release-signing)), in which case it is a signed `app-release.apk`. An unsigned APK cannot be installed until you sign it yourself (see below). To run the same checks CI runs against a release APK (both ABIs present, all four asset trees packaged, version as expected): @@ -266,9 +266,15 @@ adb shell am start -n com.px4.hawkeye.android/.MainActivity `MainActivity` is the launcher (the Compose shell). `HawkeyeActivity` is `exported="false"` and is launched from within the app, so it can't be started directly from `adb`. -### Signing a release APK +### Release signing -The release APK is unsigned, so `adb install` rejects it. Generate a key once, then align and sign before installing. `zipalign` and `apksigner` ship with the SDK build tools; substitute your installed version for `36.0.0`. +CI signs release builds with the project upload key when the environment provides it. `app/build.gradle.kts` activates the `upload` signing config only when `HAWKEYE_UPLOAD_KEYSTORE` (a path to a decoded keystore), `HAWKEYE_UPLOAD_KEYSTORE_PASSWORD`, and `HAWKEYE_UPLOAD_KEY_ALIAS` are set; the workflows decode the keystore from the `ANDROID_UPLOAD_KEYSTORE_BASE64` repository secret. Without those variables, `assembleRelease` produces an unsigned APK, which is what local builds and forks get. + +The release workflow also uploads the signed AAB from `bundleRelease` to the Google Play internal test track. Play App Signing re-signs it with a key Google holds, so a Play install and a sideloaded APK cannot upgrade over each other. + +### Signing an APK yourself + +A locally built release APK is unsigned, so `adb install` rejects it. Generate a key once, then align and sign before installing. `zipalign` and `apksigner` ship with the SDK build tools; substitute your installed version for `36.0.0`. ```bash keytool -genkeypair -v -keystore hawkeye.jks -alias hawkeye \ @@ -293,9 +299,9 @@ Three CI jobs build this app: | ------------- | --------------- | ------- | ---------------------------------------------- | | `android.yml` | `build` | Debug | Pull requests, pushes to main, manual dispatch | | `android.yml` | `release-build` | Release | Pushes to main, manual dispatch | -| `release.yml` | `android-apk` | Release | `v*` tag pushes only | +| `release.yml` | `android` | Release | `v*` tag pushes only | -`release-build` runs the same `assembleRelease` task and the same `scripts/verify-release-apk.sh` check the release uses, so a break shows up on a normal merge rather than on a live tag. It is skipped on pull requests to keep review turnaround fast, and can be triggered from a branch with `gh workflow run android.yml --ref `. +`release-build` runs the same `assembleRelease` and `bundleRelease` tasks and the same `scripts/verify-release-apk.sh` and `scripts/verify-release-bundle.sh` checks the release uses, including the signed path when the keystore secrets are present, so a break shows up on a normal merge rather than on a live tag. It is skipped on pull requests to keep review turnaround fast, and can be triggered from a branch with `gh workflow run android.yml --ref `. All three jobs share `.github/actions/setup-android-build` for the toolchain install and caching, so the NDK and CMake versions are pinned in one place. diff --git a/docs/developer/releasing.md b/docs/developer/releasing.md index 8e3b5b0..206fc87 100644 --- a/docs/developer/releasing.md +++ b/docs/developer/releasing.md @@ -32,7 +32,7 @@ A tag produces six assets: | `hawkeye__amd64.deb` | Linux amd64 | `deb-amd64` | | `hawkeye__arm64.deb` | Linux arm64 | `deb-arm64` | | `hawkeye--windows-x64.zip` | Windows x64 | `windows-x64` | -| `hawkeye--android-unsigned.apk` | Android | `android-apk` | +| `hawkeye--android.apk` | Android | `android` | The `.deb` files use Debian policy naming with underscores, which is why the install command in [Installation](../installation.md) globs `hawkeye_*.deb` and not `hawkeye-*.deb`. @@ -54,33 +54,27 @@ The two build systems receive it differently: | CMake | `-DHAWKEYE_VERSION=` | | Gradle | `-PhawkeyeVersionName=` | -`android/app/build.gradle.kts` computes the Android `versionCode` from that string as `major * 1000000 + minor * 1000 + patch`, so CI passes one value and Gradle derives the other: +`android/app/build.gradle.kts` computes the Android `versionCode` from that string as `major * 100000000 + minor * 100000 + patch * 100 + rc`, so CI passes one value and Gradle derives the other: -| Tag | versionName | versionCode | -| -------- | ----------- | ----------- | -| `v0.3.0` | `0.3.0` | 3000 | -| `v0.4.0` | `0.4.0` | 4000 | -| `v1.2.3` | `1.2.3` | 1002003 | -| no tag | `0.0.0-dev` | 1 | +| Tag | versionName | versionCode | +| ------------ | ----------- | ----------- | +| `v0.4.0-rc1` | `0.4.0-rc1` | 400001 | +| `v0.4.0` | `0.4.0` | 400099 | +| `v1.2.3` | `1.2.3` | 100200399 | +| no tag | `0.0.0-dev` | 1 | +The rc component is what makes prerelease tags safe: a final release takes 99, an `rcN` suffix takes N (1 through 98), and the `dev` and `ci` fallbacks take 0, so every rc sorts below its final release, above the previous release, and each code can be uploaded to Google Play exactly once. A local build with no `-PhawkeyeVersionName` falls back to `0.0.0-dev`, so debug builds need no extra flags. -The version is parsed strictly: a tag that is not `MAJOR.MINOR.PATCH` with an optional suffix, or that has a component outside `0..999`, fails the Android build rather than producing a misleading version code. - -::: warning -A prerelease tag reuses the version code of the release it precedes. -`v0.4.0-rc1` produces versionName `0.4.0-rc1` but versionCode `4000`, the same code the eventual `v0.4.0` gets, and Android refuses to install an APK whose version code is not greater than the installed one. -Avoid prerelease tags until the derivation accounts for them. -::: +The version is parsed strictly: a tag that is not `MAJOR.MINOR.PATCH` with an optional `rcN` suffix, that has a component outside `0..999`, or whose major exceeds 20 (past which the derivation overflows Google Play's version code cap) fails the Android build rather than producing a misleading version code. ## The Android APK -The `android-apk` job builds a single universal APK containing `arm64-v8a` and `x86_64`. +The `android` job builds a single universal APK containing `arm64-v8a` and `x86_64`. There is no `armeabi-v7a` build, so 32-bit ARM devices are not supported. The minimum supported platform is Android 10 (API 29). -The APK is unsigned. -There is no signing configuration in the project and no keystore secret in the repository, so the artifact is named `-unsigned` to make that obvious. -It has to be signed with your own key before it will install; see [Signing a release APK](https://github.com/PX4/Hawkeye/blob/main/android/README.md#signing-a-release-apk) in the Android README. +The APK is signed with the project's upload key, which the job decodes from the `ANDROID_UPLOAD_KEYSTORE_BASE64`, `ANDROID_UPLOAD_KEYSTORE_PASSWORD`, and `ANDROID_UPLOAD_KEY_ALIAS` repository secrets, and the asset installs as downloaded. +A fork without those secrets falls back to the pre-signing behavior: the artifact is built unsigned, named `hawkeye--android-unsigned.apk` to make that obvious, and has to be signed before it will install; see [Signing an APK yourself](https://github.com/PX4/Hawkeye/blob/main/android/README.md#signing-an-apk-yourself) in the Android README. Because the APK cannot be launched on a CI runner without an emulator, `android/scripts/verify-release-apk.sh` asserts on its contents instead: @@ -88,12 +82,25 @@ Because the APK cannot be launched on a CI runner without an emulator, `android/ - All four asset trees are packaged: `assets/models/`, `assets/shaders/`, `assets/fonts/`, and `assets/themes/`. These are symlinks into the repository root, so the check catches a runner that failed to materialize them. - The `versionName` AGP recorded matches the tag, and the `versionCode` is one Android will accept. +- With `--signed`, which the job passes whenever the keystore secrets are present, `apksigner verify` confirms the signature. That script takes the APK output directory and the expected version, so you can run the same check locally against your own build. +## Google Play internal testing + +The same `android` job also runs `bundleRelease`, checks the resulting AAB with `android/scripts/verify-release-bundle.sh`, and uploads it to the Google Play internal test track under the Dronecode Foundation account. +The upload step runs only when the `PLAY_SERVICE_ACCOUNT_JSON` secret is present; it authenticates as a Google Cloud service account granted release-to-testing permission on the app in the Play Console. +Prerelease tags upload like any other tag; rc builds are what the internal track is for. +Promotion beyond internal testing is manual in the Play Console. + +The AAB is not attached to the GitHub release. +Google Play is its only destination, and Play App Signing re-signs it with the app signing key Google holds, so a Play install and a sideloaded APK carry different signatures and cannot upgrade over each other. + +Anyone who sideloaded a self-signed APK from a release cut before signing landed (v0.3.0 and earlier) has to uninstall it once before an official signed build will install; release notes should carry that reminder until it stops being relevant. + ## Checking a release build before tagging -`.github/workflows/android.yml` has a `release-build` job that runs the same `assembleRelease` task and the same verification script the release uses. +`.github/workflows/android.yml` has a `release-build` job that runs the same `assembleRelease` and `bundleRelease` tasks and the same verification scripts the release uses, including the signed path when the keystore secrets are present. It runs on pushes to `main` and on manual dispatch, and is skipped on pull requests to keep review turnaround fast. Trigger it from a branch before tagging: diff --git a/docs/installation.md b/docs/installation.md index bd3b7da..d2cd888 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,7 +1,7 @@ # Installation Hawkeye ships as a Homebrew formula on macOS, a `.deb` package on Debian/Ubuntu, and a portable ZIP on Windows. -Android builds are published as an unsigned APK for developers. +Android builds are published as a signed APK and through Google Play internal testing. If you want the latest development build, see [Building from source](./developer/build.md). ## macOS (Homebrew) @@ -42,16 +42,16 @@ To launch from any terminal, add the extracted folder to your `PATH`. ## Android -Download `hawkeye--android-unsigned.apk` from the [Hawkeye releases page](https://github.com/PX4/Hawkeye/releases/latest). +Download `hawkeye--android.apk` from the [Hawkeye releases page](https://github.com/PX4/Hawkeye/releases/latest) and install it. +The APK is signed and installs as downloaded; Android asks you to allow installs from your browser or file manager the first time. The APK bundles both `arm64-v8a` and `x86_64`, so it runs on 64-bit ARM devices and in the Android emulator. It requires Android 10 (API 29) or newer and a GPU with OpenGL ES 3.0. -::: info Unsigned APK -The release APK ships unsigned and cannot be installed as downloaded. -Sign it with your own key using `zipalign` and `apksigner` from the Android SDK build tools first. -See the [Android README](https://github.com/PX4/Hawkeye/blob/main/android/README.md) for the exact commands. -Signed distribution is on the roadmap. +::: info Google Play internal testing +Releases are also pushed to the Google Play internal test track. +Ask a maintainer to add your Google account to the tester list if you prefer installs and automatic updates through Play. +A Play install and a sideloaded APK are signed with different keys, so switching between them requires an uninstall. ::: ## Verifying the install diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dac6d87..cf764f9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -203,16 +203,19 @@ Common fixes: ### The downloaded APK won't install -The release APK is published unsigned, so the package installer rejects it. -Confirm that is the cause with `apksigner` from the Android SDK build tools: +Release APKs are signed, so a fresh install succeeds as downloaded. +An install that fails over an existing Hawkeye has one of two causes: + +- The installed version has an equal or higher version code. Android refuses downgrades; uninstall first. +- The installed build is self-signed, either a local build or a release from the unsigned era (v0.3.0 and earlier). Its certificate does not match the project's upload key, so the installer rejects the update. Uninstall once; updates work normally from then on. + +Check what a package is signed with using `apksigner` from the Android SDK build tools: ```sh -apksigner verify hawkeye--android-unsigned.apk +apksigner verify --print-certs hawkeye--android.apk ``` -An unsigned APK reports `DOES NOT VERIFY` with `Missing META-INF/MANIFEST.MF`. -Sign it with your own key before installing. -The [Android README](https://github.com/PX4/Hawkeye/blob/main/android/README.md) has the `keytool`, `zipalign`, and `apksigner` commands. +An official release verifies and prints the project certificate; a build you signed yourself prints yours. ### The APK installs on one device but not another