name: Build & publish the Android APK

# The fifth workflow, and the second that publishes.  It builds a signed
# arm64-v8a APK on every version tag and puts it in
# Gitea's *generic* package registry, which — unlike the repository — is
# readable without credentials.  That is what lets an Obtainium client
# poll a plain URL with no token and no public mirror of the source.
#
# **Why its own file rather than a job in ci.yml.**  `ci.yml` runs on
# every branch push and is the workflow that gates; this one runs on
# tags only, takes tens of minutes on a cold cache, and the runner has
# capacity 1.  Hanging it off the gate would put every push behind an
# SDK download.
#
# **Why it is keyed on the tag.**  The ljos pipeline this is modelled on
# computes a version in CI and cuts the release itself, then gates the
# Android job on `needs.release.outputs.version != ''` with an
# `always()` whose absence silently kills the manual path.  This repo
# has no release automation — tags are pushed by hand and
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
# version and none of that machinery, or its failure modes, is needed.
#
# It deliberately does **not** carry `continue-on-error`.  In ljos the
# Android job shared a pipeline with a server deploy that must never go
# red over a phone build; here it is standalone and can neither delay
# nor redden anything, so a release step that fails silently would be
# strictly worse than one that fails visibly.

on:
  push:
    tags: ["v*"]
  workflow_dispatch:
    inputs:
      version:
        description: "Version to build (default: the latest v* tag)"
        required: false

concurrency:
  group: android-${{ github.ref }}
  cancel-in-progress: true

jobs:
  apk:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    container:
      image: ubuntu:24.04
      # /cache/tool holds the Go toolchain ci.yml already downloads.
      # The other three are this workflow's own and are ~4 GB between
      # them, which is most of its wall clock on a cold run:
      #   android-sdk  the SDK, the NDK and the platform (~2 GB)
      #   gradle       GRADLE_USER_HOME — the wrapper distribution and
      #                the AGP dependency graph (~700 MB)
      #   pnpm-store   shared with ci.yml
      # Every path must be inside the runner's `valid_volumes` allowlist:
      # a directory outside it makes the job **fail to start**, rather
      # than silently skipping the mount.
      volumes:
        - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
        - /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
        - /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
        - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
    env:
      PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
      SERVER_URL: ${{ github.server_url }}
      REPO: ${{ github.repository }}
      OWNER: ${{ github.repository_owner }}
      SHA: ${{ github.sha }}
      REF_NAME: ${{ github.ref_name }}
      DEBIAN_FRONTEND: noninteractive
      GO_VERSION: '1.25.0'
      npm_config_store_dir: /cache/pnpm-store
      # The Go half wants the NDK; the Gradle half wants a platform.
      ANDROID_HOME: /cache/android-sdk
      ANDROID_SDK_ROOT: /cache/android-sdk
      GRADLE_USER_HOME: /cache/gradle
      # Pinned, not "whatever sdkmanager installs": newer NDKs have
      # broken the Wails Android build before, and r26d is what plan
      # 015 phase 0 was verified against.
      NDK_VERSION: 26.3.11579264
      # The registry package name.  Obtainium watches
      # <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
      PACKAGE_NAME: yellowjacket-android

    steps:
      # libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
      # nothing in this job builds a desktop app: `wails3` is the task
      # runner the whole Android build goes through, and the CLI links
      # the GTK/WebKit bindings, so `go tool wails3` cannot compile
      # without them.  libasound2-dev is oto's `pkg-config -- alsa`
      # probe, for the same reason (the *Android* build uses oboe, not
      # ALSA — this is the host toolchain only).
      - name: System packages
        run: |
          set -eu
          apt-get update -qq
          apt-get install -y -qq --no-install-recommends \
            ca-certificates curl git jq unzip zip \
            build-essential pkg-config \
            libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
            openjdk-21-jdk-headless

      # By hand rather than actions/checkout: that is a JS action and
      # needs node inside the container before any step has installed
      # it.  Same approach as the other four workflows.
      - name: Clone repo at this commit
        run: |
          set -eu
          git clone --quiet \
            "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
          git -C /src checkout --quiet --detach "$SHA"
          git config --global --add safe.directory /src
          git -C /src log --oneline -1

      # A tag push carries the version in its own name.  A manual run has
      # no tag, so it takes the input or falls back to the latest v* tag,
      # which is what a hand-triggered rebuild wants anyway.
      - name: Resolve the version
        id: version
        working-directory: /src
        run: |
          set -eu
          v="${{ inputs.version }}"
          if [ -z "$v" ]; then
            case "$REF_NAME" in
              v*) v="$REF_NAME" ;;
              *)  v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;;
            esac
          fi
          v="${v#v}"

          # v0.0.0 is semantic-release's version floor, not a shipment —
          # see the bootstrap step in release.yml.  It is skipped cleanly
          # rather than failing the guard below, because a 45-minute red
          # run against a tag that was never meant to ship is noise, and
          # this is the most expensive of the four workflows a tag fires.
          if [ "$v" = "0.0.0" ]; then
            echo "v0.0.0 is the version floor, not a release; nothing to build"
            echo "skip=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # Nor is a prerelease, and this trigger is `v*`, which matches
          # `v0.4.0-beta.1`.  Two reasons it is worst here.  The APK goes
          # to the *generic* registry, which is readable without
          # credentials so Obtainium can poll a plain URL — a beta would
          # be offered to every device on it.  And the versionCode maths
          # below splits on dots and would read "1" out of "0-beta",
          # producing a code that is wrong rather than a build that
          # fails: Android orders releases by that integer and refuses
          # anything not greater than what is installed.
          case "$v" in
            *-*)
              echo "v$v is a prerelease; not publishing an APK for it"
              echo "skip=true" >> "$GITHUB_OUTPUT"
              exit 0
              ;;
          esac
          echo "skip=false" >> "$GITHUB_OUTPUT"

          # Android orders releases by an integer and refuses anything
          # not greater than what is installed.  1.3.1 -> 10301, which
          # increases as long as minor and patch stay below 100.
          IFS=. read -r maj min pat <<EOF
          $v
          EOF
          code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
          if [ "$code" -le 0 ]; then
            echo "refusing to build version '$v' (versionCode $code)" >&2
            exit 1
          fi

          echo "version=$v"     >> "$GITHUB_OUTPUT"
          echo "code=$code"     >> "$GITHUB_OUTPUT"
          echo "tag=v$v"        >> "$GITHUB_OUTPUT"
          echo "building $v (versionCode $code)"

          # Releases restarted at 0.0.1 when they became automatic (plan
          # 017), so versionCode restarted at 1 — *below* the 10300 an
          # installed 1.3.0 build carries.  Android refuses a downgrade
          # outright, and the only remedy is an uninstall, which takes the
          # user's library with it.  Said here because this is the file
          # that computes the number.
          if [ "$code" -lt 10600 ]; then
            echo
            echo "note: versionCode $code is below the 10600 that v1.6.0 shipped."
            echo "      An existing install must be removed before this one will"
            echo "      install, and that removal takes its library with it."
          fi

      - name: Go toolchain
        if: steps.version.outputs.skip == 'false'
        run: |
          set -eu
          if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
            mkdir -p /cache/tool && rm -rf /cache/tool/go
            curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
          fi
          echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
          /cache/tool/go/bin/go version

      - name: Node toolchain
        if: steps.version.outputs.skip == 'false'
        run: |
          set -eu
          curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
          apt-get install -y -qq --no-install-recommends nodejs
          corepack enable
          node --version

      # Idempotent by directory check.  sdkmanager is itself idempotent
      # but still spends minutes verifying, so the guards are what make
      # this cheap on every run after the first.
      - name: Android SDK and NDK (cached)
        if: steps.version.outputs.skip == 'false'
        run: |
          set -eu
          mkdir -p "$ANDROID_HOME/cmdline-tools"

          if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
            echo "command line tools: installing"
            cd /tmp
            curl -fsSL -o tools.zip \
              https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
            unzip -q tools.zip
            rm -rf "$ANDROID_HOME/cmdline-tools/latest"
            mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
          else
            echo "command line tools: cached"
          fi

          export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
          yes | sdkmanager --licenses >/dev/null 2>&1 || true

          install_if_missing() {
            if [ -d "$ANDROID_HOME/$2" ]; then
              echo "$1: cached"
            else
              echo "$1: installing"
              yes | sdkmanager --install "$1" >/dev/null
            fi
          }
          # android-35 matches compileSdk/targetSdk in
          # build/android/app/build.gradle.  No system image and no
          # emulator: this job builds, it does not run.
          install_if_missing "platform-tools"       "platform-tools"
          install_if_missing "platforms;android-35" "platforms/android-35"
          install_if_missing "build-tools;34.0.0"   "build-tools/34.0.0"
          install_if_missing "ndk;${NDK_VERSION}"   "ndk/${NDK_VERSION}"

          echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
          du -sh "$ANDROID_HOME" || true

      # **Signing is not optional past the first install.**  Android
      # refuses to update an app whose signing key changed and the only
      # remedy is an uninstall, which takes the user's library with it.
      # build.gradle falls back to the *debug* keystore when these are
      # absent, and that key differs between every machine and every
      # runner — so publishing an unsigned build is a decision to
      # reinstall by hand for ever.  Fail instead.
      # **Signing is not optional past the first install.**  Android
      # refuses to update an app whose signing key changed and the only
      # remedy is an uninstall, which takes the user's library with it.
      # build.gradle falls back to the *debug* keystore when these are
      # absent, and that key differs between every machine and every
      # runner — so publishing an unsigned build is a decision to
      # reinstall by hand for ever.  Fail instead.
      #
      # Decode, check and build are one step on purpose.  Splitting them
      # would mean either handing the password to a later step through
      # `$GITHUB_ENV` — where the `env:` dump is only masked for values
      # that are *verbatim* a secret, so a trimmed one could print in
      # clear — or repeating the trimming logic in both.
      - name: Build the signed APK
        if: steps.version.outputs.skip == 'false'
        working-directory: /src
        env:
          KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
          KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          YJ_VERSION: ${{ steps.version.outputs.version }}
          YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
        run: |
          set -eu

          if [ -z "${KEYSTORE_B64:-}" ]; then
            echo "ANDROID_KEYSTORE_B64 is not set."
            echo
            echo "Building without it signs with the debug key, and every future"
            echo "update then fails with a signature mismatch.  See"
            echo "docs/android-release.md for the keytool command and the secrets."
            exit 1
          fi
          if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
            echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
            exit 1
          fi

          # The path is decided here rather than composed in an `env:`
          # block: `${{ env.HOME }}` evaluates to an empty string in
          # Gitea's expression context, which turns "$HOME/x.jks" into
          # "/x.jks" — reported by Gradle as a missing file, a minute in.
          keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
          printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
          chmod 600 "$keystore"

          # **A secret pasted into a web form very often carries a
          # trailing newline**, and a password is compared byte for byte.
          # Trim CR and LF from all three, and say so when it mattered —
          # "the keystore did not open" with a correct password is an
          # unpleasant thing to debug blind.
          pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
          if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
            echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
          fi
          alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
          keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')

          # Describe the artifact before trying to open it.  A truncated
          # or mis-pasted base64 yields a file that is the wrong size or
          # has no keystore header at all, and that is a different
          # problem from a wrong password.
          size=$(stat -c %s "$keystore")
          magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
          echo "keystore: $size bytes, first four bytes: $magic"

          # The fingerprint of the decoded file, so "is the secret the
          # keystore I have locally?" is answerable without guessing.
          # A hash of a *public* certificate store gives nothing away,
          # and the alternative is comparing byte counts by eye.
          #
          #     sha256sum ~/path/to/yellowjacket-release.jks
          #
          # A password that is right for one keystore and wrong for
          # another is indistinguishable from a wrong password, and this
          # is the line that distinguishes them.
          echo "  sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
          case "$magic" in
            "30 82"*)       echo "  header: PKCS12 (keytool's default since JDK 9)" ;;
            "fe ed fe ed")  echo "  header: legacy JKS" ;;
            *)              echo "  WARNING: not a keystore header.  Is the secret the base64 of the .jks?" ;;
          esac

          # Open it here rather than letting Gradle discover the problem
          # at :app:validateSigningRelease, a minute of build time in and
          # reported as a missing file rather than a bad password.
          if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
            echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
            echo "  password length after trimming: ${#pass}" >&2
            sed 's/^/  keytool: /' /tmp/ks.err | head -5 >&2
            echo >&2

            # A password pasted *with its shell quotes* is the one
            # remaining cause that looks identical to a wrong password:
            # the secret is two characters longer than the password and
            # nothing in the error says so.  Naming it is safe --
            # stripping the quotes and carrying on would not be, since a
            # password may legitimately contain them.
            unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
            if [ "$unquoted" != "$pass" ] &&
               keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
              echo "  ** it opens with the surrounding quotes removed. **" >&2
              echo "  Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
              echo >&2
            fi
            echo "Check it locally with the same two values:" >&2
            echo "  printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
            echo "  keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
            exit 1
          fi
          echo "keystore opens with the supplied password"

          # And check the alias now, for the same reason.  It defaults to
          # `yellowjacket`, so a keystore created with any other alias
          # would otherwise fail deep inside Gradle.
          if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
            echo "alias '$alias_want' is not in this keystore.  It holds:" >&2
            sed -n 's/^\([^,]*\),.*Entry.*$/    \1/p' /tmp/ks.txt >&2
            echo "Set ANDROID_KEY_ALIAS to one of those." >&2
            exit 1
          fi
          echo "alias '$alias_want': present"

          ANDROID_KEYSTORE_FILE="$keystore"
          ANDROID_KEYSTORE_PASSWORD="$pass"
          ANDROID_KEY_ALIAS="$alias_want"
          ANDROID_KEY_PASSWORD="$keypass"
          export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
          export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD

          # ANDROID_SDK is passed explicitly: the Makefile defaults it to
          # ~/Android/Sdk, which is the developer-machine layout and not
          # this container's.
          make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"

      - name: Verify the APK
        id: apk
        if: steps.version.outputs.skip == 'false'
        working-directory: /src
        run: |
          set -eu
          apk=bin/yellowjacket.apk
          [ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
          bt="$ANDROID_HOME/build-tools/34.0.0"

          ls -la "$apk"
          "$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'

          # arm64 and *only* arm64.  x86_64 Android cannot run this app
          # (modernc's raw lstat against Android's seccomp filter, which
          # is every x86_64 device and not merely the emulator), so an
          # x86_64 slice would be ~31 MB that runs nowhere -- and its
          # reappearance would mean someone had put the ABI back in
          # app/build.gradle without knowing that.
          "$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
            echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }

          # The identity the pipeline exists to keep stable.
          "$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
            echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }

          echo
          "$bt/apksigner" verify --print-certs "$apk" |
            grep -E 'Signer #1 certificate (DN|SHA-256 digest)'

          # A build signed with the debug key installs once and can never
          # be updated.  It must never reach the registry.
          if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
            echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
            exit 1
          fi
          echo
          echo "Record that SHA-256.  If it ever changes, updates will fail."

      # Two copies: a versioned one for history and a fixed `latest` URL
      # for Obtainium to watch.  Gitea refuses to overwrite an existing
      # file, so `latest` is deleted first.  Credentials are the same
      # OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
      - name: Publish to the Gitea package registry
        if: steps.version.outputs.skip == 'false'
        working-directory: /src
        env:
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          set -eu
          base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
          apk=bin/yellowjacket.apk

          put() {
            code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
              --user "${OWNER}:${PACKAGE_TOKEN}" \
              --upload-file "$apk" "$1")
            echo "  -> $1 : $code"
            # 409 is "already there", which is the correct outcome for a
            # re-run of the same tag and not a failure.
            if [ "$code" != "201" ] && [ "$code" != "409" ]; then
              cat /tmp/put.out >&2
              return 1
            fi
          }

          echo "publishing the versioned copy"
          put "$base/$VERSION/yellowjacket-$VERSION.apk"

          echo "clearing the previous latest"
          curl -s -o /dev/null -w '  -> delete latest: %{http_code}\n' \
            --user "${OWNER}:${PACKAGE_TOKEN}" \
            -X DELETE "$base/latest/yellowjacket.apk" || true

          echo "publishing latest"
          put "$base/latest/yellowjacket.apk"

          echo
          echo "Obtainium URL:"
          echo "  $base/latest/yellowjacket.apk"

      # The generic registry is what Obtainium polls; the release page is
      # what a person looks at.  Same file, already built and already
      # verified by the step above — so this cannot publish something the
      # signature check would have refused.
      - name: Attach the APK to the release
        if: steps.version.outputs.skip == 'false'
        working-directory: /src
        env:
          TAG: ${{ steps.version.outputs.tag }}
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          set -eu
          ./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \
            "yellowjacket-${VERSION}-android-arm64.apk"
