Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup-android-build/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
65 changes: 56 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -312,31 +319,71 @@ 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
env:
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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ On first launch, Windows SmartScreen may warn that the binary is from an unknown

### Android

Download `hawkeye-<version>-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-<version>-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

Expand Down
18 changes: 12 additions & 6 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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 \
Expand All @@ -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 <branch>`.
`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 <branch>`.

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.

Expand Down
47 changes: 27 additions & 20 deletions docs/developer/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ A tag produces six assets:
| `hawkeye_<version>_amd64.deb` | Linux amd64 | `deb-amd64` |
| `hawkeye_<version>_arm64.deb` | Linux arm64 | `deb-arm64` |
| `hawkeye-<version>-windows-x64.zip` | Windows x64 | `windows-x64` |
| `hawkeye-<version>-android-unsigned.apk` | Android | `android-apk` |
| `hawkeye-<version>-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`.

Expand All @@ -54,46 +54,53 @@ The two build systems receive it differently:
| CMake | `-DHAWKEYE_VERSION=<version>` |
| Gradle | `-PhawkeyeVersionName=<version>` |

`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-<version>-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:

- Both `lib/arm64-v8a/libhawkeye.so` and `lib/x86_64/libhawkeye.so` are present.
- 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:
Expand Down
14 changes: 7 additions & 7 deletions docs/installation.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -42,16 +42,16 @@ To launch from any terminal, add the extracted folder to your `PATH`.

## Android

Download `hawkeye-<version>-android-unsigned.apk` from the [Hawkeye releases page](https://github.com/PX4/Hawkeye/releases/latest).
Download `hawkeye-<version>-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
Expand Down
Loading
Loading