name: Android build and signing
description: >
  Auto-detect an Android app — Flutter or native Gradle — build a release AAB
  (Flutter apps are compiled with Dart obfuscation, which is mandatory), and
  sign it with the upload key stored under creds/. Google Play upload is
  handled by the package workflow so the signed artifact is always retained
  first.

inputs:
  package-name:
    description: Android applicationId override; auto-detected when empty
    required: false
    default: ""
  build-number:
    description: >-
      Android versionCode override. When unset, the action uses
      max(GITHUB_RUN_NUMBER, highest versionCode already on Play + 1), so a repo whose
      CI run numbers start at 1 cannot collide with codes Play already holds.
    required: false
    default: ""
  build-name:
    description: >-
      versionName override; defaults to pubspec.yaml (Flutter) or the value
      declared in the module's build.gradle (native Gradle)
    required: false
    default: ""
  run-tests:
    description: >-
      Run the project's analyzer and unit tests before the release build
      (flutter analyze + flutter test, or the Gradle module's unit tests)
    required: false
    default: "true"
  dart-defines:
    description: >-
      Compile-time Dart defines for a Flutter app, one `KEY=VALUE` per line
      (or whitespace-separated), passed to `flutter build appbundle` as
      `--dart-define`. The package workflow passes the hosted-flow origin
      (`ONBOARDING_PUBLIC_ORIGIN`) from the repository variable of the same
      name. Ignored for native Gradle apps; empty passes nothing.
    required: false
    default: ""
  firebase-app-id:
    description: >-
      Firebase App ID(s) for the Crashlytics symbol upload — the
      `1:<project number>:android:<hash>` value (mobilesdk_app_id in
      google-services.json, or Project settings → Your apps in the Firebase
      console). Several ids may be listed, separated by commas or whitespace;
      the Android one is used. Empty disables the upload. The package workflow
      passes the FIREBASE_APP_ID repository variable.
    required: false
    default: ""

outputs:
  package-name:
    description: Auto-detected Android applicationId
    value: ${{ steps.config.outputs.package_name }}
  bundle-path:
    description: Absolute path of the signed Android App Bundle
    value: ${{ steps.bundle.outputs.bundle_path }}
  build-number:
    description: Effective Android versionCode
    value: ${{ steps.config.outputs.build_number }}
  play-service-account:
    description: Path to the discovered Google Play service-account JSON
    value: ${{ steps.config.outputs.service_account }}
  play-api-ready:
    description: Prebuild readiness from the same owned edit used to read existing version codes
    value: ${{ steps.config.outputs.play_api_ready }}
  project-kind:
    description: Detected build system — `flutter` or `gradle`
    value: ${{ steps.config.outputs.project_kind }}
  symbols-path:
    description: >-
      Directory holding the Dart symbol files (app.android-*.symbols) that
      `flutter symbolize` needs to read a stack trace from this obfuscated
      build. Empty for native Gradle apps, which have no Dart code.
    value: ${{ steps.flutter_build.outputs.symbols_path }}

runs:
  using: composite
  steps:
    # Resolve readiness and highest versionCode together before any compilation.
    # Missing dependencies or an indeterminate store read must fail here; they are
    # not evidence of a new package with no existing version codes.
    - name: Install Play API dependencies
      shell: bash
      run: python3 -m pip install --disable-pip-version-check -q 'google-auth>=2.40,<3' 'requests>=2.32,<3'

    - name: Resolve Android app and credentials
      id: config
      shell: bash
      env:
        INPUT_PACKAGE_NAME: ${{ inputs.package-name }}
        INPUT_BUILD_NUMBER: ${{ inputs.build-number }}
        INPUT_BUILD_NAME: ${{ inputs.build-name }}
        PLAY_EDIT_BINDING: ${{ env.PLAY_EDIT_BINDING }}
        GITHUB_TOKEN: ${{ github.token }}
      run: node "${{ github.action_path }}/play-upload/owner_run.cjs" resolve

    # --- Flutter --------------------------------------------------------
    - name: Prepare Crashlytics symbol tools
      if: ${{ steps.config.outputs.project_kind == 'flutter' }}
      shell: bash
      env:
        FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
      run: python3 "${{ github.action_path }}/scripts/crashlytics_symbols.py" --prepare-only

    - name: Get Flutter dependencies
      if: ${{ steps.config.outputs.project_kind == 'flutter' }}
      shell: bash
      run: flutter pub get

    - name: Analyze Flutter app
      if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
      shell: bash
      run: flutter analyze

    # `flutter test` hard-fails with "Test directory \"test\" not found" when a repo
    # has no tests, which is not a real failure. Skip instead of blowing up the deploy.
    - name: Test Flutter app
      if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
      shell: bash
      run: |
        if [ -d test ]; then
          flutter test --no-pub
        else
          echo "No test/ directory; skipping flutter test."
        fi

    # Dart obfuscation is mandatory for Flutter apps: every release build is
    # compiled with --obfuscate, and there is deliberately no input or repo
    # variable that switches it off. --split-debug-info is not optional
    # either — `flutter build` refuses --obfuscate without it — and the symbol
    # files it writes are the only way to read a stack trace from this build,
    # so the build fails when they are missing. The directory lives under
    # RUNNER_TEMP rather than in the checkout so nothing that inspects or
    # commits the working tree can ever see it.
    - name: Build release Android App Bundle
      id: flutter_build
      if: ${{ steps.config.outputs.project_kind == 'flutter' }}
      shell: bash
      env:
        BUILD_NAME: ${{ inputs.build-name }}
        DART_DEFINES: ${{ inputs.dart-defines }}
        DART_SYMBOLS_DIR: ${{ runner.temp }}/dart-symbols/android
      run: |
        args=(
          build appbundle
          --release
          --build-number "$ANDROID_BUILD_NUMBER"
          --obfuscate
          --split-debug-info "$DART_SYMBOLS_DIR"
        )
        if [ -n "$BUILD_NAME" ]; then
          args+=(--build-name "$BUILD_NAME")
        fi
        # One --dart-define per KEY=VALUE entry; a token without '=' is a
        # workflow authoring error and fails here rather than compiling a
        # define nobody reads.
        for define in $DART_DEFINES; do
          case "$define" in
            *=*) args+=(--dart-define "$define") ;;
            *) echo "::error::dart-defines entry '$define' is not KEY=VALUE"; exit 1 ;;
          esac
        done
        python3 "${{ github.action_path }}/scripts/android_build_diagnostics.py" --phase flutter_bundle -- \
          flutter "${args[@]}"
        if ! ls "$DART_SYMBOLS_DIR"/*.symbols >/dev/null 2>&1; then
          echo "::error::flutter build wrote no Dart symbol files to $DART_SYMBOLS_DIR;" \
               "a crash from this obfuscated build could never be read. Refusing to ship it."
          exit 1
        fi
        ls -la "$DART_SYMBOLS_DIR"
        echo "symbols_path=$DART_SYMBOLS_DIR" >> "$GITHUB_OUTPUT"

    # The symbols are retained HERE, not in the workflow, on purpose. The
    # consumer-side autoupdate re-vendors this action but can never touch
    # deploy.yml (GITHUB_TOKEN cannot push workflow files), so an updated
    # action paired with an older workflow is the normal state of the fleet.
    # If the flag lived here and the safety net lived in the workflow, that
    # pairing would ship obfuscated bundles and destroy the only copy of their
    # symbols with the runner. Whatever obfuscation needs to be safe must
    # travel in the same file as the flag. No retention-days: the repository's
    # own artifact retention setting governs, and unlike a fixed number it can
    # be raised to cover the life of a release.
    - name: Retain Dart symbol files
      if: ${{ steps.config.outputs.project_kind == 'flutter' }}
      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
      env:
        NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
      with:
        name: android-symbols-${{ steps.config.outputs.package_name }}-${{ steps.config.outputs.build_number }}
        path: ${{ steps.flutter_build.outputs.symbols_path }}
        if-no-files-found: error

    # Crashlytics shows *** for every Dart frame of an obfuscated build until
    # it has the symbol files, and on Android nothing uploads them by itself
    # (the Crashlytics Gradle plugin handles native NDK symbols only). With
    # firebase-app-id set — deploy.yml passes the FIREBASE_APP_ID repository
    # variable — they go up here, before the workflow ships the bundle to
    # Play, so a failed upload fails the deploy instead of shipping crashes
    # nobody can read. Unset, the step only warns when the app depends on
    # firebase_crashlytics. The Firebase CLI needs Node and Java, both already
    # on this job, and no credentials. Logic and its tests live in
    # scripts/crashlytics_symbols.py.
    - name: Upload Dart symbols to Crashlytics
      if: ${{ steps.config.outputs.project_kind == 'flutter' }}
      shell: bash
      env:
        FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
        DART_SYMBOLS_DIR: ${{ steps.flutter_build.outputs.symbols_path }}
      run: |
        python3 "${{ github.action_path }}/scripts/crashlytics_symbols.py" \
          --symbols-dir "$DART_SYMBOLS_DIR"

    # --- Native Gradle --------------------------------------------------
    # resolve_android.py rewrites versionCode/versionName in the module build
    # file when it can find a literal to rewrite, and the build step below
    # additionally overrides them through the Variant API so the resolved
    # versionCode holds even when there is no literal — see
    # scripts/version_override.init.gradle.
    # `test` is the aggregate task ("Run unit tests for all variants") and is the
    # only one guaranteed to exist: AGP only creates testXUnitTest tasks for the
    # variants it enables, and AGP 9 disables the release unit test variant by
    # default, so `:app:testReleaseUnitTest` is simply absent in many projects.
    # Running it unqualified also mirrors `flutter test`, which covers the whole
    # package rather than one module.
    - name: Test Android app
      if: ${{ steps.config.outputs.project_kind == 'gradle' && inputs.run-tests == 'true' }}
      shell: bash
      working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
      run: |
        chmod +x ./gradlew
        ./gradlew test --console=plain --stacktrace

    # ANDROID_BUILD_NUMBER is already in the environment: resolve_android.py
    # exported it through GITHUB_ENV. ANDROID_BUILD_NAME is only set when the
    # caller actually pinned a build-name — empty means "leave versionName
    # alone", which the init script honours.
    - name: Build release Android App Bundle with Gradle
      if: ${{ steps.config.outputs.project_kind == 'gradle' }}
      shell: bash
      working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
      env:
        ANDROID_BUILD_NAME: ${{ inputs.build-name }}
      run: |
        chmod +x ./gradlew
        python3 "${{ github.action_path }}/scripts/android_build_diagnostics.py" --phase gradle_bundle -- \
          ./gradlew "${ANDROID_GRADLE_MODULE}:bundleRelease" \
          --init-script "${{ github.action_path }}/scripts/version_override.init.gradle" \
          --console=plain --stacktrace

    # --- Shared ---------------------------------------------------------
    - name: Locate Android App Bundle
      id: bundle
      shell: bash
      run: python3 "${{ github.action_path }}/scripts/find_bundle.py"

    - name: Sign Android App Bundle with upload key
      shell: bash
      env:
        BUNDLE_PATH: ${{ steps.bundle.outputs.bundle_path }}
      run: |
        python3 "${{ github.action_path }}/scripts/sign_bundle.py" \
          --bundle "$BUNDLE_PATH" \
          --properties "$ANDROID_SIGNING_PROPERTIES" \
          --keystore "$ANDROID_KEYSTORE_PATH"
