Merge branch 'main' into fix-359-sqlcipher-16kb
This commit is contained in:
@@ -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/<rev>`` -- 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 <packages>`` 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' -> <root>/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/<rev> 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 <packages>` 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:]))
|
||||
@@ -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()
|
||||
+241
-22
@@ -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: |
|
||||
|
||||
+72
-2
@@ -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-<version>.jar) — exactly what Robolectric's
|
||||
// LocalDependencyResolver looks up as <artifactId>-<version>.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<Sync>("syncRobolectricAndroidAll") {
|
||||
description = "Stages Robolectric's android-all-instrumented jar for offline resolution (issue #373)."
|
||||
from(robolectricAndroidAll)
|
||||
into(robolectricDepsDir)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user