# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules, # 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an # AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on # the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape # (push + pull_request, path-filtered), same "one job per verification layer" structure, # and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave # the same way on rapid pushes. # # ── HONESTY NOTE (read before trusting a green badge) ──────────────────────────────────── # This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has # NEVER run here and this file has never been executed by the platform. What IS verified: # every command below was run locally on macOS against the same Gradle build # (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile), # and the YAML parses. What is NOT verified: the runner-side pieces — action resolution, # the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg. # # ── Why no `src/**` path trigger (ios.yml HAS one) ─────────────────────────────────────── # ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the # real Node server and would catch client↔server protocol drift. Android has no equivalent # leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server # with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**` # would therefore burn runner minutes without checking a single contract. When an Android # server-contract leg is added, add `src/**` here at the same time. name: android on: push: paths: - "android/**" - ".github/workflows/android.yml" pull_request: paths: - "android/**" - ".github/workflows/android.yml" # Manual trigger for the instrumented (device) leg below, which is dispatch-only. workflow_dispatch: jobs: # Layer 1 — everything that runs without a device. This is exactly the green gate # android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a # compile-only pass over the instrumented sources so they cannot silently rot. build-and-test: runs-on: ubuntu-latest timeout-minutes: 45 defaults: run: working-directory: android steps: - uses: actions/checkout@v4 # jvmToolchain(17) is declared by every module; Gradle resolves it from the # installed JDKs, so 17 must actually be present (the runner image's default may # be a different major). - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" cache: gradle # :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with # build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an # Android SDK, but which platforms it holds drifts image to image — so install what the # build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager. - name: Install the Android SDK packages this build compiles against run: | # `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes` # with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that # normal ending as a failed step. set -eu sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" if [ ! -x "$sdkmanager_bin" ]; then sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)" fi if [ ! -x "$sdkmanager_bin" ]; then echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2 exit 1 fi yes | "$sdkmanager_bin" --licenses > /dev/null "$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools" # 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes # the Android-library modules' testDebugUnitTest and :app's — no device needed. - name: JVM unit tests (all modules) run: ./gradlew test # The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do # not reach (resources, manifest merge, DI graph assembly) must not hide behind them. - name: Debug APK builds run: ./gradlew :app:assembleDebug # Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin # (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual # percentages into the run log so a near-miss is visible before it becomes a failure. # The Android-framework modules are NOT gated (no Kover on them) — a known gap. - name: Coverage gate (Kover, >= 80% on the four gated modules) run: ./gradlew koverVerify koverLog # The instrumented sources cannot RUN here, but they must keep COMPILING. Three modules # carry them, 119 @Test in total: `:app` (67 — the device-blocker suite: key routing, # resize, alternate-screen scroll, autofill, driven through a custom HiltTestRunner), # `:host-registry` (31 — incl. TinkAccessTokenStoreTest, the only assertion anywhere that # the access token is ciphertext at rest) and `:client-tls-android` (21 — AndroidKeyStore # import). A silent compile break would delete all of that without anyone noticing. - name: Instrumented test sources still compile run: ./gradlew :app:assembleDebugAndroidTest :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest - name: Upload test + coverage reports if: failure() uses: actions/upload-artifact@v4 with: name: android-reports path: | android/*/build/reports/tests/** android/*/build/test-results/** android/*/build/reports/kover/** retention-days: 7 if-no-files-found: ignore # Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose. # # What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the # frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store # instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather # than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no # JVM double, so this can only run on a device or emulator. # # The suite itself is NOT unproven: the android-blocker-fixes session ran `:app`'s 67 # instrumented tests green on real hardware. What has never been proven is this leg — the # CI *emulator* path. Those are different claims and only the second one gates this `if:`. # # Why it is not on push: this workflow has never executed on any runner (see the honesty # note at the top), and an emulator leg is the single most fragile thing in Android CI — # it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute, # KVM-dependent job into every push would either block the repo on a leg nobody has seen # pass, or push people to make it non-blocking — and a leg that cannot fail is a false green # (the same trap ios.yml's `ios17-floor-tests` comment calls out). # # THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token # store, the DataStore stores, the AndroidKeyStore importer, or the terminal view/input # path change. Once it has been seen green on a real runner, promote it by deleting the # `if:` below — the suite has already earned it on hardware. # # MANUAL EQUIVALENT (the path that has actually been walked — DEVICE_QA_CHECKLIST.md): # with a device/emulator attached, # cd android && ANDROID_HOME= ./gradlew :app:connectedDebugAndroidTest \ # :host-registry:connectedDebugAndroidTest \ # :client-tls-android:connectedDebugAndroidTest # (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still # need real hardware.) instrumented-tests: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" cache: gradle - name: Enable KVM for the emulator run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm # API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is # most likely to differ (mirrors ios.yml's iOS 17 floor leg). - name: connectedDebugAndroidTest on an API 29 emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 29 target: google_apis arch: x86_64 working-directory: android script: ./gradlew :app:connectedDebugAndroidTest :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest - name: Upload instrumented reports if: always() uses: actions/upload-artifact@v4 with: name: android-instrumented-reports path: android/*/build/reports/androidTests/** retention-days: 7 if-no-files-found: ignore