name: Hash Bags Android build # Triggers: # - push to dev/main (validate every commit) # - PRs targeting dev/main (gate merges) # - manual via workflow_dispatch on: # Manual-only for now. Trigger via Actions → "Hash Bags Android build" # → Run workflow when you want a build. workflow_dispatch runs as the # triggering user, so secrets are always available (unlike PR triggers). workflow_dispatch: concurrency: group: android-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: build: runs-on: ubuntu-latest container: # Same Cake-Labs prebuilt image used for Linux. Bundles Flutter 3.32.0, # Android NDK r28, Go 1.24.1, Android SDK, JDK 17, gradle, and the # native build deps (boost, openssl, libsodium, libzmq, unbound, icu). image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} APP_ANDROID_TYPE: cakewallet steps: - name: Fix Actions HOME (writeable for ~/.cache + gradle) run: echo "HOME=/root" >> $GITHUB_ENV - uses: actions/checkout@v4 with: fetch-depth: 1 - name: Configure git inside the container run: | git config --global --add safe.directory '*' git config --global user.email "ci@suchsoftware.com" git config --global user.name "Hash Bags CI" # ---- External prebuilt deps (same approach as Linux workflow) -------- - name: Fetch prebuilt torch_dart run: | set -x -e pushd scripts rm -rf torch_dart torch_dart.tar.gz wget -q https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz mkdir torch_dart tar -xzf torch_dart.tar.gz -C torch_dart rm torch_dart.tar.gz popd - name: Fetch prebuilt reown_flutter run: | set -x -e pushd scripts rm -rf reown_flutter reown_flutter.tar.gz wget -q https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz mkdir reown_flutter tar -xzf reown_flutter.tar.gz -C reown_flutter rm reown_flutter.tar.gz popd - name: Clone BitBox Flutter run: | set -x -e pushd scripts ./build_bitbox_flutter.sh popd # ---- Native crypto cores (monero_c prebuilt bundle) ------------------ # Same release-bundle.zip as Linux. Contains pre-cross-compiled .so # files for monero/wownero/zano across all 4 Android ABIs. We only ship # monero + wownero — zano libs in the bundle are ignored. - name: Fetch prebuilt monero_c .so bundle run: | set -x -e ./scripts/prepare_moneroc.sh MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags) echo "monero_c TAG: $MONERO_C_TAG" mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip unzip -q release-bundle.zip rm release-bundle.zip ls popd # ---- Stage native libs into android/app/src/main/jniLibs// ----- # Flutter Android packaging auto-includes everything under jniLibs. - name: Place Android .so files into jniLibs run: | set -x -e MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags) BUNDLE_DIR="scripts/monero_c/release/$MONERO_C_TAG" # monero_c target dir → Android ABI dir. i686 (32-bit x86) is # essentially dead on real Android devices; the monero_c bundle # doesn't ship it. Missing target = log + skip, not fatal. The # required modern ABIs (arm64-v8a, x86_64) MUST exist or we fail. declare -A ABI_MAP=( [aarch64-linux-android]=arm64-v8a [armv7a-linux-androideabi]=armeabi-v7a [i686-linux-android]=x86 [x86_64-linux-android]=x86_64 ) REQUIRED_ABIS=(arm64-v8a x86_64) declare -A FOUND_ABI for target in "${!ABI_MAP[@]}"; do abi="${ABI_MAP[$target]}" if [[ ! -d "$BUNDLE_DIR/$target" ]]; then echo "SKIP: $target not in bundle (Android ABI $abi will not be shipped)" continue fi mkdir -p "android/app/src/main/jniLibs/$abi" ok=true for coin in monero wownero; do src="$BUNDLE_DIR/$target/lib${coin}_wallet2_api_c.so" if [[ -f "$src" ]]; then cp -v "$src" "android/app/src/main/jniLibs/$abi/" else echo "SKIP: $src missing (skipping $abi)" ok=false break fi done if $ok; then FOUND_ABI[$abi]=1 else rm -rf "android/app/src/main/jniLibs/$abi" fi done # Fail loudly only if a required ABI is missing. for required in "${REQUIRED_ABIS[@]}"; do if [[ -z "${FOUND_ABI[$required]:-}" ]]; then echo "FATAL: required ABI $required is missing — monero_c bundle layout has changed" exit 1 fi done echo "=== jniLibs contents ===" find android/app/src/main/jniLibs/ -type f -name '*.so' | sort # mwebd (Litecoin MWEB) is NOT built here. LTC works without MWEB — # users just don't get the privacy MWEB feature. Add a build_mwebd # step if/when we want MWEB on Android. # ---- Configure: pubspec.yaml, AndroidManifest.xml, app_properties ---- - name: Run Android configure (cakewallet profile) run: | set -x -e pushd scripts/android source ./app_env.sh cakewallet ./app_config.sh popd # ---- Secrets --------------------------------------------------------- - name: Generate per-module secrets.g.dart files (all empty defaults) run: | # tool/generate_new_secrets.dart imports app deps (encrypt/convert/intl), # so the app's packages must be resolved first. The configure step runs # `flutter pub get` inside pubspec_gen.sh WITHOUT `set -e`, so any hiccup # there fails silently and surfaces here as "Couldn't resolve the package". # Resolve explicitly (and loudly) before running the tool. set -e flutter pub get dart run tool/generate_new_secrets.dart - name: Pin crypto secrets (never ship random salts) env: CRYPTO_SALT: ${{ secrets.CRYPTO_SALT }} CRYPTO_KEYCHAIN_SALT: ${{ secrets.CRYPTO_KEYCHAIN_SALT }} CRYPTO_KEY: ${{ secrets.CRYPTO_KEY }} CRYPTO_WALLET_SALT: ${{ secrets.CRYPTO_WALLET_SALT }} CRYPTO_SHORT_KEY: ${{ secrets.CRYPTO_SHORT_KEY }} CRYPTO_BACKUP_SALT: ${{ secrets.CRYPTO_BACKUP_SALT }} CRYPTO_BACKUP_KEYCHAIN_SALT: ${{ secrets.CRYPTO_BACKUP_KEYCHAIN_SALT }} CRYPTO_WALLET_GROUP_SALT: ${{ secrets.CRYPTO_WALLET_GROUP_SALT }} run: | set -e # generate_new_secrets.dart mints RANDOM salts each run. Shipping # those breaks decryption of everything the previous build stored # (2026-08-29 "Corrupted seeds" incident). Overwrite with the pinned # values and refuse to build if any is missing. for v in CRYPTO_SALT CRYPTO_KEYCHAIN_SALT CRYPTO_KEY CRYPTO_WALLET_SALT CRYPTO_SHORT_KEY CRYPTO_BACKUP_SALT CRYPTO_BACKUP_KEYCHAIN_SALT CRYPTO_WALLET_GROUP_SALT; do eval "val=\${$v:-}" [ -n "$val" ] || { echo "FATAL: $v is empty — refusing to ship random crypto secrets"; exit 1; } done sed -i.bak \ -e "s|const salt = '.*';|const salt = '${CRYPTO_SALT}';|" \ -e "s|const keychainSalt = '.*';|const keychainSalt = '${CRYPTO_KEYCHAIN_SALT}';|" \ -e "s|const key = '.*';|const key = '${CRYPTO_KEY}';|" \ -e "s|const walletSalt = '.*';|const walletSalt = '${CRYPTO_WALLET_SALT}';|" \ -e "s|const shortKey = '.*';|const shortKey = '${CRYPTO_SHORT_KEY}';|" \ -e "s|const backupSalt = '.*';|const backupSalt = '${CRYPTO_BACKUP_SALT}';|" \ -e "s|const backupKeychainSalt = '.*';|const backupKeychainSalt = '${CRYPTO_BACKUP_KEYCHAIN_SALT}';|" \ -e "s|const walletGroupSalt = '.*';|const walletGroupSalt = '${CRYPTO_WALLET_GROUP_SALT}';|" \ lib/.secrets.g.dart rm -f lib/.secrets.g.dart.bak for c in salt keychainSalt key walletSalt shortKey backupSalt backupKeychainSalt walletGroupSalt; do grep -qE "^const $c = '[0-9a-f]{8,}';" lib/.secrets.g.dart || { echo "FATAL: $c not pinned"; exit 1; } done echo "crypto secrets pinned (values not printed)" - name: Inject Trocador + WalletConnect + MoonPay secrets into lib/.secrets.g.dart env: TROCADOR_API_KEY: ${{ secrets.TROCADOR_API_KEY }} TROCADOR_MONERO_API_KEY: ${{ secrets.TROCADOR_MONERO_API_KEY }} TROCADOR_EXCHANGE_MARKUP: ${{ secrets.TROCADOR_EXCHANGE_MARKUP }} WALLETCONNECT_PROJECT_ID: ${{ secrets.WALLETCONNECT_PROJECT_ID }} # MoonPay: publishable key only. NEVER inject moonPaySecretKey (server-side). MOONPAY_API_KEY: ${{ secrets.MOONPAY_API_KEY }} EXCHANGE_HELPER_API_KEY: ${{ secrets.EXCHANGE_HELPER_API_KEY }} run: | sed -i \ -e "s|const trocadorApiKey = '';|const trocadorApiKey = '${TROCADOR_API_KEY}';|" \ -e "s|const trocadorMoneroApiKey = '';|const trocadorMoneroApiKey = '${TROCADOR_MONERO_API_KEY}';|" \ -e "s|const trocadorExchangeMarkup = '';|const trocadorExchangeMarkup = '${TROCADOR_EXCHANGE_MARKUP:-1}';|" \ -e "s|const walletConnectProjectId = '';|const walletConnectProjectId = '${WALLETCONNECT_PROJECT_ID}';|" \ -e "s|const moonPayApiKey = '';|const moonPayApiKey = '${MOONPAY_API_KEY}';|" \ -e "s|const exchangeHelperApiKey = '';|const exchangeHelperApiKey = '${EXCHANGE_HELPER_API_KEY}';|" \ lib/.secrets.g.dart awk -F"'" '/^const (trocador[A-Za-z]*|walletConnectProjectId|moonPayApiKey|exchangeHelperApiKey) = / { split($1, a, " "); v = (length($2) ? "set (" length($2) " chars)" : "EMPTY"); print a[2] ": " v }' lib/.secrets.g.dart # lengths only; values never reach the log # ---- Signing: decode upload keystore + write key.properties --------- - name: Decode Android upload keystore + write key.properties env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | set -e # Debug visibility — prints lengths only, never values. If a secret # length is 0, Gitea Actions is not passing it to this run (most # commonly because the run was triggered by a from-fork PR). echo "ANDROID_KEYSTORE_BASE64 length: ${#ANDROID_KEYSTORE_BASE64}" echo "ANDROID_KEYSTORE_PASSWORD length: ${#ANDROID_KEYSTORE_PASSWORD}" echo "ANDROID_KEY_ALIAS length: ${#ANDROID_KEY_ALIAS}" echo "ANDROID_KEY_PASSWORD length: ${#ANDROID_KEY_PASSWORD}" if [[ -z "$ANDROID_KEYSTORE_BASE64" ]]; then echo "FATAL: ANDROID_KEYSTORE_BASE64 not reaching the runner." echo "Check: (1) secret is in repo Settings → Actions → Secrets;" echo " (2) workflow was triggered by 'push' or 'workflow_dispatch'" echo " (PR triggers from a fork strip secrets);" echo " (3) Gitea's runner is configured to pass secrets." exit 1 fi # Write decoded keystore next to build.gradle (storeFile path # in key.properties is resolved relative to android/app/). echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks ls -la android/app/upload-keystore.jks # key.properties consumed by build.gradle's signingConfigs.release. cat > android/key.properties </dev/null | sort -V | tail -1 || which apksigner) if [[ -n "$APKSIGNER" ]]; then "$APKSIGNER" verify --print-certs build/app/outputs/flutter-apk/app-release.apk || true else echo "apksigner not on PATH; skipping sig print" fi # Flutter names every build app-release.{apk,aab}, so two releases unzip # to identical filenames and an old one in ~/Downloads looks exactly like # the new one. That is how 1.0.3's AAB got dropped into the 1.0.4 Play # release. Name the files after the version they carry. - name: Name the outputs after their version id: names run: | set -euo pipefail FULL=$(awk '/^version:/ {print $2; exit}' pubspec_description.yaml) VER="${FULL%%+*}"; BUILD="${FULL#*+}" if [ -z "$VER" ] || [ "$BUILD" = "$FULL" ]; then echo "pubspec_description.yaml version '$FULL' is not +; refusing to guess a name" >&2 exit 1 fi BASE="hash-bags-${VER}-build${BUILD}" mkdir -p dist cp build/app/outputs/flutter-apk/app-release.apk "dist/${BASE}.apk" cp build/app/outputs/bundle/release/app-release.aab "dist/${BASE}.aab" echo "base=$BASE" >> "$GITHUB_OUTPUT" ls -la dist # actions/upload-artifact@v4 doesn't work on Gitea; pin to v3. - name: Upload APK artifact (sideload-able) uses: actions/upload-artifact@v3 with: name: ${{ steps.names.outputs.base }}-apk-${{ github.sha }} path: dist/${{ steps.names.outputs.base }}.apk retention-days: 14 - name: Upload AAB artifact (for Play Console) uses: actions/upload-artifact@v3 with: name: ${{ steps.names.outputs.base }}-aab-${{ github.sha }} path: dist/${{ steps.names.outputs.base }}.aab retention-days: 30