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: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 946b482..829b32c 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*", @@ -408,6 +421,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) @@ -468,6 +520,24 @@ 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) + // 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) + 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..baf07e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,22 @@ 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" +# 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). @@ -115,6 +131,11 @@ 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" } +# 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" }