name: Mobile Deploy
on:
  push:
    branches: [main]
    paths:
      - '.factory/releases/mobile.json'
  workflow_dispatch:
  workflow_call:
    inputs:
      candidate-build:
        type: boolean
        default: false

permissions:
  contents: write
  actions: read
  models: read
  deployments: write

concurrency:
  group: mobile-deploy-${{ github.ref }}
  cancel-in-progress: ${{ inputs.candidate-build || false }}

jobs:
  release:
    name: Admit mobile release
    if: ${{ github.ref_name == github.event.repository.default_branch || inputs.candidate-build }}
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    outputs:
      ios: ${{ steps.intent.outputs.ios }}
      android: ${{ steps.intent.outputs.android }}
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
        with:
          fetch-depth: 0
      - name: Verify release intent and prior upload receipts
        id: intent
        env:
          GITHUB_TOKEN: ${{ github.token }}
          DEPLOY_PLATFORMS: ${{ vars.DEPLOY_PLATFORMS || 'both' }}
          MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
          MOBILE_CANDIDATE_BUILD: ${{ inputs.candidate-build && 'true' || 'false' }}
        run: node .github/actions/swift-app/scripts/mobile_release.cjs admit "$DEPLOY_PLATFORMS"
  # Repository variable DEPLOY_PLATFORMS selects which stores this repo ships to:
  #   unset / 'both' — iOS and Android (the default)
  #   'android'      — Android only; skip the iOS job entirely
  #   'ios'          — iOS only; skip the Android job entirely
  # Use it when one platform is intentionally out of scope for a repo, so an
  # unrelated failure on the other platform cannot block the release you want.
  ios:
    needs: release
    name: iOS TestFlight
    env:
      MOBILE_CANDIDATE_BUILD: ${{ inputs.candidate-build && 'true' || 'false' }}
      MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
      MOBILE_RELEASE_DEPLOYMENT_ID: ${{ needs.release.outputs.ios }}
      GITHUB_TOKEN: ${{ github.token }}
    if: ${{ needs.release.outputs.ios != '' }}
    runs-on: macos-15
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
        with:
          fetch-depth: 0
          persist-credentials: true
      - name: Materialize encrypted iOS credentials
        id: ios_credentials
        shell: bash
        env:
          CI_ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
          CI_ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          CI_ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          CI_DISTRIBUTION_P12: ${{ secrets.IOS_DISTRIBUTION_P12_BASE64 }}
          CI_DISTRIBUTION_META: ${{ secrets.IOS_DISTRIBUTION_CERT_META_BASE64 }}
          CI_DISTRIBUTION_REGISTRY: ${{ secrets.IOS_DISTRIBUTION_CERT_REGISTRY_BASE64 }}
        run: |
          umask 077
          mkdir -p creds
          if [ -n "${CI_ASC_KEY_P8:-}" ]; then
            : "${CI_ASC_KEY_ID:?ASC_KEY_ID secret is required with ASC_KEY_P8}"
            : "${CI_ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required with ASC_KEY_P8}"
            printf '%s' "$CI_ASC_KEY_P8" \
              > "creds/AuthKey_${CI_ASC_KEY_ID}_Issuer_${CI_ASC_ISSUER_ID}.p8"
          fi
          if [ -z "${CI_DISTRIBUTION_P12:-}${CI_DISTRIBUTION_META:-}${CI_DISTRIBUTION_REGISTRY:-}" ]; then
            echo "source=checkout" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          : "${CI_DISTRIBUTION_P12:?IOS_DISTRIBUTION_P12_BASE64 secret is required}"
          : "${CI_DISTRIBUTION_META:?IOS_DISTRIBUTION_CERT_META_BASE64 secret is required}"
          : "${CI_DISTRIBUTION_REGISTRY:?IOS_DISTRIBUTION_CERT_REGISTRY_BASE64 secret is required}"
          printf '%s' "$CI_DISTRIBUTION_P12" | base64 -D > creds/cert.p12
          printf '%s' "$CI_DISTRIBUTION_META" | base64 -D > creds/cert.meta.json
          printf '%s' "$CI_DISTRIBUTION_REGISTRY" | base64 -D > creds/cert.registry.json
          echo "source=encrypted" >> "$GITHUB_OUTPUT"
      - name: Reuse matching candidate binary
        id: reuse
        if: ${{ !inputs.candidate-build }}
        shell: bash
        run: node .github/actions/swift-app/scripts/mobile_artifact_inventory.cjs restore ios
      - name: Detect Flutter
        id: flutter
        shell: bash
        run: |
          if [ -f pubspec.yaml ]; then
            echo "enabled=true" >> "$GITHUB_OUTPUT"
          else
            echo "enabled=false" >> "$GITHUB_OUTPUT"
          fi
      - name: Set up Flutter
        if: ${{ steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        uses: ./.github/actions/android-app/flutter-setup
        with:
          channel: stable
          cache: true
          # A release runs on the default branch, which cannot restore the SDK
          # cache a task branch built, so it downloads the whole archive through
          # the account exit. This job has an hour; the helper's own 20-minute
          # default belongs to a validation job and turned that download into a
          # guaranteed failure (obd-scanner, 2026-09-09: 428 MB retained, curl 28).
          archive-budget-seconds: '2100'
      - name: Retain CocoaPods cache process scope
        id: pods-cache-scope
        if: ${{ steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        continue-on-error: true
        shell: bash
        run: node .github/actions/android-app/scripts/cache_scope.cjs --snapshot
      - name: Cache CocoaPods
        if: ${{ steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5, same pinned cache process as Flutter
        continue-on-error: true
        env:
          NODE_OPTIONS: --require "${{ steps.pods-cache-scope.outputs.preloader }}"
          SEGMENT_DOWNLOAD_TIMEOUT_MINS: "1"
        with:
          path: |
            ios/Pods
            ~/Library/Caches/CocoaPods
          key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock', 'pubspec.lock') }}
          restore-keys: pods-${{ runner.os }}-
      # Same as the android job: an app that deliberately carries a private git
      # dependency has it cloned by `flutter pub get` below. The runner has no
      # SSH key, so an https url only resolves once this token rewrite is in
      # place. No secret => no-op (panel-built apps depend on pub.dev only; a
      # private dep must otherwise carry its own credentials).
      - name: Authenticate private git dependencies
        if: ${{ steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        shell: bash
        env:
          GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
        run: |
          if [ -z "${GIT_PRIVATE_TOKEN:-}" ]; then
            echo "No GIT_PRIVATE_TOKEN secret; private git dependencies must carry their own credentials."
            exit 0
          fi
          echo "::add-mask::$GIT_PRIVATE_TOKEN"
          git config --global \
            url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
          echo "Configured authenticated github.com remotes for pub."
      - name: Prepare Flutter iOS project
        if: ${{ steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        shell: >-
          python3 .github/actions/swift-app/scripts/native_bundle.py
          --folder ios
          -- bash --noprofile --norc -e -o pipefail {0}
        env:
          ONBOARDING_PUBLIC_ORIGIN: ${{ vars.ONBOARDING_PUBLIC_ORIGIN }}
        run: |
          flutter pub get
          # Even --config-only can query Xcode and run pod install. Pub generates
          # the Flutter configuration needed by the Podfile without resolving Pods.
          python3 .github/actions/swift-app/scripts/native_pods.py "$GITHUB_WORKSPACE" \
            --container ios/Runner.xcworkspace
          python3 .github/actions/swift-app/scripts/native_dependency_guard.py "$GITHUB_WORKSPACE" \
            --container ios/Runner.xcworkspace
          # Flutter removed the synthetic `package:flutter_gen`, so apps that use
          # AppLocalizations now generate real sources. Nothing else generates them,
          # and analyze/compile fail on the missing import if we skip this.
          if [ -f l10n.yaml ]; then flutter gen-l10n; fi
          # Apps wired up by `flutterfire configure` carry an Xcode run-script phase that
          # shells out to the flutterfire CLI to upload Crashlytics dSYMs. That CLI is not
          # on a clean runner, so the phase exits non-zero and fails the whole archive.
          if grep -q "firebase_crashlytics:" pubspec.yaml 2>/dev/null; then
            dart pub global activate flutterfire_cli >/dev/null 2>&1 || true
            echo "$HOME/.pub-cache/bin" >> "$GITHUB_PATH"
          fi
          # Dart obfuscation is mandatory for Flutter apps, and this is where
          # the iOS build gets it even though xcodebuild does the compiling:
          # --config-only writes DART_OBFUSCATION=true and SPLIT_DEBUG_INFO=<dir>
          # into ios/Flutter/Generated.xcconfig, and the Flutter build phase
          # inside the archive below compiles against those settings. The
          # symbol files land under RUNNER_TEMP (never in the checkout) and are
          # retained after the archive — without them an obfuscated crash log
          # cannot be read.
          # The hosted-flow origin the app compiles in: the panel's CURRENT
          # value, from the repository variable it keeps in step with the
          # origin it serves, so a hostname re-minted after a build can never
          # stay compiled in. Unset (an app with no hosted flows, or one that
          # predates the variable) leaves the app's own default alone.
          define_args=()
          if [ -n "${ONBOARDING_PUBLIC_ORIGIN:-}" ]; then
            define_args+=(--dart-define "ONBOARDING_PUBLIC_ORIGIN=${ONBOARDING_PUBLIC_ORIGIN}")
          fi
          flutter build ios --release --no-codesign --config-only \
            --obfuscate --split-debug-info="$RUNNER_TEMP/dart-symbols/ios" \
            "${define_args[@]}"
      - uses: ./.github/actions/swift-app
        id: ios
        with:
          project: ${{ steps.flutter.outputs.enabled == 'true' && 'ios/Runner.xcodeproj' || '' }}
          workspace: ${{ steps.flutter.outputs.enabled == 'true' && 'ios/Runner.xcworkspace' || '' }}
          scheme: ${{ steps.flutter.outputs.enabled == 'true' && 'Runner' || '' }}
          candidate-binary: ${{ steps.reuse.outputs.directory }}
          upload: ${{ inputs.candidate-build && 'false' || 'true' }}
          auto-update: 'false'  # the frozen source includes its reviewed action version
          run-tests: 'false'  # tests belong to the CI gate, not the deploy path
          # A feature-branch TestFlight build must not create or mutate a live
          # App Store version. That also lets it upload while the current version
          # is locked in review. Default-branch deploys retain metadata automation.
          manage-app-store-version: >-
            ${{ !inputs.candidate-build && github.ref_name == github.event.repository.default_branch && 'true' || 'false' }}
          # Repository variable FIREBASE_APP_ID: the app's Firebase App ID(s),
          # comma-separated when it ships both platforms. With an iOS id the
          # action uploads the archive's dSYMs to Crashlytics before TestFlight.
          firebase-app-id: ${{ vars.FIREBASE_APP_ID }}
      # `flutter symbolize` with app.ios-arm64.symbols is the only way to read a
      # stack trace from the build that just went to TestFlight (App Store
      # Connect receives native dSYMs only), and this runner holds the only
      # copy. The archive writes the symbols well before the upload, and the
      # action keeps working after the upload (App Store metadata, "What's
      # New"), so these two steps run on `!cancelled()`: a failure there must
      # not discard the symbols of a build that is already live. An action that
      # succeeded without writing symbols is a broken obfuscation setup and
      # fails here rather than shipping crashes nobody can read; an action that
      # failed earlier simply has nothing to retain and adds no second error.
      - name: Locate Dart symbol files
        id: ios_symbols
        if: ${{ !cancelled() && steps.flutter.outputs.enabled == 'true' }}
        shell: bash
        env:
          ACTION_OUTCOME: ${{ steps.ios.outcome }}
        run: |
          dir="$RUNNER_TEMP/dart-symbols/ios"
          if ls "$dir"/*.symbols >/dev/null 2>&1; then
            ls -la "$dir"
            echo "present=true" >> "$GITHUB_OUTPUT"
          elif [ "$ACTION_OUTCOME" = "success" ]; then
            echo "::error::the archive wrote no Dart symbol files to $dir;" \
                 "a crash from this obfuscated build could never be read."
            exit 1
          else
            echo "No Dart symbol files (the build did not get as far as the archive)."
            echo "present=false" >> "$GITHUB_OUTPUT"
          fi
      # BUILD_NUMBER comes from the action through GITHUB_ENV, so the artifact
      # is named after the TestFlight build it belongs to. No retention-days:
      # the repository's artifact retention setting governs (90 days unless
      # raised), and a crash reporter needs its own copy — see the README.
      - name: Retain Dart symbol files
        if: ${{ !cancelled() && steps.ios_symbols.outputs.present == 'true' }}
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
        env:
          NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
        with:
          name: ios-symbols-${{ github.event.repository.name }}-${{ env.BUILD_NUMBER || github.run_number }}
          path: ${{ runner.temp }}/dart-symbols/ios
          if-no-files-found: error

      - name: Package candidate shipping binary
        id: candidate_binary
        if: ${{ inputs.candidate-build }}
        shell: bash
        run: node .github/actions/swift-app/scripts/mobile_artifact_inventory.cjs pack ios
      - name: Retain candidate shipping binary
        if: ${{ steps.candidate_binary.outcome == 'success' }}
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
        env:
          NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
        with:
          name: ${{ steps.candidate_binary.outputs.name }}
          path: ${{ steps.candidate_binary.outputs.directory }}
          if-no-files-found: error

  android:
    needs: release
    name: Android Google Play
    env:
      MOBILE_CANDIDATE_BUILD: ${{ inputs.candidate-build && 'true' || 'false' }}
      MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
      MOBILE_RELEASE_DEPLOYMENT_ID: ${{ needs.release.outputs.android }}
      GITHUB_TOKEN: ${{ github.token }}
      PLAY_EDIT_BINDING: ${{ vars.APP_ROBOT_PLAY_EDIT_BINDING }}
    if: ${{ needs.release.outputs.android != '' }}
    runs-on: ubuntu-24.04
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
      - name: Materialize encrypted Android credentials
        shell: bash
        env:
          CI_ANDROID_KEYSTORE: ${{ secrets.ANDROID_UPLOAD_KEY_BASE64 }}
          CI_ANDROID_PROPERTIES: ${{ secrets.ANDROID_SIGNING_PROPERTIES }}
          CI_PLAY_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
        run: |
          [ -n "${CI_ANDROID_KEYSTORE:-}${CI_ANDROID_PROPERTIES:-}${CI_PLAY_ACCOUNT:-}" ] || exit 0
          : "${CI_ANDROID_KEYSTORE:?ANDROID_UPLOAD_KEY_BASE64 secret is required}"
          : "${CI_ANDROID_PROPERTIES:?ANDROID_SIGNING_PROPERTIES secret is required}"
          : "${CI_PLAY_ACCOUNT:?GOOGLE_PLAY_SERVICE_ACCOUNT_JSON secret is required}"
          umask 077
          mkdir -p creds
          printf '%s' "$CI_ANDROID_KEYSTORE" | base64 --decode > creds/android-upload-key.jks
          printf '%s' "$CI_ANDROID_PROPERTIES" > creds/android-signing.properties
          printf '%s' "$CI_PLAY_ACCOUNT" > creds/play-service-account.json
      - name: Reuse matching candidate binary
        id: reuse
        if: ${{ !inputs.candidate-build }}
        shell: bash
        run: node .github/actions/swift-app/scripts/mobile_artifact_inventory.cjs restore android
      # Two Android delivery modes:
      #   * bitrise — the upload key + Play service account live in Bitrise,
      #     not the repo, so the GitHub runner cannot sign. Drop a
      #     creds/bitrise.json ({"enabled":true,"app_slug":"...",...}) and this
      #     job triggers the Bitrise workflow (which holds the secrets) and
      #     waits for it. See android-action/scripts/bitrise_deploy.py.
      #   * local  — default; build + sign + upload on the GitHub runner using
      #     creds/android-upload-key.jks + creds/android-signing.properties.
      - name: Select Android deploy mode
        id: mode
        shell: bash
        run: |
          bitrise_enabled=$(python3 -c \
            "import json;print(json.load(open('creds/bitrise.json')).get('enabled',False))" 2>/dev/null || true)
          if [ -f creds/bitrise.json ] && [ "$bitrise_enabled" = "True" ]; then
            echo "mode=bitrise" >> "$GITHUB_OUTPUT"
          else
            echo "mode=local" >> "$GITHUB_OUTPUT"
          fi

      # --- Bitrise mode ---------------------------------------------------
      - name: Record Bitrise upload intent
        if: ${{ steps.mode.outputs.mode == 'bitrise' && !inputs.candidate-build }}
        run: node .github/actions/swift-app/scripts/mobile_release.cjs begin-upload android
      - name: Deploy Android via Bitrise
        id: bitrise
        if: ${{ steps.mode.outputs.mode == 'bitrise' && !inputs.candidate-build }}
        shell: bash
        env:
          BITRISE_API_TOKEN: ${{ secrets.BITRISE_API_TOKEN }}
        run: python3 .github/actions/android-app/scripts/bitrise_deploy.py
      - name: Record confirmed Bitrise delivery
        if: ${{ steps.bitrise.outcome == 'success' }}
        run: node .github/actions/swift-app/scripts/mobile_release.cjs finish-upload android

      # --- Local mode -----------------------------------------------------
      # Native Gradle apps must not pay for a Flutter install, and their AGP
      # version generally wants a newer JDK than a Flutter app's does.
      - name: Detect Flutter
        id: flutter
        if: ${{ steps.mode.outputs.mode == 'local' }}
        shell: bash
        run: |
          if [ -f pubspec.yaml ]; then
            echo "enabled=true" >> "$GITHUB_OUTPUT"
          else
            echo "enabled=false" >> "$GITHUB_OUTPUT"
          fi
      # A release Flutter build (Gradle caches + intermediates + a 100-200 MB AAB)
      # does not fit in the ~14 GB an ubuntu runner leaves free on /, and the
      # failure surfaces far from its cause: "No space left on device" out of
      # aapt2, zip, or Gradle's own cache writer. Reclaiming the preinstalled
      # toolchains this build will never use buys ~25 GB and costs a few seconds.
      - name: Free runner disk space
        if: ${{ steps.mode.outputs.mode == 'local' && runner.os == 'Linux' && steps.reuse.outputs.restored != 'true' }}
        shell: bash
        run: |
          echo "Before:"; df -h / | tail -1
          # NOT /usr/local/lib/node_modules: npm and npx live there, and the
          # Android action's Crashlytics symbol upload shells out to npx.
          # Keep installed Android SDK/NDKs: removing them makes AGP download them again.
          sudo rm -rf /usr/share/dotnet \
                      /opt/ghc /usr/local/.ghcup /usr/local/share/powershell \
                      /usr/share/swift /usr/local/share/chromium 2>/dev/null || true
          sudo docker image prune --all --force >/dev/null 2>&1 || true
          echo "After:";  df -h / | tail -1
      # The JDK used to be hardcoded here — 17 for Flutter, 21 for everything
      # else — which assumes every native Gradle app is new enough for 21. An
      # app on an old Kotlin is not: kapt reaches into javac internals that JDK
      # 21 moved, and the build dies with "Internal compiler error" out of
      # kaptGenerateStubsReleaseKotlin, naming neither Kotlin nor the JDK.
      #
      # Repository variable JAVA_VERSION pins the JDK for this repo and always
      # wins — set it (e.g. '17') when an app needs a specific JDK and nothing
      # should second-guess it. Left unset, select_jdk.py keeps today's choice
      # and only drops a native Gradle app to 17 when it can positively read a
      # Kotlin Gradle plugin older than 1.9.20, which is the release that added
      # JDK 21 support. See android-action/scripts/select_jdk.py.
      - name: Choose the JDK for this build
        id: jdk
        if: ${{ steps.mode.outputs.mode == 'local' && steps.reuse.outputs.restored != 'true' }}
        shell: bash
        env:
          JAVA_VERSION: ${{ vars.JAVA_VERSION }}
          FLUTTER_ENABLED: ${{ steps.flutter.outputs.enabled }}
        run: |
          # Never let a chooser bug take down every Android job: fall back to the
          # JDK this workflow used before the chooser existed.
          if ! python3 .github/actions/android-app/scripts/select_jdk.py; then
            echo "::warning::select_jdk.py failed; falling back"
            if [ "${FLUTTER_ENABLED}" = "true" ]; then fallback=17; else fallback=21; fi
            echo "version=${fallback}" >> "$GITHUB_OUTPUT"
          fi
      - name: Set up Java
        if: ${{ steps.mode.outputs.mode == 'local' && steps.reuse.outputs.restored != 'true' }}
        uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
        with:
          distribution: temurin
          java-version: ${{ steps.jdk.outputs.version }}
      # Keep Java setup mandatory; optional caches have no unbounded post step.
      - name: Retain Gradle cache scope
        id: gradle_scope
        if: ${{ steps.mode.outputs.mode == 'local' && steps.reuse.outputs.restored != 'true' }}
        continue-on-error: true
        shell: bash
        run: |
          node .github/actions/android-app/scripts/cache_scope.cjs --snapshot
          node - <<'NODE'
          require('fs').appendFileSync(process.env.GITHUB_OUTPUT,
            `home=${require('os').homedir()}\narch=${process.arch}\n`);
          NODE
      - name: Restore Gradle dependencies
        id: gradle_cache
        if: ${{ steps.mode.outputs.mode == 'local' && steps.gradle_scope.outcome == 'success' && steps.reuse.outputs.restored != 'true' }}
        uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
        continue-on-error: true
        timeout-minutes: 2
        env:
          NODE_OPTIONS: --require "${{ steps.gradle_scope.outputs.preloader }}"
          SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
        with:
          path: |
            ${{ steps.gradle_scope.outputs.home }}/.gradle/caches
            ${{ steps.gradle_scope.outputs.home }}/.gradle/wrapper
          key: >-
            ${{ format('setup-java-{0}-{1}-gradle-{2}', runner.os, steps.gradle_scope.outputs.arch,
                hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/Versions.kt',
                'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties')) }}
          restore-keys: >-
            ${{ format('setup-java-{0}-{1}-gradle-', runner.os, steps.gradle_scope.outputs.arch) }}
      - name: Set up Flutter
        if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        uses: ./.github/actions/android-app/flutter-setup
        with:
          channel: stable
          cache: true
          # 35 of this job's 45 minutes; see the iOS job for why a release needs
          # more archive room than a validation run.
          archive-budget-seconds: '2100'
      # Flutter apps can depend on private git packages. Most repos in this fleet
      # embed a PAT directly in the pubspec URL, which publishes the token into
      # source and into every fork of the lockfile. Setting the optional
      # GIT_PRIVATE_TOKEN secret instead lets plain
      # https://github.com/<org>/<repo>.git URLs resolve on the runner, so the
      # token lives in one revocable place. No secret, no step — repos that do
      # not need it are unaffected.
      # The `secrets` context is NOT available in a step-level `if:` — using it
      # there does not evaluate false, it makes the whole workflow file invalid
      # and every run fails before any job starts. So the secret comes in as env
      # and the emptiness check happens in the shell.
      - name: Authenticate private git dependencies
        if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        shell: bash
        env:
          GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
        run: |
          if [ -z "${GIT_PRIVATE_TOKEN:-}" ]; then
            echo "No GIT_PRIVATE_TOKEN secret; private git dependencies must carry their own credentials."
            exit 0
          fi
          echo "::add-mask::$GIT_PRIVATE_TOKEN"
          git config --global \
            url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
          echo "Configured authenticated github.com remotes for pub."
      # A Gradle daemon sized for a workstation does not fit a CI runner. Asking
      # for -Xmx8G with a 4G metaspace reserves 12 GB before the Kotlin compile
      # daemon spawns its own JVM, and the runner is killed mid-compile — which
      # GitHub reports as "cancelled", not as a failure, so it reads as a flake
      # rather than an out-of-memory. Clamp to a size that leaves room, in the
      # checkout only.
      - name: Cap the Gradle daemon heap for this runner
        if: ${{ steps.mode.outputs.mode == 'local' && runner.os == 'Linux' && steps.reuse.outputs.restored != 'true' }}
        shell: bash
        env:
          MAX_HEAP_GB: '4'
          MAX_META_GB: '2'
        run: |
          shopt -s nullglob
          for f in android/gradle.properties gradle.properties; do
            [ -f "$f" ] || continue
            before=$(grep -m1 '^org.gradle.jvmargs' "$f" || true)
            [ -n "$before" ] || continue
            heap=$(sed -nE 's/.*-Xmx([0-9]+)[Gg].*/\1/p' <<<"$before")
            meta=$(sed -nE 's/.*MaxMetaspaceSize=([0-9]+)[Gg].*/\1/p' <<<"$before")
            changed=""
            if [ -n "$heap" ] && [ "$heap" -gt "$MAX_HEAP_GB" ]; then
              sed -i -E "s/-Xmx${heap}[Gg]/-Xmx${MAX_HEAP_GB}G/" "$f"; changed=1
            fi
            if [ -n "$meta" ] && [ "$meta" -gt "$MAX_META_GB" ]; then
              sed -i -E "s/MaxMetaspaceSize=${meta}[Gg]/MaxMetaspaceSize=${MAX_META_GB}G/" "$f"; changed=1
            fi
            if [ -n "$changed" ]; then
              echo "::warning::$f asked for more heap than this runner has; capped it for this build only ($before)"
              grep -m1 '^org.gradle.jvmargs' "$f"
            fi
          done
      # Same reason as the iOS job: apps using AppLocalizations must generate it
      # before anything analyses or compiles them. No-op for apps without l10n.
      - name: Generate localizations
        if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' && steps.reuse.outputs.restored != 'true' }}
        shell: bash
        run: |
          if [ -f l10n.yaml ]; then
            flutter pub get
            flutter gen-l10n
          fi
      - name: Build and sign Android release
        id: android
        if: ${{ steps.mode.outputs.mode == 'local' && steps.reuse.outputs.restored != 'true' }}
        uses: ./.github/actions/android-app
        env:
          GITHUB_TOKEN: ${{ github.token }}
        with:
          # Tests belong to the CI gate, not the deploy path — same policy as
          # the iOS job above. Running the analyzer here breaks every deploy
          # the day a new stable Flutter adds a warning-level lint, even when
          # the app's own CI gate is green.
          run-tests: 'false'
          # Same variable as the iOS job. With an Android id the action uploads
          # the Dart symbol files to Crashlytics before the bundle goes to Play;
          # unset, it warns when the app depends on firebase_crashlytics.
          firebase-app-id: ${{ vars.FIREBASE_APP_ID }}
          # The hosted-flow origin the app compiles in, from the repository
          # variable the panel keeps in step with the origin it serves — the
          # same define the iOS job passes. Empty leaves the app's default.
          dart-defines: >-
            ${{ vars.ONBOARDING_PUBLIC_ORIGIN != '' && format('ONBOARDING_PUBLIC_ORIGIN={0}', vars.ONBOARDING_PUBLIC_ORIGIN) || '' }}
      - name: Retain signed Android App Bundle
        if: ${{ steps.mode.outputs.mode == 'local' }}
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
        env:
          NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
        with:
          name: android-${{ steps.android.outputs.package-name || steps.reuse.outputs.package-name }}-${{ steps.android.outputs.build-number || steps.reuse.outputs.build-number }}
          path: ${{ steps.android.outputs.bundle-path || steps.reuse.outputs.bundle-path }}
          if-no-files-found: error
          retention-days: 30
      - name: Package candidate shipping binary
        id: candidate_binary
        if: ${{ inputs.candidate-build && steps.mode.outputs.mode == 'local' }}
        shell: bash
        env:
          MOBILE_ARTIFACT_BINARY: ${{ steps.android.outputs.bundle-path }}
          ANDROID_PACKAGE_NAME: ${{ steps.android.outputs.package-name }}
          ANDROID_BUILD_NUMBER: ${{ steps.android.outputs.build-number }}
          ANDROID_PROJECT_KIND: ${{ steps.android.outputs.project-kind }}
        run: node .github/actions/swift-app/scripts/mobile_artifact_inventory.cjs pack android
      - name: Retain candidate shipping binary
        if: ${{ steps.candidate_binary.outcome == 'success' }}
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
        env:
          NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
        with:
          name: ${{ steps.candidate_binary.outputs.name }}
          path: ${{ steps.candidate_binary.outputs.directory }}
          if-no-files-found: error

      # A Flutter app's Dart symbol files (android-symbols-<package>-<code>)
      # are retained by the action itself, right after the obfuscated build —
      # the action autoupdates on consumers while this file does not, and the
      # flag and its safety net must never be split across that boundary.
      - name: Install Google Play preflight dependencies
        if: ${{ steps.mode.outputs.mode == 'local' && steps.android.outputs.play-api-ready == '' && !inputs.candidate-build }}
        shell: bash
        run: python3 -m pip install --disable-pip-version-check 'google-auth>=2.40,<3' 'requests>=2.32,<3'
      - name: Check Google Play API readiness
        id: play
        if: ${{ steps.mode.outputs.mode == 'local' && !inputs.candidate-build }}
        shell: bash
        env:
          PACKAGE_NAME: ${{ steps.android.outputs.package-name || steps.reuse.outputs.package-name }}
          SERVICE_ACCOUNT: ${{ steps.android.outputs.play-service-account || steps.reuse.outputs.play-service-account }}
          EARLY_READINESS: ${{ steps.android.outputs.play-api-ready }}
          GITHUB_TOKEN: ${{ github.token }}
        run: |
          # Current actions verify readiness before compiling. Older action copies
          # have no output, so retain their standalone preflight compatibility.
          case "$EARLY_READINESS" in
            true|false) echo "ready=$EARLY_READINESS" >> "$GITHUB_OUTPUT"; exit 0 ;;
            '') ;;
            *) echo "::error::Invalid Android readiness output"; exit 1 ;;
          esac
          node .github/actions/android-app/play-upload/owner_run.cjs \
            preflight "$PACKAGE_NAME" "$SERVICE_ACCOUNT"
      # The wrapper verifies the unchanged pinned official uploader and classifies
      # its immediate result. Only explicit expired/deleted edits retry normally;
      # only Play's explicit review-hold refusal changes the review flag.
      - name: Upload Android release to Google Play
        id: play-upload
        if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' && !inputs.candidate-build }}
        uses: ./.github/actions/android-app/play-upload
        env:
              GITHUB_TOKEN: ${{ github.token }}
        with:
          mobileReleaseIntentScript: ${{ github.workspace }}/.github/actions/swift-app/scripts/mobile_release.cjs
          serviceAccountJson: ${{ steps.android.outputs.play-service-account || steps.reuse.outputs.play-service-account }}
          packageName: ${{ steps.android.outputs.package-name || steps.reuse.outputs.package-name }}
          releaseFiles: ${{ steps.android.outputs.bundle-path || steps.reuse.outputs.bundle-path }}
          tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
          status: ${{ vars.GOOGLE_PLAY_STATUS || 'completed' }}
          userFraction: >-
            ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
          whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}

      # The retry landed the bundle without submitting it. That is a fact for
      - name: Record confirmed Android upload
        if: ${{ steps.play-upload.outcome == 'success' }}
        run: node .github/actions/swift-app/scripts/mobile_release.cjs finish-upload android

      # the panel that merged this commit, not a chore for a person: the panel
      # reads this typed check-run annotation off the job (title + JSON with a
      # versioned schema, never the log text) and opens its own Play console
      # step that presses "Send changes for review". Normal after a policy
      # rejection; the ordinary first upload submits and this never fires.
      - name: Record that Play holds this release for a review submission
        if: ${{ steps.play-upload.outcome == 'success' && steps.play-upload.outputs.review-pending == 'true' }}
        shell: bash
        env:
          PACKAGE: ${{ steps.android.outputs.package-name || steps.reuse.outputs.package-name }}
          TRACK: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
          VERSION_CODE: ${{ steps.android.outputs.build-number || steps.reuse.outputs.build-number }}
        run: |
          echo "::notice title=play_review_pending::{\"schema\":\"gowalk-cicd/play-review-pending.v1\",\"package\":\"${PACKAGE}\",\"track\":\"${TRACK}\",\"version_code\":\"${VERSION_CODE}\"}"

      # Saved even when the build failed OR the job ran out of time. The cache
      # holds DOWNLOADS, not build outputs: Gradle's `modules-2` store is
      # content-addressed and re-verified on use, and a partial fetch never
      # enters it. `!cancelled()` still skipped exactly the case that matters —
      # a job cancelled at its own `timeout-minutes` — which deadlocked every
      # app's FIRST Android release: a cold build that exceeds the cap is
      # cancelled before this step, so nothing is cached, so the next attempt is
      # cold too. Foundy (2026-09-09) spent 39 of its 45 minutes in
      # `flutter_bundle` with no cache entry for the repository at all, while an
      # app with a warm cache finishes the same job in 13 minutes.
      - name: Save Gradle dependencies
        if: >-
          ${{ always() && steps.mode.outputs.mode == 'local'
              && steps.gradle_cache.outputs.cache-primary-key != ''
              && steps.gradle_cache.outputs.cache-hit != 'true' }}
        uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
        continue-on-error: true
        timeout-minutes: 2
        env:
          NODE_OPTIONS: --require "${{ steps.gradle_scope.outputs.preloader }}"
        with:
          path: |
            ${{ steps.gradle_scope.outputs.home }}/.gradle/caches
            ${{ steps.gradle_scope.outputs.home }}/.gradle/wrapper
          key: >-
            ${{ job.status == 'success' && steps.gradle_cache.outputs.cache-primary-key
                || format('{0}-partial-{1}-{2}', steps.gradle_cache.outputs.cache-primary-key,
                    github.run_id, github.run_attempt) }}

  # GitHub only registers a newly added workflow after it reaches the default
  # branch. A brand-new app's feature branch therefore cannot dispatch the
  # separate Backend Deploy workflow yet. Reuse this already-registered mobile
  # workflow for the first feature-branch deploy; default-branch pushes keep
  # using deploy-backend.yml and never run this job twice.
  backend-preview:
    name: Backend Deploy
    if: >-
      ${{ github.event_name == 'workflow_dispatch'
          && github.ref_name != github.event.repository.default_branch }}
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
      - id: dir
        shell: bash
        run: |
          if [ -f .github/actions/backend-app/layout.cjs ]; then
            node .github/actions/backend-app/layout.cjs
          else
            echo "path=" >> "$GITHUB_OUTPUT"
            echo "compose=" >> "$GITHUB_OUTPUT"
          fi
      - name: Deploy backend from feature branch
        if: ${{ steps.dir.outputs.path != '' }}
        uses: ./.github/actions/backend-app
        with:
          ssh-key: ${{ secrets.BACKEND_DEPLOY_SSH_KEY }}
          host: ${{ vars.BACKEND_DEPLOY_HOST || '138.197.36.107' }}
          app-name: ${{ vars.BACKEND_APP_NAME || github.event.repository.name }}
          api-domain: ${{ vars.BACKEND_API_DOMAIN }}
          backend-dir: ${{ steps.dir.outputs.path }}
          compose-file: ${{ steps.dir.outputs.compose }}
          health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
          public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
          public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
          expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
          ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
          ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
          runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
