From cf1a8f6b838074762a4dfd6ed9f034c0efbebd38 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 15:01:28 -0500 Subject: [PATCH 1/3] test(compose): Robolectric JVM Compose testing infra + PoC (#373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables unit-testing Jetpack Compose UI on the JVM via Robolectric, so render-only screens can leave the jacocoNonJvmTestableSurface exclusion list and be counted by JaCoCo without an emulator. - add Robolectric 4.16.1 (test scope) + Compose ui-test-junit4/-manifest - testOptions.unitTests.isIncludeAndroidResources = true so resources (strings, Material3 theme) resolve on the JVM - src/test/resources/robolectric.properties pins sdk=36 (targetSdk 37 is a preview level Robolectric 4.16 has no sandbox for) - PoC: AddAnotherAccountScreenJvmTest drives the screen with the v2 createComposeRule under RobolectricTestRunner (3 tests, green on the JVM) - drop AddAnotherAccountScreen from jacocoNonJvmTestableSurface (now JVM-covered); floor stays 0.79 — re-ratchet deferred to end of #373 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 29 ++++++- .../AddAnotherAccountScreenJvmTest.kt | 78 +++++++++++++++++++ app/src/test/resources/robolectric.properties | 7 ++ gradle/libs.versions.toml | 7 ++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef0c692..bce1769 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,6 +147,12 @@ android { } testOptions { + // Robolectric-backed Compose UI unit tests (issue #373) need the merged Android resources + // (drawables, strings, the compiled resource table) on the JVM unit-test classpath so + // `stringResource(...)` and Material3 theming resolve without an emulator. Off by default in + // AGP; JVM tests that don't touch resources are unaffected. + unitTests.isIncludeAndroidResources = true + // Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual // device per supported Android version (a rolling ~7-year window, API 29 → latest stable). // Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g. @@ -251,7 +257,14 @@ val jacocoGeneratedExcludes = listOf( // audit): unlike `jacocoGeneratedExcludes` above, none of this is generated code — it is hand-written // but structurally unreachable from a JVM unit test, so counting it against the metric just measures // how much Compose/framework glue exists rather than how well the logic is tested. Four buckets: -// 1. Compose screen/component render code — only exercisable via a Compose UI test or an emulator. +// 1. Compose screen/component render code. Historically only exercisable via an emulator, so it was +// excluded here. Issue #373 changes that: Robolectric runs the Android framework on the JVM, so a +// `createComposeRule()` test in the `test` source set now gives these files real JVM coverage +// without an emulator. This bucket therefore SHRINKS one screen at a time — each glob is deleted +// in the same PR that adds that screen's Robolectric JVM Compose test. AddAnotherAccountScreen was +// the first (see AddAnotherAccountScreenJvmTest) and has been removed below; the rest are tracked +// as per-area conversion tickets under #373. The coverage-floor re-ratchet is deferred until the +// whole conversion is done and stable (#373) — do NOT raise it in a conversion PR. // 2. Android framework entry points the OS instantiates directly (Activity/Service/Application/ // BackupAgent) rather than the app's own code constructing them. // 3. Hilt DI modules — `@Provides`/`@Binds` one-liners with no branching logic. @@ -298,7 +311,7 @@ val jacocoNonJvmTestableSurface = listOf( "**/AppLockGateHost*", "**/FolderDrawer*", "**/MailboxScreen*", - "**/AddAnotherAccountScreen*", + // AddAnotherAccountScreen converted to a Robolectric JVM Compose test (#373) — now JVM-covered. "**/BatteryOptimizationScreen*", "**/ContactsAccessScreen*", "**/LicenseScreen*", @@ -459,6 +472,18 @@ dependencies { // The real org.json for unit tests (android.jar ships a stubbed, no-op version). testImplementation("org.json:json:20231013") + // Robolectric-backed JVM Compose UI tests (issue #373): Robolectric runs the Android framework + // on the JVM so `createComposeRule()` can drive composables without an emulator, bringing screen + // render code into the JaCoCo JVM-testable surface. The Compose test artifacts come from the same + // BOM as the app (aligned versions) and reuse the ui-test-junit4 / ui-test-manifest aliases the + // androidTest source set already declares — here in `test` (JVM), not `androidTest`. Robolectric + // sources Android's real org.json from its sandbox, so it does not clash with the stub-replacing + // org.json above (that is for the plain, non-Robolectric JVM tests). + testImplementation(libs.robolectric) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.compose.ui.test.manifest) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.intents) diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt new file mode 100644 index 0000000..84237bd --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric feasibility PoC (issue #373): proves Jetpack Compose UI can be unit-tested on the JVM + * without an emulator. `createComposeRule()` drives [AddAnotherAccountScreen] under + * [RobolectricTestRunner]; `stringResource(...)` and Material3 theming resolve because + * `testOptions.unitTests.isIncludeAndroidResources` is enabled in `app/build.gradle.kts`. Runs under + * `:app:testDebugUnitTest`, NOT `androidTest` — the instrumented `AddAnotherAccountScreenTest` stays. + * + * `@Config(sdk = [36])` because compileSdk/targetSdk 37 (preview) has no Robolectric 4.16 sandbox + * (also the default in `src/test/resources/robolectric.properties`); `@GraphicsMode(NATIVE)` is + * required for Compose to render under Robolectric. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class AddAnotherAccountScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private fun setContent(onAddAnother: () -> Unit = {}, onFinish: () -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AddAnotherAccountScreen(onAddAnother = onAddAnother, onFinish = onFinish) + } + } + } + + @Test + fun rendersAccountAddedTitleFromResources() { + setContent() + + // Proves the merged Android resource table loaded on the JVM (isIncludeAndroidResources). + composeTestRule.onNodeWithText(string(R.string.onboarding_account_added_title)).assertIsDisplayed() + } + + @Test + fun tappingYes_invokesOnAddAnother() { + var addAnother = false + setContent(onAddAnother = { addAnother = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).performClick() + + assertTrue(addAnother) + } + + @Test + fun tappingNo_invokesOnFinish() { + var finished = false + setContent(onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + assertTrue(finished) + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..eb85a88 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Default SDK for every Robolectric (JVM) test (issue #373). compileSdk/targetSdk are 37, a +# preview level Robolectric 4.16.x does not ship a sandbox for; 36 (Baklava) is its newest +# supported level and needs JDK 21 (the project's Gradle JDK). Individual tests may still override +# with @Config(sdk = [..]). Only affects tests run with RobolectricTestRunner; plain JUnit unit +# tests are unaffected. +sdk=36 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f61fb86..6039f7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,11 @@ androidxTestRules = "1.7.0" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9" +# Robolectric — runs the Android framework on the JVM so Compose UI (createComposeRule) can be +# unit-tested without an emulator (issue #373). 4.16.x is the first line to support compileSdk 36 +# (Baklava) and requires JDK 21 for it; API 37 (preview) is unsupported, so Robolectric tests pin +# @Config(sdk = 36) — also set in app/src/test/resources/robolectric.properties. +robolectric = "4.16.1" errorprone = "2.50.0" # Lint/format. ktlint owns formatting; detekt owns static analysis (formatting ruleset off). # detekt 2.0 is the only line with Gradle 9 support (stable 1.23.x tops out at Gradle 8.12). @@ -115,6 +120,8 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } # mockk's Android artifact for instrumented tests (dexmaker/byte-buddy-android backed). mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } +# Robolectric — JVM Android framework for unit-testing Compose UI without an emulator (issue #373). +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } From 324f7c2c5192c22f7903c8058f92057f4bfc308e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 15:49:07 -0500 Subject: [PATCH 2/3] fix(test): resolve Robolectric android-all via Gradle so the JVM Compose test runs in CI (#373) Robolectric resolved its android-all-instrumented runtime jar lazily at test time via its own MavenDependencyResolver/MavenArtifactFetcher, and that download is unreliable on CI runners: AddAnotherAccountScreenJvmTest failed with `AssertionError at MavenArtifactFetcher ... IOException` ("Failed to fetch maven artifact"), though it passed locally where ~/.m2 was warm. Resolve the jar through Gradle instead (reliable, cached, persisted by the CI Gradle cache) and hand it to Robolectric in offline mode so it never hits the network at test time: - Pin org.robolectric:android-all-instrumented:16-robolectric-13921718-i7 (exactly what Robolectric 4.16.1 DefaultSdkProvider maps @Config(sdk=36) to) in the version catalog. - Add it to a dedicated resolvable configuration (NOT testImplementation/ testRuntimeOnly, which would flatten the ~200MB instrumented framework onto the JVM test classpath and collide with the stub android.jar). - syncRobolectricAndroidAll stages the jar under its Maven filename, and robolectric.offline + robolectric.dependency.dir point Robolectric's LocalDependencyResolver at it. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 45 +++++++++++++++++++++++++++++++++++++++ gradle/libs.versions.toml | 14 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bce1769..57cea52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -412,6 +412,45 @@ tasks.named("check") { dependsOn("jacocoTestCoverageVerification") } +// --- Robolectric android-all offline resolution (issue #373) ------------------------------------ +// Robolectric runs the real Android framework on the JVM from a large `android-all-instrumented` +// jar. By default it resolves that jar LAZILY AT TEST TIME by downloading it from Maven Central +// (org.robolectric.internal.dependency.MavenDependencyResolver -> MavenArtifactFetcher). That +// runtime download is unreliable on CI runners and failed the JVM Compose PoC in CI with +// `java.lang.AssertionError at MavenArtifactFetcher ... Caused by: java.io.IOException` ("Failed to +// fetch maven artifact"). Fix: resolve the jar through Gradle instead — reliable, cached, and +// persisted by the CI Gradle cache, using the same repositories as every other dependency — then +// hand it to Robolectric in OFFLINE mode so it never touches the network at test time. +// +// A DEDICATED resolvable configuration (deliberately NOT testImplementation/testRuntimeOnly) keeps +// the ~200 MB instrumented framework jar OFF the JVM unit-test classpath: it must be loaded only by +// Robolectric's sandbox classloader, never flattened onto the app's test classpath where it would +// collide with the stub `android.jar`. `syncRobolectricAndroidAll` stages the resolved jar under +// its Maven filename (android-all-instrumented-.jar) — exactly what Robolectric's +// LocalDependencyResolver looks up as -.jar — and the two system properties +// below switch Robolectric onto that offline directory (see LegacyDependencyResolver). Every +// Robolectric test pins @Config(sdk = 36) (app/src/test/resources/robolectric.properties), so the +// single sdk=36 jar covers them all; a test on a different SDK must add that android-all version to +// this configuration too. The offline properties are inert for non-Robolectric JVM tests. +val robolectricAndroidAll: Configuration = configurations.create("robolectricAndroidAll") { + isCanBeConsumed = false + isCanBeResolved = true +} + +val robolectricDepsDir = layout.buildDirectory.dir("robolectric-android-all") + +val syncRobolectricAndroidAll = tasks.register("syncRobolectricAndroidAll") { + description = "Stages Robolectric's android-all-instrumented jar for offline resolution (issue #373)." + from(robolectricAndroidAll) + into(robolectricDepsDir) +} + +tasks.withType().configureEach { + dependsOn(syncRobolectricAndroidAll) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.dependency.dir", robolectricDepsDir.get().asFile.absolutePath) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -480,6 +519,12 @@ dependencies { // sources Android's real org.json from its sandbox, so it does not clash with the stub-replacing // org.json above (that is for the plain, non-Robolectric JVM tests). testImplementation(libs.robolectric) + // The android-all-instrumented framework jar Robolectric loads into its sandbox — resolved via + // Gradle and staged for offline use by syncRobolectricAndroidAll above so no flaky test-time + // download happens in CI (issue #373). On its own dedicated configuration, NOT the test + // classpath — see that block for why. The artifact has no transitive dependencies (verified from + // its POM), so it resolves to exactly the one staged jar. + "robolectricAndroidAll"(libs.robolectric.android.all.instrumented) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6039f7b..baf07e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,17 @@ greenmail = "2.1.9" # (Baklava) and requires JDK 21 for it; API 37 (preview) is unsupported, so Robolectric tests pin # @Config(sdk = 36) — also set in app/src/test/resources/robolectric.properties. robolectric = "4.16.1" +# The instrumented android-all runtime jar Robolectric loads into its sandbox to run the Android +# framework on the JVM. By DEFAULT Robolectric downloads this ~200 MB artifact from Maven Central +# lazily at TEST time (MavenDependencyResolver -> MavenArtifactFetcher); that runtime fetch is +# unreliable on CI runners and failed the JVM Compose PoC with "Failed to fetch maven artifact" +# (issue #373). Pinning it lets Gradle resolve+cache it and hand it to Robolectric in offline mode +# (see the robolectricAndroidAll wiring in app/build.gradle.kts) so no network fetch happens at +# test time. The value is BOTH Robolectric-version- and SDK-specific: it is exactly what +# Robolectric 4.16.1's DefaultSdkProvider maps @Config(sdk = 36) to — androidVersion "16", build +# "13921718", preinstrumented suffix "-i7". Bump it together with `robolectric` above, and update +# it if the pinned @Config(sdk = ..) / robolectric.properties SDK ever changes. +robolectricAndroidAll = "16-robolectric-13921718-i7" errorprone = "2.50.0" # Lint/format. ktlint owns formatting; detekt owns static analysis (formatting ruleset off). # detekt 2.0 is the only line with Gradle 9 support (stable 1.23.x tops out at Gradle 8.12). @@ -122,6 +133,9 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } # Robolectric — JVM Android framework for unit-testing Compose UI without an emulator (issue #373). robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +# Resolved via Gradle (not Robolectric's runtime Maven fetch) and staged for offline use so the JVM +# Compose tests run in CI without a flaky test-time download — see app/build.gradle.kts (issue #373). +robolectric-android-all-instrumented = { group = "org.robolectric", name = "android-all-instrumented", version.ref = "robolectricAndroidAll" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } From aa628831be63955df2d94d884979bd605569848f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 18:59:05 -0500 Subject: [PATCH 3/3] ci: retry + cache Android SDK/emulator setup to survive corrupt-zip sdkmanager failures (#389) The dominant merge-blocking flake was the "Set up Android SDK" step (android-actions/setup-android v4.0.1) dying BEFORE the emulator starts: Wrong version in preinstalled sdkmanager Warning: ... preparing SDK package Android Emulator: Error reading Zip content from a SeekableByteChannel. Error: The process '.../sdkmanager' failed with exit code 1 Root cause: the action's default cmdline-tools version (20.0) rarely matches the runner image's preinstalled one, so it logs "Wrong version in preinstalled sdkmanager" and re-fetches cmdline-tools with NO checksum; it then runs its default `sdkmanager tools platform-tools` install. Any of those downloads can be a corrupt/truncated zip, which sdkmanager turns into an un-retried exit 1. v4.0.1 is the latest release, so this is fixed by configuration + hardening, not a bump. Harden with verify -> reject -> retry, never trusting sdkmanager's exit code alone, via a new stdlib-only helper .github/scripts/setup_android_sdk.py: - bootstrap: download the pinned cmdline-tools zip, verify size + SHA-256 (authoritative pin, cross-checked against Google's published SHA-1), and install it to $ANDROID_SDK_ROOT/cmdline-tools/20.0 -- the exact path setup-android probes first, so the action reuses the verified tree and never does its own unverified "Wrong version" re-download. A mismatch (corrupt OR wrong version) deletes the bad zip + any half-extracted dir and re-downloads. - install: sdkmanager --install with retry + backoff; on a corrupt package zip it purges the partial/corrupt package dir (and sdkmanager's temp dirs) before retrying, forcing a fresh download instead of a re-read. - setup-android now runs with packages: "" (no flaky tools/platform-tools install) and cmdline-tools-version: "14742923" (reuse the verified bootstrap). - actions/cache restore + success-gated save so only a verified SDK is ever cached (integrity gates the cache); shrinks the re-download/corruption surface. Applied to every SDK-setup job (debug-build, unit-tests, static-analysis, e2e matrix, e2e-preview). Emulator BOOT logic, #372 API-37 sharding, and #388 diagnostics are untouched. Pure-logic helpers are unit-tested (test_setup_android_sdk.py, run by the traffic-control-tests job). Co-Authored-By: Claude Opus 4.8 --- .github/scripts/setup_android_sdk.py | 306 ++++++++++++++++++++++ .github/scripts/test_setup_android_sdk.py | 178 +++++++++++++ .github/workflows/ci.yml | 263 +++++++++++++++++-- 3 files changed, 725 insertions(+), 22 deletions(-) create mode 100644 .github/scripts/setup_android_sdk.py create mode 100644 .github/scripts/test_setup_android_sdk.py diff --git a/.github/scripts/setup_android_sdk.py b/.github/scripts/setup_android_sdk.py new file mode 100644 index 0000000..1ebbccf --- /dev/null +++ b/.github/scripts/setup_android_sdk.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Hardened Android SDK setup for CI (issue #389). + +The dominant merge-blocking flake was the **Set up Android SDK** step +(`android-actions/setup-android`) dying *before* the emulator ever starts: + + Wrong version in preinstalled sdkmanager + Warning: ... preparing SDK package Android Emulator: Error reading Zip + content from a SeekableByteChannel. + Error: The process '.../sdkmanager' failed with exit code 1 + +Two root causes, both a corrupt/truncated download that a bare `sdkmanager` +turns into an un-retried exit 1: + + * the action's own **unverified** cmdline-tools re-download (its default + cmdline-tools version rarely matches the runner image's preinstalled one, so + it logs "Wrong version in preinstalled sdkmanager" and re-fetches with *no* + checksum), and + * the action's default ``packages: tools platform-tools`` install (the "SDK + Tools" corrupt zip seen on a #388 preview shard) plus the emulator/platform + package installs. + +This module hardens both with **verify -> reject -> retry**, never trusting +sdkmanager's exit code alone: + + ``bootstrap`` Download the *pinned* Android command-line tools zip, verify it + against a pinned size + SHA-256, and install it to + ``$ANDROID_SDK_ROOT/cmdline-tools/`` -- the exact path + setup-android probes first, so the action reuses our verified + tree and never does its own unverified "Wrong version" + re-download. A size/hash mismatch (corrupt OR wrong version) + => delete the bad zip + any half-extracted dir => re-download + clean. Only a verified tree is ever left in place, so the + success-gated cache can never bake in a corrupt SDK. + + ``install`` Run ``sdkmanager --install `` with retry + backoff. + "Error reading Zip content from a SeekableByteChannel" is a + corrupt package zip, so on failure each requested package's dir + (and sdkmanager's temp/intermediate dirs) is PURGED before the + retry -- forcing a fresh re-download instead of a re-read of the + corrupt file. + +stdlib only (urllib/hashlib/zipfile/...), cross-platform, per the repo's "prefer +Python for dev/CI-helper scripts" rule. The pure helpers are unit-tested in +``test_setup_android_sdk.py`` (run by the ``traffic-control-tests`` job); the +full download/install path is validated by CI itself. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request +import zipfile + +# --- Pinned Android command-line tools (revision 20.0) -------------------- +# android-actions/setup-android v4.0.1 defaults to this same build (its +# getVersionShort() maps "14742923" -> "20.0"). We provision it OURSELVES, +# integrity-checked, into the path the action looks for first +# ($ANDROID_SDK_ROOT/cmdline-tools/20.0), so the action finds it, skips its own +# unverified download, and never prints "Wrong version in preinstalled +# sdkmanager". +# +# CLT_SIZE + the SHA-1 are Google's published values for this immutable, +# build-numbered zip (repository2-3.xml). CLT_SHA256 was computed locally from +# bytes that matched BOTH of Google's published values, so it is an authoritative +# integrity pin. A build-numbered URL is immutable, so these never drift; bumping +# the tools means bumping all four constants together. +CLT_VERSION_LONG = "14742923" +CLT_VERSION_SHORT = "20.0" +CLT_URL = ( + "https://dl.google.com/android/repository/" + f"commandlinetools-linux-{CLT_VERSION_LONG}_latest.zip" +) +CLT_SIZE = 172789259 +CLT_SHA256 = "04453066b540409d975c676d781da1477479dde3761310f1a7eb92a1dfb15af7" + +# Total tries (1 initial + retries). Backoff is linear: 10s, 20s, 30s ... +MAX_ATTEMPTS = 4 + + +def log(msg: str) -> None: + print(msg, flush=True) + + +def warn(msg: str) -> None: + print(f"::warning::{msg}", flush=True) + + +def error(msg: str) -> None: + print(f"::error::{msg}", flush=True) + + +def backoff_seconds(attempt: int) -> int: + """Linear backoff before the next attempt: 10s after attempt 1, 20s after 2...""" + return 10 * attempt + + +def sdk_root() -> str: + """The Android SDK root. GitHub-hosted runners preset ANDROID_SDK_ROOT / + ANDROID_HOME to /usr/local/lib/android/sdk; fall back to the SDK's default.""" + root = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME") + if not root: + root = os.path.join(os.path.expanduser("~"), ".android", "sdk") + return root + + +def sha256_of(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def verify_download(path, expected_size, expected_sha256): + """(ok, detail) for a downloaded file: size first (cheap), then SHA-256. + A mismatch means a corrupt/truncated download OR the wrong version -- both + must be rejected and re-fetched.""" + if not os.path.exists(path): + return False, "download missing" + actual_size = os.path.getsize(path) + if actual_size != expected_size: + return False, f"size {actual_size} != expected {expected_size}" + actual_sha = sha256_of(path) + if actual_sha != expected_sha256: + return False, f"sha256 {actual_sha} != expected {expected_sha256}" + return True, "ok" + + +def package_dir(root: str, package: str) -> str: + """On-disk dir for an sdkmanager package id. sdkmanager lays packages out by + turning the ';' separators into path separators, e.g. + 'platforms;android-37.0' -> /platforms/android-37.0, so this is exactly + the tree to purge to force a corrupt package to re-download.""" + return os.path.join(root, *package.split(";")) + + +def _rm(path: str) -> None: + """Best-effort recursive delete of a file or dir (reject a bad download).""" + if os.path.islink(path) or os.path.isfile(path): + try: + os.remove(path) + except FileNotFoundError: + pass + elif os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + + +def _extract_preserving_perms(zip_path: str, target_dir: str) -> None: + """Extract a zip, restoring the unix permission bits stored in each entry's + external attributes. ZipFile.extractall drops the executable bit, which would + leave bin/sdkmanager non-executable and break the action's `sdkmanager + --licenses`; Google's zip is unix-built, so external_attr carries the +x.""" + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + extracted = zf.extract(info, target_dir) + mode = (info.external_attr >> 16) & 0o7777 + if mode: + os.chmod(extracted, mode) + + +class _RejectAndRetry(Exception): + """Internal signal: discard this attempt's download and retry from scratch.""" + + +def bootstrap() -> int: + """Ensure $ANDROID_SDK_ROOT/cmdline-tools/ is a verified install.""" + root = sdk_root() + dest = os.path.join(root, "cmdline-tools", CLT_VERSION_SHORT) + sdkmanager = os.path.join(dest, "bin", "sdkmanager") + if os.path.exists(sdkmanager): + # Cache hit (or already provisioned): the cache is populated only after a + # passing integrity check, so a present tree is trusted -> no re-download. + log(f"cmdline-tools {CLT_VERSION_SHORT} already present at {dest} " + "(cache hit) -- skipping verified download") + return 0 + + tools_parent = os.path.join(root, "cmdline-tools") + os.makedirs(tools_parent, exist_ok=True) + for attempt in range(1, MAX_ATTEMPTS + 1): + log(f"::group::Download + verify cmdline-tools {CLT_VERSION_SHORT} " + f"(attempt {attempt}/{MAX_ATTEMPTS})") + tmp_zip = os.path.join(tempfile.gettempdir(), f"clt-{CLT_VERSION_LONG}.zip") + # Extract on the SAME filesystem as `dest` so the final move is an atomic + # rename that preserves the restored +x bit on bin/sdkmanager. + tmp_extract = tempfile.mkdtemp(prefix=".clt-extract-", dir=tools_parent) + _rm(tmp_zip) + try: + log(f"Downloading {CLT_URL}") + urllib.request.urlretrieve(CLT_URL, tmp_zip) # noqa: S310 (pinned https) + ok, detail = verify_download(tmp_zip, CLT_SIZE, CLT_SHA256) + if not ok: + warn(f"cmdline-tools integrity check failed: {detail} -- " + "rejecting the bad download and retrying clean") + raise _RejectAndRetry() + log(f"Integrity OK (size {CLT_SIZE}, sha256 {CLT_SHA256})") + _extract_preserving_perms(tmp_zip, tmp_extract) + unpacked = os.path.join(tmp_extract, "cmdline-tools") + if not os.path.isdir(unpacked): + warn("extracted zip has no top-level cmdline-tools/ dir -- retrying") + raise _RejectAndRetry() + _rm(dest) # drop any half-extracted leftover before moving the good tree + shutil.move(unpacked, dest) + # Mirror the action: touch repositories.cfg so sdkmanager is happy. + open(os.path.join(root, "repositories.cfg"), "a", encoding="utf-8").close() + if os.path.exists(sdkmanager): + log(f"Installed verified cmdline-tools to {dest}") + return 0 + warn("sdkmanager missing after extract -- retrying") + except _RejectAndRetry: + pass + except Exception as exc: # noqa: BLE001 - any transient error is retryable + warn(f"cmdline-tools bootstrap attempt {attempt} failed: {exc}") + finally: + _rm(tmp_zip) + _rm(tmp_extract) + log("::endgroup::") + if attempt < MAX_ATTEMPTS: + time.sleep(backoff_seconds(attempt)) + error(f"Failed to provision verified cmdline-tools after {MAX_ATTEMPTS} attempts") + return 1 + + +def find_sdkmanager(root: str): + """Locate sdkmanager: our pinned rev first, then the action's `latest`, then PATH.""" + candidates = [ + os.path.join(root, "cmdline-tools", CLT_VERSION_SHORT, "bin", "sdkmanager"), + os.path.join(root, "cmdline-tools", "latest", "bin", "sdkmanager"), + ] + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return shutil.which("sdkmanager") + + +def install(packages) -> int: + """`sdkmanager --install ` with retry + purge-on-corrupt-zip.""" + root = sdk_root() + sdkmanager = find_sdkmanager(root) + if not sdkmanager: + error("sdkmanager not found -- run the cmdline-tools bootstrap step first") + return 1 + # Feed 'y' repeatedly in case any license needs accepting (setup-android's + # --licenses runs first, but this keeps the step self-contained). + accept = ("y\n" * 32).encode() + for attempt in range(1, MAX_ATTEMPTS + 1): + log(f"::group::sdkmanager --install {' '.join(packages)} " + f"(attempt {attempt}/{MAX_ATTEMPTS})") + result = subprocess.run([sdkmanager, "--install", *packages], input=accept) + log("::endgroup::") + if result.returncode == 0: + log(f"Installed SDK packages: {' '.join(packages)}") + return 0 + warn(f"sdkmanager attempt {attempt} failed (exit {result.returncode}) -- " + "purging partial/corrupt packages before retry") + # REJECT: a corrupt package zip must be re-downloaded, not re-read. Purge + # each requested package's dir + sdkmanager's temp/intermediate dirs so + # the retry starts clean. + for pkg in packages: + _rm(package_dir(root, pkg)) + _rm(os.path.join(root, ".temp")) + _rm(os.path.join(root, ".downloadIntermediates")) + if attempt < MAX_ATTEMPTS: + time.sleep(backoff_seconds(attempt)) + error(f"sdkmanager failed to install {list(packages)} after {MAX_ATTEMPTS} attempts") + log("--- sdkmanager --list_installed ---") + subprocess.run([sdkmanager, "--list_installed"]) + return 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Hardened Android SDK setup for CI (issue #389)." + ) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser( + "bootstrap", + help="Download + SHA-256-verify the pinned Android command-line tools.", + ) + installer = sub.add_parser( + "install", + help="sdkmanager --install with retry + purge-on-corrupt-zip.", + ) + installer.add_argument("packages", nargs="+", help="sdkmanager package ids") + return parser + + +def main(argv) -> int: + args = build_parser().parse_args(argv) + if args.command == "bootstrap": + return bootstrap() + if args.command == "install": + return install(args.packages) + return 2 # pragma: no cover - argparse requires a subcommand + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/scripts/test_setup_android_sdk.py b/.github/scripts/test_setup_android_sdk.py new file mode 100644 index 0000000..57c0cdb --- /dev/null +++ b/.github/scripts/test_setup_android_sdk.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Unit tests for the pure helpers of setup_android_sdk.py (no network, no SDK). + +Covers the bits whose correctness is load-bearing for the hardening in #389: +the package-id -> purge-path mapping (a wrong mapping would purge the wrong dir), +the size/SHA-256 integrity gate (verify -> reject), the pinned-constant +self-consistency, the backoff schedule, sdkmanager discovery, and that extraction +restores the executable bit that sdkmanager needs. The full download/install path +is exercised by CI itself.""" + +from __future__ import annotations + +import hashlib +import os +import stat +import tempfile +import unittest +import zipfile + +import setup_android_sdk as sdk + + +class PackageDirTests(unittest.TestCase): + def test_semicolon_ids_map_to_nested_dirs(self): + root = os.path.join("opt", "sdk") + self.assertEqual( + sdk.package_dir(root, "platforms;android-37.0"), + os.path.join(root, "platforms", "android-37.0"), + ) + self.assertEqual( + sdk.package_dir(root, "build-tools;37.0.0"), + os.path.join(root, "build-tools", "37.0.0"), + ) + self.assertEqual( + sdk.package_dir(root, "system-images;android-37.0;google_apis_ps16k;x86_64"), + os.path.join(root, "system-images", "android-37.0", "google_apis_ps16k", "x86_64"), + ) + + def test_flat_ids_map_to_single_dir(self): + root = os.path.join("opt", "sdk") + self.assertEqual(sdk.package_dir(root, "emulator"), os.path.join(root, "emulator")) + self.assertEqual( + sdk.package_dir(root, "platform-tools"), os.path.join(root, "platform-tools") + ) + + def test_purge_target_stays_under_root(self): + # The purge path must never escape the SDK root (no absolute/`..` package ids). + root = os.path.abspath(os.path.join("opt", "sdk")) + target = os.path.abspath(sdk.package_dir(root, "platforms;android-37.0")) + self.assertTrue(target.startswith(root + os.sep)) + + +class VerifyDownloadTests(unittest.TestCase): + def _write(self, data: bytes) -> str: + fd, path = tempfile.mkstemp() + with os.fdopen(fd, "wb") as fh: + fh.write(data) + self.addCleanup(lambda: os.path.exists(path) and os.remove(path)) + return path + + def test_accepts_matching_size_and_hash(self): + data = b"correct-cmdline-tools-bytes" + path = self._write(data) + ok, detail = sdk.verify_download(path, len(data), hashlib.sha256(data).hexdigest()) + self.assertTrue(ok, detail) + self.assertEqual(detail, "ok") + + def test_rejects_wrong_size_before_hashing(self): + data = b"truncated" + path = self._write(data) + ok, detail = sdk.verify_download(path, len(data) + 1, hashlib.sha256(data).hexdigest()) + self.assertFalse(ok) + self.assertIn("size", detail) + + def test_rejects_corrupt_bytes_with_right_size(self): + good = b"aaaaaaaa" + corrupt = b"aaaaaaab" # same length, different content (silent corruption) + path = self._write(corrupt) + ok, detail = sdk.verify_download(path, len(good), hashlib.sha256(good).hexdigest()) + self.assertFalse(ok) + self.assertIn("sha256", detail) + + def test_rejects_missing_file(self): + ok, detail = sdk.verify_download( + os.path.join(tempfile.gettempdir(), "does-not-exist-clt.zip"), 1, "0" * 64 + ) + self.assertFalse(ok) + + +class PinnedConstantsTests(unittest.TestCase): + def test_url_embeds_the_pinned_build_number(self): + self.assertIn(sdk.CLT_VERSION_LONG, sdk.CLT_URL) + self.assertTrue(sdk.CLT_URL.startswith("https://")) + self.assertTrue(sdk.CLT_URL.endswith("_latest.zip")) + + def test_sha256_is_a_full_hex_digest(self): + self.assertEqual(len(sdk.CLT_SHA256), 64) + int(sdk.CLT_SHA256, 16) # raises if not hex + self.assertEqual(sdk.CLT_SHA256, sdk.CLT_SHA256.lower()) + + def test_size_is_positive(self): + self.assertGreater(sdk.CLT_SIZE, 0) + + +class BackoffTests(unittest.TestCase): + def test_backoff_is_linear_and_increasing(self): + seq = [sdk.backoff_seconds(a) for a in range(1, sdk.MAX_ATTEMPTS + 1)] + self.assertEqual(seq, [10, 20, 30, 40][: sdk.MAX_ATTEMPTS]) + self.assertEqual(seq, sorted(seq)) + + +class SdkRootTests(unittest.TestCase): + def test_prefers_android_sdk_root_over_home(self): + with _env(ANDROID_SDK_ROOT="/a/sdk-root", ANDROID_HOME="/b/home"): + self.assertEqual(sdk.sdk_root(), "/a/sdk-root") + + def test_falls_back_to_android_home(self): + with _env(ANDROID_SDK_ROOT=None, ANDROID_HOME="/b/home"): + self.assertEqual(sdk.sdk_root(), "/b/home") + + +class FindSdkManagerTests(unittest.TestCase): + def test_prefers_pinned_revision_dir(self): + with tempfile.TemporaryDirectory() as root: + pinned = os.path.join(root, "cmdline-tools", sdk.CLT_VERSION_SHORT, "bin") + latest = os.path.join(root, "cmdline-tools", "latest", "bin") + for d in (pinned, latest): + os.makedirs(d) + open(os.path.join(d, "sdkmanager"), "w").close() + self.assertEqual( + sdk.find_sdkmanager(root), + os.path.join(pinned, "sdkmanager"), + ) + + +class ExtractPermsTests(unittest.TestCase): + @unittest.skipUnless(os.name == "posix", "unix exec bit only meaningful on POSIX") + def test_executable_bit_is_restored(self): + with tempfile.TemporaryDirectory() as work: + zip_path = os.path.join(work, "clt.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + info = zipfile.ZipInfo("cmdline-tools/bin/sdkmanager") + info.external_attr = 0o755 << 16 # -rwxr-xr-x, as Google's zip stores it + zf.writestr(info, "#!/bin/sh\n") + out = os.path.join(work, "out") + sdk._extract_preserving_perms(zip_path, out) + mode = os.stat(os.path.join(out, "cmdline-tools", "bin", "sdkmanager")).st_mode + self.assertTrue(mode & stat.S_IXUSR, "sdkmanager must be executable after extract") + + +class _env: + """Context manager to set/clear env vars for a test, restoring them after.""" + + def __init__(self, **values): + self._values = values + self._saved = {} + + def __enter__(self): + for key, value in self._values.items(): + self._saved[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + return self + + def __exit__(self, *exc): + for key, previous in self._saved.items(): + if previous is None: + os.environ.pop(key, None) + else: + os.environ[key] = previous + return False + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c22280..1467be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,11 +88,57 @@ jobs: distribution: temurin java-version: "21" + - name: Restore Android SDK cache + id: android-sdk-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Cache the SHA-256-verified command-line tools + platform/build-tools so a + # green run doesn't re-download (and risk re-corrupting) them. Restore-only + # here + the success-gated save below == "integrity gates the cache": a + # corrupt/failed SDK is never saved (#389). Shared key (identical contents). + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + # Bump the prefix if ANDROID_PLATFORM/ANDROID_BUILD_TOOLS or the pinned + # cmdline-tools build (14742923) change. + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 + + # Provision the SHA-256-verified command-line tools into the exact path + # setup-android probes first, so the action reuses it and never does its own + # unverified "Wrong version in preinstalled sdkmanager" re-download (#389). + - name: Bootstrap verified Android command-line tools + run: python3 .github/scripts/setup_android_sdk.py bootstrap + - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + # Reuse the verified cmdline-tools bootstrapped above (matching version => + # no unverified re-download) and pass '' packages so the action does NOT run + # the flaky `sdkmanager tools platform-tools` install — the corrupt-zip + # surface that failed the "Set up Android SDK" step (#389). + cmdline-tools-version: "14742923" + packages: "" + # verify -> reject -> retry: a corrupt package zip ("Error reading Zip + # content ...") is purged and re-downloaded instead of failing on sdkmanager's + # bare exit 1 (#389; supersedes the inline retry loop from #387/#388). - name: Install SDK platform and build-tools - run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + run: python3 .github/scripts/setup_android_sdk.py install "platform-tools" "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + # Save the verified SDK only on success (never cache a corrupt SDK) and only on + # a miss (avoid redundant re-saves). + - name: Save Android SDK cache + if: success() && steps.android-sdk-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -120,11 +166,57 @@ jobs: distribution: temurin java-version: "21" + - name: Restore Android SDK cache + id: android-sdk-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Cache the SHA-256-verified command-line tools + platform/build-tools so a + # green run doesn't re-download (and risk re-corrupting) them. Restore-only + # here + the success-gated save below == "integrity gates the cache": a + # corrupt/failed SDK is never saved (#389). Shared key (identical contents). + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + # Bump the prefix if ANDROID_PLATFORM/ANDROID_BUILD_TOOLS or the pinned + # cmdline-tools build (14742923) change. + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 + + # Provision the SHA-256-verified command-line tools into the exact path + # setup-android probes first, so the action reuses it and never does its own + # unverified "Wrong version in preinstalled sdkmanager" re-download (#389). + - name: Bootstrap verified Android command-line tools + run: python3 .github/scripts/setup_android_sdk.py bootstrap + - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + # Reuse the verified cmdline-tools bootstrapped above (matching version => + # no unverified re-download) and pass '' packages so the action does NOT run + # the flaky `sdkmanager tools platform-tools` install — the corrupt-zip + # surface that failed the "Set up Android SDK" step (#389). + cmdline-tools-version: "14742923" + packages: "" + # verify -> reject -> retry: a corrupt package zip ("Error reading Zip + # content ...") is purged and re-downloaded instead of failing on sdkmanager's + # bare exit 1 (#389; supersedes the inline retry loop from #387/#388). - name: Install SDK platform and build-tools - run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + run: python3 .github/scripts/setup_android_sdk.py install "platform-tools" "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + # Save the verified SDK only on success (never cache a corrupt SDK) and only on + # a miss (avoid redundant re-saves). + - name: Save Android SDK cache + if: success() && steps.android-sdk-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -177,11 +269,57 @@ jobs: java-version: "21" # AGP configuration needs the SDK even for ktlint/detekt (they run on the :app module). + - name: Restore Android SDK cache + id: android-sdk-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Cache the SHA-256-verified command-line tools + platform/build-tools so a + # green run doesn't re-download (and risk re-corrupting) them. Restore-only + # here + the success-gated save below == "integrity gates the cache": a + # corrupt/failed SDK is never saved (#389). Shared key (identical contents). + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + # Bump the prefix if ANDROID_PLATFORM/ANDROID_BUILD_TOOLS or the pinned + # cmdline-tools build (14742923) change. + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 + + # Provision the SHA-256-verified command-line tools into the exact path + # setup-android probes first, so the action reuses it and never does its own + # unverified "Wrong version in preinstalled sdkmanager" re-download (#389). + - name: Bootstrap verified Android command-line tools + run: python3 .github/scripts/setup_android_sdk.py bootstrap + - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + # Reuse the verified cmdline-tools bootstrapped above (matching version => + # no unverified re-download) and pass '' packages so the action does NOT run + # the flaky `sdkmanager tools platform-tools` install — the corrupt-zip + # surface that failed the "Set up Android SDK" step (#389). + cmdline-tools-version: "14742923" + packages: "" + # verify -> reject -> retry: a corrupt package zip ("Error reading Zip + # content ...") is purged and re-downloaded instead of failing on sdkmanager's + # bare exit 1 (#389; supersedes the inline retry loop from #387/#388). - name: Install SDK platform and build-tools - run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + run: python3 .github/scripts/setup_android_sdk.py install "platform-tools" "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + # Save the verified SDK only on success (never cache a corrupt SDK) and only on + # a miss (avoid redundant re-saves). + - name: Save Android SDK cache + if: success() && steps.android-sdk-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -225,28 +363,61 @@ jobs: distribution: temurin java-version: "21" + - name: Restore Android SDK cache + id: android-sdk-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Cache the SHA-256-verified command-line tools + platform/build-tools so a + # green run doesn't re-download (and risk re-corrupting) them. Restore-only + # here + the success-gated save below == "integrity gates the cache": a + # corrupt/failed SDK is never saved (#389). Shared key (identical contents). + # The emulator + system image stay with android-emulator-runner (boot logic + # is out of scope for #389). + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + # Bump the prefix if ANDROID_PLATFORM/ANDROID_BUILD_TOOLS or the pinned + # cmdline-tools build (14742923) change. + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 + + # Provision the SHA-256-verified command-line tools into the exact path + # setup-android probes first, so the action reuses it and never does its own + # unverified "Wrong version in preinstalled sdkmanager" re-download (#389). + - name: Bootstrap verified Android command-line tools + run: python3 .github/scripts/setup_android_sdk.py bootstrap + - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + # Reuse the verified cmdline-tools bootstrapped above (matching version => + # no unverified re-download) and pass '' packages so the action does NOT run + # the flaky `sdkmanager tools platform-tools` install — the corrupt-zip + # surface that failed the "Set up Android SDK" step (#389). + cmdline-tools-version: "14742923" + packages: "" - # sdkmanager can exit 1 on a transient package-mirror/network hiccup with no useful trail — - # an `E2E (31)` leg died exactly this way (#387). Retry up to 3x with backoff so a transient - # failure self-heals, and on a hard failure print the installed-package list so the cause is - # visible in the step log instead of a bare exit 1. + # verify -> reject -> retry (#389, supersedes the #387/#388 inline loop): sdkmanager + # can exit 1 on a corrupt/truncated package zip ("Error reading Zip content ...") — an + # `E2E (31)` leg died this way. The helper purges each partial/corrupt package and + # re-downloads it clean, and on a hard failure prints the installed-package list so the + # cause is visible in the step log instead of a bare exit 1. - name: Install SDK platform and build-tools - run: | - for attempt in 1 2 3; do - echo "::group::sdkmanager install (attempt $attempt)" - if sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"; then - echo "::endgroup::" - exit 0 - fi - echo "::endgroup::" - echo "::warning::sdkmanager attempt $attempt failed to install $ANDROID_PLATFORM / $ANDROID_BUILD_TOOLS" - if [ "$attempt" -lt 3 ]; then sleep "$((attempt * 15))"; fi - done - echo "::error::sdkmanager failed to install the SDK packages after 3 attempts" - echo "--- sdkmanager --list_installed ---"; sdkmanager --list_installed 2>&1 || true - exit 1 + run: python3 .github/scripts/setup_android_sdk.py install "platform-tools" "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + # Save the verified SDK only on success (never cache a corrupt SDK) and only on + # a miss (avoid redundant re-saves). + - name: Save Android SDK cache + if: success() && steps.android-sdk-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -402,8 +573,39 @@ jobs: distribution: temurin java-version: "21" + - name: Restore Android SDK cache + id: android-sdk-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Cache the SHA-256-verified command-line tools + platform/build-tools so a + # green run doesn't re-download (and risk re-corrupting) them. Restore-only + # here + the success-gated save below == "integrity gates the cache": a + # corrupt/failed SDK is never saved (#389). The ~1 GB preview system image + # keeps its own cache below. + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + # Bump the prefix if ANDROID_PLATFORM/ANDROID_BUILD_TOOLS or the pinned + # cmdline-tools build (14742923) change. + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 + + # Provision the SHA-256-verified command-line tools into the exact path + # setup-android probes first, so the action reuses it and never does its own + # unverified "Wrong version in preinstalled sdkmanager" re-download (#389). + - name: Bootstrap verified Android command-line tools + run: python3 .github/scripts/setup_android_sdk.py bootstrap + - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + # Reuse the verified cmdline-tools bootstrapped above (matching version => + # no unverified re-download) and pass '' packages so the action does NOT run + # the flaky `sdkmanager tools platform-tools` install — the corrupt-zip + # surface that failed the "Set up Android SDK" step (#389). + cmdline-tools-version: "14742923" + packages: "" - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 @@ -425,8 +627,25 @@ jobs: path: /usr/local/lib/android/sdk/system-images/android-37.0 key: sysimg-android-37.0-google_apis_ps16k-x86_64 + # verify -> reject -> retry (#389): the preview emulator + 16 KB-page system image + # download here; a corrupt/truncated package zip ("Error reading Zip content ...") is + # purged and re-downloaded clean instead of failing on sdkmanager's bare exit 1. - name: Install SDK packages + preview system image - run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" "platform-tools" "emulator" "$API37_IMAGE" + run: python3 .github/scripts/setup_android_sdk.py install "platform-tools" "emulator" "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" "$API37_IMAGE" + + # Save the verified command-line tools + platform/build-tools only on success and only + # on a miss. The ~1 GB system image keeps its own cache above; the emulator re-downloads + # (self-healing via the retry) to keep this shared key small. + - name: Save Android SDK cache + if: success() && steps.android-sdk-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /usr/local/lib/android/sdk/cmdline-tools/20.0 + /usr/local/lib/android/sdk/platforms + /usr/local/lib/android/sdk/build-tools + /usr/local/lib/android/sdk/platform-tools + key: android-sdk-v1-${{ runner.os }}-clt14742923-plat37.0-bt37.0.0 - name: Create API 37 AVD run: |