Add Rust distribution auditing, pinned post-build AAB signing, complete offline third-party notices, an Android privacy policy and reviewable Play listing assets. Preserve the application ID and audit both ARM ABIs, native symbols, baseline profiles and R8 mapping. Fix dialog focus ownership, navigation-triggered cleanup loops and stale authentication errors after first-run setup, with native and real-activity regressions. Google Play publication is deferred by the user. Issue #112 remains open for production signing, Console declarations and actual Play install/update verification.
732 lines
40 KiB
Python
732 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""Build-only orchestration; never shipped or invoked by the Android application."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import runpy
|
|
import shutil
|
|
import tarfile
|
|
import urllib.request
|
|
import zipfile
|
|
import xml.etree.ElementTree as ET
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
ABIS = ("arm64-v8a", "armeabi-v7a")
|
|
MODULES = ("app", "core", "features", "platform", "rust-bridge", "provider-client")
|
|
SDK = Path(os.environ.get("ANDROID_HOME", os.environ.get("ANDROID_SDK_ROOT", "")))
|
|
CARGO = os.environ.get("CARGO", "cargo")
|
|
|
|
|
|
def run(*args, capture=False, **kwargs):
|
|
return subprocess.run([str(a) for a in args], check=True, text=True,
|
|
stdout=subprocess.PIPE if capture else None, **kwargs)
|
|
|
|
|
|
def tool(*args):
|
|
return run(CARGO, "+1.97.1", "run", "--locked", "-p", "ironstorage-android-build", "--", *args)
|
|
|
|
|
|
def download(spec, path):
|
|
if not path.exists():
|
|
with urllib.request.urlopen(spec["url"], timeout=60) as response, path.open("wb") as output:
|
|
shutil.copyfileobj(response, output)
|
|
if hashlib.sha256(path.read_bytes()).hexdigest() != spec["sha256"]:
|
|
raise RuntimeError("CI toolchain checksum mismatch")
|
|
|
|
|
|
def prepare_linux():
|
|
if platform.system() != "Linux" or platform.machine() not in ("aarch64", "arm64"):
|
|
raise RuntimeError("Native CI toolchains require Linux ARM64")
|
|
SDK.mkdir(parents=True, exist_ok=True)
|
|
policy = json.loads((ROOT / "android/ci-toolchains.json").read_text())
|
|
with tempfile.TemporaryDirectory(prefix="ironstorage-tools-") as directory:
|
|
directory = Path(directory)
|
|
for key, spec in policy.items():
|
|
download(spec, directory / key)
|
|
cli = SDK / "cmdline-tools/latest"
|
|
if not cli.exists():
|
|
destination = SDK / "cmdline-tools"
|
|
with zipfile.ZipFile(directory / "sdk_cli") as archive:
|
|
archive.extractall(destination)
|
|
(destination / "cmdline-tools").rename(cli)
|
|
for path in (cli / "bin").iterdir():
|
|
path.chmod(0o755)
|
|
ndk = SDK / "ndk/30.0.16248370"
|
|
if not ndk.exists():
|
|
destination = SDK / "ndk"
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
with tarfile.open(directory / "ndk") as archive:
|
|
archive.extractall(destination, filter="data")
|
|
(destination / "android-ndk-r30").rename(ndk)
|
|
prebuilt = ndk / "toolchains/llvm/prebuilt"
|
|
alias = prebuilt / "linux-x86_64"
|
|
# cargo-ndk 4.1.2 hardcodes this directory name on Linux; binaries remain native ARM64.
|
|
if not alias.exists():
|
|
alias.symlink_to("linux-arm64", target_is_directory=True)
|
|
native = SDK / "native-arm64"
|
|
if not native.exists():
|
|
with zipfile.ZipFile(directory / "sdk_native") as archive:
|
|
archive.extractall(native)
|
|
for path in native.rglob("*"):
|
|
if path.is_file():
|
|
path.chmod(0o755)
|
|
run(cli / "bin/sdkmanager", "--licenses", input="y\n" * 100)
|
|
run(cli / "bin/sdkmanager", "platforms;android-37.0", "build-tools;36.0.0")
|
|
run(prebuilt / "linux-arm64/bin/clang", "--version")
|
|
run(prebuilt / "linux-arm64/bin/ld.lld", "--version")
|
|
run(native / "build-tools/aapt2", "version")
|
|
|
|
|
|
def gradle(*args):
|
|
options = [ROOT / "android/gradlew", "-p", ROOT / "android", "--no-configuration-cache", "--console=plain"]
|
|
if os.environ.get("CI") == "true":
|
|
options.extend(("--no-daemon", "-Pkotlin.compiler.execution.strategy=in-process"))
|
|
if platform.system() == "Linux" and platform.machine() in ("aarch64", "arm64"):
|
|
options.append("-Pandroid.aapt2FromMavenOverride=" + str(ROOT / ".gitea/scripts/native-arm64/aapt2"))
|
|
return run(*options, *args, cwd=ROOT)
|
|
|
|
|
|
def verification_phases():
|
|
return (
|
|
tuple(f":{module}:testDebugUnitTest" for module in MODULES),
|
|
(":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease"),
|
|
*((f":{module}:assembleDebugAndroidTest",) for module in ("app", "core", "rust-bridge", "platform")),
|
|
*((f":{module}:ktlintCheck", f":{module}:lintDebug") for module in MODULES),
|
|
(":app:assembleBenchmark", ":app:assembleProfile", ":performance:assembleBenchmark", ":performance:ktlintCheck"),
|
|
("androidVerification",),
|
|
)
|
|
|
|
|
|
def resources():
|
|
facts = {"available_disk_bytes": shutil.disk_usage(ROOT).free}
|
|
memory = Path("/proc/meminfo")
|
|
if memory.exists():
|
|
facts["memory_kib"] = {key: int(value.split()[0]) for line in memory.read_text().splitlines()
|
|
for key, value in [line.split(":", 1)]
|
|
if key in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree")}
|
|
for name in ("memory.current", "memory.max", "memory.swap.max", "memory.events"):
|
|
path = Path("/sys/fs/cgroup") / name
|
|
if path.exists():
|
|
facts[name] = path.read_text().strip()
|
|
print("CI resource facts " + json.dumps(facts, sort_keys=True), flush=True)
|
|
|
|
|
|
def select_symbols(symbols, output):
|
|
allowed = {f"{abi}/{lib}.dbg" for abi in ABIS for lib in ("libironstorage_android.so", "libjnidispatch.so")}
|
|
seen = set()
|
|
with zipfile.ZipFile(symbols) as source, zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as destination:
|
|
for name in source.namelist():
|
|
if name.split("/", 1)[0] in ABIS:
|
|
if name not in allowed or name in seen:
|
|
raise RuntimeError("Unreviewed or duplicate native debug symbol path")
|
|
seen.add(name)
|
|
with source.open(name) as original, destination.open(name, "w", force_zip64=True) as target:
|
|
shutil.copyfileobj(original, target)
|
|
if seen != allowed:
|
|
raise RuntimeError("Supported ARM debug symbols missing")
|
|
|
|
|
|
def checksum_artifacts(output):
|
|
checksums = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(output.iterdir()) if path.is_file() and path.name != "sha256.json"}
|
|
(output / "sha256.json").write_text(json.dumps(checksums, indent=2) + "\n")
|
|
|
|
|
|
def upload_signing():
|
|
names = ("STORE_FILE", "STORE_PASSWORD", "KEY_ALIAS", "KEY_PASSWORD", "CERT_SHA256")
|
|
signing = {name: os.environ.get("IRONSTORAGE_UPLOAD_" + name, "") for name in names}
|
|
if not all(signing.values()) or not re.fullmatch(r"[0-9a-fA-F]{64}", signing["CERT_SHA256"]):
|
|
raise RuntimeError("Distribution requires all five IRONSTORAGE_UPLOAD variables and a pinned SHA-256 certificate")
|
|
store = Path(signing["STORE_FILE"]).resolve(strict=True)
|
|
if store.is_relative_to(ROOT) or not store.is_file():
|
|
raise RuntimeError("Upload keystore must be a file outside the repository")
|
|
certificate = subprocess.run(["keytool", "-exportcert", "-keystore", str(store),
|
|
"-alias", signing["KEY_ALIAS"], "-storepass:env", "IRONSTORAGE_UPLOAD_STORE_PASSWORD"],
|
|
check=True, stdout=subprocess.PIPE).stdout
|
|
if hashlib.sha256(certificate).hexdigest() != signing["CERT_SHA256"].lower():
|
|
raise RuntimeError("Upload certificate differs from the independently pinned certificate")
|
|
return signing, store
|
|
|
|
|
|
def distribution():
|
|
"""Build/sign/audit locally or on a trusted runner; never publishes to Play."""
|
|
signing, store = upload_signing()
|
|
public_env = {key: value for key, value in os.environ.items() if not key.startswith("IRONSTORAGE_UPLOAD_")}
|
|
run("python3", ROOT / ".gitea/scripts/android_ci.py", "build", env=public_env)
|
|
source = ROOT / "android/build/ci-artifacts"
|
|
# Only distribution artifacts go to the release archive, never debug/test APKs.
|
|
output = ROOT / "android/build/distribution"
|
|
output.mkdir(parents=True, exist_ok=False)
|
|
try:
|
|
for name in ("mapping.txt", "native-debug-symbols.zip", "sbom.cdx.json", "third-party-notices.txt"):
|
|
shutil.copy2(source / name, output / name)
|
|
bundle = output / "app-release.aab"
|
|
run("jarsigner", "-keystore", store, "-storepass:env", "IRONSTORAGE_UPLOAD_STORE_PASSWORD",
|
|
"-keypass:env", "IRONSTORAGE_UPLOAD_KEY_PASSWORD", "-digestalg", "SHA-256",
|
|
"-signedjar", bundle, source / "app-release.aab", signing["KEY_ALIAS"])
|
|
run("jarsigner", "-verify", "-strict", "-keystore", store,
|
|
"-storepass:env", "IRONSTORAGE_UPLOAD_STORE_PASSWORD", bundle, signing["KEY_ALIAS"])
|
|
jar = ROOT / "android/build/bundletool.jar"
|
|
download(json.loads((ROOT / "android/bundletool.json").read_text()), jar)
|
|
run("java", "-jar", jar, "validate", "--bundle=" + str(bundle), env=public_env)
|
|
manifest = output / "bundle-manifest.xml"
|
|
manifest.write_text(run("java", "-jar", jar, "dump", "manifest", "--bundle=" + str(bundle), capture=True, env=public_env).stdout)
|
|
run(CARGO, "+1.97.1", "run", "--locked", "-p", "ironstorage-android-build", "--", "audit-distribution",
|
|
ROOT / "android", manifest, bundle, output / "mapping.txt",
|
|
output / "native-debug-symbols.zip", output / "third-party-notices.txt", output / "distribution.json", env=public_env)
|
|
(output / "upload-certificate-sha256.txt").write_text(signing["CERT_SHA256"].lower() + "\n")
|
|
checksum_artifacts(output)
|
|
except BaseException:
|
|
shutil.rmtree(output)
|
|
raise
|
|
|
|
|
|
def backup_references(manifest, resource_table):
|
|
application = ET.fromstring(manifest).find("application")
|
|
if application is None:
|
|
raise RuntimeError("Packaged application missing")
|
|
ids = []
|
|
for attribute, name in (("fullBackupContent", "backup_rules"), ("dataExtractionRules", "data_extraction_rules")):
|
|
values = re.findall(r"(?m)^\s*resource (0x[0-9a-f]{8}) xml/" + name + r"(?:\s|$)", resource_table)
|
|
if len(values) != 1:
|
|
raise RuntimeError("Backup resource symbol missing or ambiguous")
|
|
if application.get("{http://schemas.android.com/apk/res/android}" + attribute) != "@ref/" + values[0]:
|
|
raise RuntimeError("Packaged manifest selects an unreviewed backup resource")
|
|
ids.extend(values)
|
|
if len(set(ids)) != 2:
|
|
raise RuntimeError("Backup resource symbols overlap")
|
|
|
|
|
|
def clear_native_intermediates(target):
|
|
paths = [target / triple for triple in ("aarch64-linux-android", "armv7-linux-androideabi")]
|
|
if target.resolve() != target.absolute() or any(path.is_symlink() or (path.exists() and not path.is_dir()) for path in paths):
|
|
raise RuntimeError("Native scratch path must not contain symlinks or unexpected files")
|
|
for path in paths:
|
|
if path.exists():
|
|
shutil.rmtree(path)
|
|
|
|
|
|
def clean_ci(root=ROOT):
|
|
if os.environ.get("CI") != "true":
|
|
raise RuntimeError("Build cleanup is restricted to CI checkouts")
|
|
paths = [root / "target", root / "android/build"]
|
|
paths.extend(root / "android" / module / "build" for module in (*MODULES, "performance"))
|
|
if any(path.resolve() != path or (path.exists() and not path.is_dir()) for path in paths):
|
|
raise RuntimeError("CI build directories must not contain symlinks or unexpected files")
|
|
for path in paths:
|
|
if path.exists():
|
|
shutil.rmtree(path)
|
|
|
|
|
|
def build():
|
|
ci = os.environ.get("CI") == "true"
|
|
scratch = ROOT / "android/build/native-cargo"
|
|
if ci:
|
|
resources()
|
|
if scratch.resolve() != scratch:
|
|
raise RuntimeError("CI Cargo scratch directory must not contain symlinks")
|
|
os.environ["CARGO_TARGET_DIR"] = str(scratch)
|
|
# Resolve/check the distribution before spending time compiling the host tool.
|
|
gradle("--version")
|
|
# UniFFI generation is memory-heavy; compile host tools before Gradle build JVMs.
|
|
run(CARGO, "+1.97.1", "build", "--locked", "-p", "ironstorage-android-build", cwd=ROOT)
|
|
if ci:
|
|
for variant in ("Debug", "Release"):
|
|
gradle(f":rust-bridge:prepare{variant}RustBindings")
|
|
clear_native_intermediates(scratch)
|
|
if ci:
|
|
# Release retained JVM/compiler state between memory-heavy build phases.
|
|
for phase in verification_phases():
|
|
print("CI Gradle phase " + " ".join(phase), flush=True)
|
|
try:
|
|
gradle(*phase)
|
|
finally:
|
|
resources()
|
|
else:
|
|
gradle("androidVerification")
|
|
output = ROOT / "android/build/ci-artifacts"
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
apk = ROOT / "android/app/build/outputs/apk/release/app-release-unsigned.apk"
|
|
symbols = ROOT / "android/app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip"
|
|
selected_symbols = output / "native-debug-symbols.zip"
|
|
select_symbols(symbols, selected_symbols)
|
|
tool("audit-release", apk, selected_symbols)
|
|
analyzer = SDK / "cmdline-tools/latest/bin/apkanalyzer"
|
|
aapt2 = ROOT / ".gitea/scripts/native-arm64/aapt2" if platform.system() == "Linux" else SDK / "build-tools/36.0.0/aapt2"
|
|
for variant, path in [("debug", ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"), ("release", apk)]:
|
|
manifest = output / (variant + "-manifest.xml")
|
|
manifest.write_text(run(analyzer, "manifest", "print", path, capture=True).stdout)
|
|
backup_references(manifest.read_text(), run(aapt2, "dump", "resources", path, capture=True).stdout)
|
|
rules = []
|
|
for name in ("backup_rules", "data_extraction_rules"):
|
|
resource = run(analyzer, "resources", "value", "--type", "xml", "--config", "default", "--name", name, path, capture=True).stdout.strip()
|
|
decoded = output / f"{variant}-{name}.xml"
|
|
decoded.write_text(run(analyzer, "resources", "xml", "--file", resource, path, capture=True).stdout)
|
|
rules.append(decoded)
|
|
tool("audit-manifest", manifest, *rules)
|
|
if variant == "release":
|
|
tool("audit-release-manifest", manifest)
|
|
trees = []
|
|
for target in ("aarch64-linux-android", "armv7-linux-androideabi"):
|
|
trees.append(run(CARGO, "+1.97.1", "tree", "--locked", "-p", "ironstorage-android", "--target", target, "--edges", "normal", "--prefix", "none", "--format", "{p}|{l}|{r}", capture=True, cwd=ROOT).stdout)
|
|
tree = ROOT / "android/build/rust-dependencies.txt"
|
|
tree.write_text("\n".join(trees))
|
|
inventory = ROOT / "android/build/reports/android-dependencies.json"
|
|
rows = []
|
|
for module in MODULES:
|
|
rows.extend(json.loads((ROOT / "android" / module / "build/reports/android-dependencies.json").read_text()))
|
|
inventory.parent.mkdir(parents=True, exist_ok=True)
|
|
inventory.write_text(json.dumps(rows, sort_keys=True) + "\n")
|
|
tool("sbom", inventory, tree, ROOT / "Cargo.lock", ROOT / "android/dependency-policy.json", output / "sbom.cdx.json")
|
|
for module in MODULES:
|
|
reports = ROOT / "android" / module / "build/test-results"
|
|
if module in ("app", "core") or list(reports.rglob("TEST-*.xml")):
|
|
tool("safe-reports", reports, output / (module + "-unit-tests.json"))
|
|
files = [ROOT / "android/app/build/outputs/apk/debug/app-debug.apk", apk,
|
|
ROOT / "android/app/build/outputs/bundle/release/app-release.aab",
|
|
ROOT / "android/app/build/outputs/mapping/release/mapping.txt",
|
|
ROOT / "android/app/build/generated/notices/assets/third-party-notices.txt"]
|
|
for path in files:
|
|
shutil.copy2(path, output / path.name)
|
|
checksum_artifacts(output)
|
|
|
|
|
|
PERFORMANCE_BUDGETS = {
|
|
"editor_100_updates_ms": 500,
|
|
"editor_update_p95_us": 32000,
|
|
"cache_rebuild_ms": 15000,
|
|
"qr_analysis_ms": 5000,
|
|
"totp_cached_open_p95_us": 1000000,
|
|
"totp_warm_refresh_p95_us": 3000000,
|
|
"provider_prepare_p95_us": 1000000,
|
|
"provider_authenticated_fill_ms": 5000,
|
|
"tree_10000_navigation_ms": 3000,
|
|
"tree_10000_search_ms": 5000,
|
|
}
|
|
|
|
|
|
def performance_metrics(result):
|
|
values = {}
|
|
for name, number in re.findall(r"^INSTRUMENTATION_STATUS: ironstorage_([a-z0-9_]+)=([0-9]{1,12})$", result, re.MULTILINE):
|
|
if name in PERFORMANCE_BUDGETS:
|
|
if name in values:
|
|
raise RuntimeError("Duplicate performance measurement")
|
|
values[name] = int(number)
|
|
if values.keys() != PERFORMANCE_BUDGETS.keys():
|
|
raise RuntimeError("Missing required journey measurements")
|
|
if any(values[name] >= bound for name, bound in PERFORMANCE_BUDGETS.items()):
|
|
raise RuntimeError("Native journey exceeded its performance budget")
|
|
return values
|
|
|
|
|
|
def emulator_ready(user_state, power_state, trust_state):
|
|
return (user_state == "RUNNING_UNLOCKED" and
|
|
re.search(r"^\s*mWakefulness=Awake$", power_state, re.MULTILINE) is not None and
|
|
re.search(r'^\s*User .*\(id=0,.*deviceLocked=(?:0|false)\b', trust_state, re.MULTILINE) is not None)
|
|
|
|
|
|
def instrumentation(command, package, arguments=(), target=None):
|
|
try:
|
|
return command("shell", "am", "instrument", "-w", "-r", *arguments,
|
|
package + "/androidx.test.runner.AndroidJUnitRunner")
|
|
finally:
|
|
try:
|
|
command("shell", "am", "force-stop", package)
|
|
finally:
|
|
if target is not None and target != package:
|
|
command("shell", "am", "force-stop", target)
|
|
|
|
|
|
def emulator(serial):
|
|
adb = SDK / "platform-tools/adb"
|
|
def command(*args):
|
|
return run(adb, "-s", serial, *args, capture=True, timeout=900).stdout.strip()
|
|
abi = command("shell", "getprop", "ro.product.cpu.abi")
|
|
if abi not in ABIS:
|
|
raise RuntimeError("Only ARM emulators are in scope")
|
|
if command("shell", "getprop", "ro.kernel.qemu") != "1":
|
|
raise RuntimeError("Physical devices are outside validation scope")
|
|
if not emulator_ready(command("shell", "am", "get-started-user-state", "0"),
|
|
command("shell", "dumpsys", "power"),
|
|
command("shell", "dumpsys", "trust")):
|
|
raise RuntimeError("Wake and unlock the Android emulator before starting instrumentation")
|
|
api = int(command("shell", "getprop", "ro.build.version.sdk"))
|
|
try:
|
|
pages = int(command("shell", "getconf", "PAGE_SIZE"))
|
|
except subprocess.CalledProcessError:
|
|
sizes = {int(line.split()[1]) * 1024 for line in command("shell", "cat", "/proc/self/smaps").splitlines() if line.startswith("KernelPageSize:")}
|
|
if len(sizes) != 1:
|
|
raise RuntimeError("Cannot determine emulator kernel page size")
|
|
pages = sizes.pop()
|
|
if pages == 16384:
|
|
command("shell", "setprop", "bionic.linker.16kb.app_compat.enabled", "false")
|
|
command("shell", "setprop", "pm.16kb.app_compat.disabled", "true")
|
|
apk = ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"
|
|
command("install", "-r", apk)
|
|
# Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation.
|
|
suites = [("app", "de.rfc1437.ironstorage.test", None),
|
|
("core", "de.rfc1437.ironstorage.core.test", None),
|
|
("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None),
|
|
("platform", "de.rfc1437.ironstorage.platform.test",
|
|
"de.rfc1437.ironstorage.platform.AndroidQrCameraTest,"
|
|
"de.rfc1437.ironstorage.platform.AndroidKeystoreTest#deviceUnlockedRoundtripReplaceDeleteAndTamper,"
|
|
"de.rfc1437.ironstorage.platform.AndroidKeystoreTest#unsupportedUnlockedDeviceFlagDoesNotCreateAKey")]
|
|
native_timings = []
|
|
journey_timings = {}
|
|
for module, package, classes in suites:
|
|
test_apk = next((ROOT / "android" / module / "build/outputs/apk/androidTest/debug").glob("*.apk"))
|
|
command("install", "-r", test_apk)
|
|
if module == "rust-bridge":
|
|
for _ in range(5):
|
|
command("shell", "am", "force-stop", package)
|
|
measured = instrumentation(command, package, ("-e", "class", "de.rfc1437.ironstorage.bridge.NativeLoadPerformanceTest"))
|
|
samples = [int(line.split("=", 1)[1]) for line in measured.splitlines() if line.startswith("INSTRUMENTATION_STATUS: ironstorage_native_load_ns=")]
|
|
if len(samples) != 1 or "OK (1 test)" not in measured:
|
|
raise RuntimeError("Native cold-load measurement failed")
|
|
native_timings.extend(samples)
|
|
camera_granted = None
|
|
if module == "platform":
|
|
camera_granted = "android.permission.CAMERA: granted=true" in command("shell", "dumpsys", "package", package)
|
|
if not camera_granted:
|
|
command("shell", "pm", "grant", package, "android.permission.CAMERA")
|
|
arguments = ["-e", "class", classes] if classes else []
|
|
print(f"ARM API {api}, {pages}-byte pages: invoking {module} instrumentation", flush=True)
|
|
try:
|
|
result = instrumentation(command, package, arguments,
|
|
"de.rfc1437.ironstorage" if module == "app" else package)
|
|
finally:
|
|
if camera_granted is False:
|
|
command("shell", "pm", "revoke", package, "android.permission.CAMERA")
|
|
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
|
|
local = ROOT / "android/build/local-validation"
|
|
local.mkdir(parents=True, exist_ok=True)
|
|
local.chmod(0o700)
|
|
raw = local / f"{serial}-{module}-failure.txt"
|
|
raw.write_text(result)
|
|
raw.chmod(0o600)
|
|
raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)")
|
|
if module == "core":
|
|
journey_timings = performance_metrics(result)
|
|
print(f"ARM API {api}, {pages}-byte pages: {module} instrumentation passed", flush=True)
|
|
# No activity state or application data is deleted between process recreation runs.
|
|
timings = []
|
|
for _ in range(5):
|
|
command("shell", "am", "force-stop", "de.rfc1437.ironstorage")
|
|
result = command("shell", "am", "start", "-W", "-n", "de.rfc1437.ironstorage/.MainActivity")
|
|
values = dict(line.split(":", 1) for line in result.splitlines() if ":" in line)
|
|
if values.get("Status", "").strip() != "ok":
|
|
raise RuntimeError("Cold start failed")
|
|
timings.append(int(values["TotalTime"].strip()))
|
|
output = ROOT / "android/build/ci-artifacts"
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
(output / f"emulator-api{api}-{pages}.json").write_text(json.dumps({"api": api, "abi": abi, "page_size": pages, "cold_start_ms": timings, "native_load_ns": native_timings, "journey_timings": journey_timings, "instrumentation": "passed"}, indent=2) + "\n")
|
|
checksum_artifacts(output)
|
|
print(f"ARM API {api}, {pages}-byte pages: instrumentation and five cold starts passed")
|
|
|
|
|
|
def startup_metrics(data):
|
|
values = {}
|
|
for row in data["benchmarks"]:
|
|
mode = {"coldStart": "cold", "warmStart": "warm"}.get(row["name"])
|
|
if mode is None:
|
|
continue
|
|
if mode in values or row["repeatIterations"] != 5:
|
|
raise RuntimeError("Startup measurements must have five isolated iterations")
|
|
samples = {}
|
|
for metric, key in [("timeToInitialDisplayMs", "initial_display_ms"),
|
|
("timeToFullDisplayMs", "full_display_ms")]:
|
|
runs = row["metrics"][metric]["runs"]
|
|
budget = 5000 if mode == "cold" else 3000
|
|
if len(runs) != 5 or any(type(value) not in (int, float) or
|
|
not math.isfinite(value) or value <= 0 or value >= budget for value in runs):
|
|
raise RuntimeError("Incomplete or over-budget startup measurement")
|
|
samples[key] = runs
|
|
values[mode] = samples
|
|
if values.keys() != {"cold", "warm"}:
|
|
raise RuntimeError("Both cold and warm startup measurements are required")
|
|
return values
|
|
|
|
|
|
def performance(serial, profiles=False):
|
|
adb = SDK / "platform-tools/adb"
|
|
def command(*args):
|
|
return run(adb, "-s", serial, *args, capture=True, timeout=900).stdout.strip()
|
|
abi = command("shell", "getprop", "ro.product.cpu.abi")
|
|
api = int(command("shell", "getprop", "ro.build.version.sdk"))
|
|
if abi not in ABIS or command("shell", "getprop", "ro.kernel.qemu") != "1":
|
|
raise RuntimeError("Performance validation requires an ARM emulator")
|
|
if not emulator_ready(command("shell", "am", "get-started-user-state", "0"),
|
|
command("shell", "dumpsys", "power"),
|
|
command("shell", "dumpsys", "trust")):
|
|
raise RuntimeError("Wake and unlock the Android emulator before performance validation")
|
|
if profiles and api < 34:
|
|
raise RuntimeError("Data-preserving baseline generation requires API 34 or newer")
|
|
variant = "profile" if profiles else "benchmark"
|
|
gradle(":app:assemble" + variant.capitalize(), ":performance:assembleBenchmark")
|
|
command("install", "-r", ROOT / f"android/app/build/outputs/apk/{variant}/app-{variant}.apk")
|
|
harness = "de.rfc1437.ironstorage.performance"
|
|
command("install", "-r", "-t", ROOT / "android/performance/build/outputs/apk/benchmark/performance-benchmark.apk")
|
|
directory = "/sdcard/Android/media/" + harness + "/additional_test_output"
|
|
command("shell", "mkdir", "-p", directory)
|
|
test = "BaselineProfile" if profiles else "StartupBenchmark"
|
|
print(f"ARM API {api}: invoking {test} instrumentation", flush=True)
|
|
result = instrumentation(command, harness, ("-e", "class", harness + "." + test,
|
|
"-e", "additionalTestOutputDir", directory,
|
|
"-e", "androidx.benchmark.suppressErrors", "EMULATOR"),
|
|
"de.rfc1437.ironstorage")
|
|
local = ROOT / "android/build/local-validation" / ("performance-" + serial)
|
|
local.mkdir(parents=True, exist_ok=True)
|
|
local.chmod(0o700)
|
|
(local / (test + ".txt")).write_text(result)
|
|
(local / (test + ".txt")).chmod(0o600)
|
|
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
|
|
raise RuntimeError("Performance instrumentation failed; raw output stays local")
|
|
command("pull", directory, local)
|
|
if not profiles:
|
|
data = json.loads((local / "additional_test_output" / (harness + "-benchmarkData.json")).read_text())
|
|
metrics = startup_metrics(data)
|
|
output = ROOT / "android/build/ci-artifacts"
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
(output / f"startup-api{api}-{abi}.json").write_text(json.dumps({"api": api, "abi": abi, "startup": metrics}, allow_nan=False, indent=2) + "\n")
|
|
checksum_artifacts(output)
|
|
print(f"ARM API {api}: {test} passed; inspect local artifacts before promoting numeric metrics or generated profiles")
|
|
|
|
|
|
class SelfTest(unittest.TestCase):
|
|
def test_distribution_does_not_pass_upload_secrets_to_the_build(self):
|
|
with patch.dict(os.environ, {"IRONSTORAGE_UPLOAD_STORE_PASSWORD": "public-fixture-password"}), \
|
|
patch(__name__ + ".upload_signing", return_value=({}, Path("/tmp/fixture.p12"))), \
|
|
patch(__name__ + ".run", side_effect=RuntimeError("stop before build")) as build:
|
|
with self.assertRaisesRegex(RuntimeError, "stop before build"):
|
|
distribution()
|
|
build.assert_called_once()
|
|
self.assertFalse(any(key.startswith("IRONSTORAGE_UPLOAD_") for key in build.call_args.kwargs["env"]))
|
|
self.assertIn("PATH", build.call_args.kwargs["env"])
|
|
|
|
def test_notices_reject_uncovered_rust_crates_and_unreviewed_android_terms(self):
|
|
from android_notices import android_notices, rust_notices
|
|
data = {"licenses": [{"name": "MIT", "text": "public license fixture", "used_by": [
|
|
{"crate": {"name": "fixture", "version": "1", "authors": ["Public Author"], "repository": "https://example.test/source"}}
|
|
]}]}
|
|
text = rust_notices(data, {("fixture", "1")})
|
|
self.assertIn("Public Author", text)
|
|
self.assertIn("https://crates.io/crates/fixture/1", text)
|
|
self.assertNotIn("source_path", text)
|
|
with self.assertRaisesRegex(RuntimeError, "missing a license notice"):
|
|
rust_notices(data, {("other", "1")})
|
|
data["licenses"][0]["text"] = ""
|
|
with self.assertRaisesRegex(RuntimeError, "Empty Rust license"):
|
|
rust_notices(data, set())
|
|
for policy in ({}, {"fixture:library:1": "MIT"}):
|
|
with self.assertRaisesRegex(RuntimeError, "needs review"):
|
|
android_notices([{"component": "fixture:library:1", "path": "must-not-be-read"}], policy)
|
|
|
|
def test_distribution_refuses_missing_credentials_and_unpinned_signers(self):
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with self.assertRaisesRegex(RuntimeError, "all five"):
|
|
upload_signing()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
store = Path(directory) / "upload.p12"
|
|
store.write_bytes(b"public fixture")
|
|
signing = {"IRONSTORAGE_UPLOAD_" + key: value for key, value in {
|
|
"STORE_FILE": str(store), "STORE_PASSWORD": "public", "KEY_ALIAS": "fixture",
|
|
"KEY_PASSWORD": "public", "CERT_SHA256": "0" * 64,
|
|
}.items()}
|
|
with patch.dict(os.environ, signing, clear=True), patch.object(subprocess, "run") as keytool:
|
|
keytool.return_value.stdout = b"public fixture certificate"
|
|
with self.assertRaisesRegex(RuntimeError, "independently pinned"):
|
|
upload_signing()
|
|
keytool.assert_called_once()
|
|
signing["IRONSTORAGE_UPLOAD_STORE_FILE"] = str(Path(__file__).resolve())
|
|
with patch.dict(os.environ, signing, clear=True):
|
|
with self.assertRaisesRegex(RuntimeError, "outside the repository"):
|
|
upload_signing()
|
|
|
|
def test_instrumentation_stops_harness_and_target_on_failure(self):
|
|
calls = []
|
|
def command(*args):
|
|
calls.append(args)
|
|
if args[2] == "instrument":
|
|
raise RuntimeError("disconnected emulator")
|
|
with self.assertRaisesRegex(RuntimeError, "disconnected emulator"):
|
|
instrumentation(command, "fixture.test", target="fixture")
|
|
self.assertEqual(calls[-2:], [("shell", "am", "force-stop", "fixture.test"),
|
|
("shell", "am", "force-stop", "fixture")])
|
|
|
|
def test_emulator_preflight_rejects_post_reboot_lock_and_sleep(self):
|
|
unlocked = ' User "Owner" (id=0, flags=0x13) (current): deviceLocked=0, strongAuthRequired=0x0'
|
|
self.assertTrue(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", unlocked))
|
|
self.assertFalse(emulator_ready("RUNNING_LOCKED", " mWakefulness=Awake\n", unlocked))
|
|
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Asleep\n", unlocked))
|
|
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", "", unlocked))
|
|
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", unlocked.replace("deviceLocked=0", "deviceLocked=1")))
|
|
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", ""))
|
|
|
|
def test_startup_requires_complete_bounded_numeric_samples(self):
|
|
def data():
|
|
return {"benchmarks": [{"name": name, "repeatIterations": 5, "metrics": {
|
|
metric: {"runs": [1, 2, 3, 4, 5]} for metric in ("timeToInitialDisplayMs", "timeToFullDisplayMs")
|
|
}} for name in ("coldStart", "warmStart")]}
|
|
self.assertEqual(set(startup_metrics(data())), {"cold", "warm"})
|
|
for samples in ([1, 2], [1, 2, 3, 4, float("nan")], [1, 2, 3, 4, 5000], [1, 2, 3, 4, "secret"], [1, 2, 3, 4, True]):
|
|
invalid = data()
|
|
invalid["benchmarks"][0]["metrics"]["timeToFullDisplayMs"]["runs"] = samples
|
|
with self.assertRaises(RuntimeError):
|
|
startup_metrics(invalid)
|
|
invalid = data()
|
|
invalid["benchmarks"].append(invalid["benchmarks"][0])
|
|
with self.assertRaises(RuntimeError):
|
|
startup_metrics(invalid)
|
|
|
|
def test_performance_metrics_retain_only_reviewed_numbers_and_enforce_budgets(self):
|
|
valid = "\n".join(f"INSTRUMENTATION_STATUS: ironstorage_{name}=1" for name in PERFORMANCE_BUDGETS)
|
|
self.assertEqual(performance_metrics(valid + "\nINSTRUMENTATION_STATUS: ironstorage_secret=999"), dict.fromkeys(PERFORMANCE_BUDGETS, 1))
|
|
for invalid in (valid.replace("editor_100_updates_ms=1", "editor_100_updates_ms=500"), valid.replace("qr_analysis_ms=1", "qr_analysis_ms=NaN"), valid + "\nINSTRUMENTATION_STATUS: ironstorage_qr_analysis_ms=1"):
|
|
with self.assertRaises(RuntimeError):
|
|
performance_metrics(invalid)
|
|
|
|
|
|
def test_isolated_phases_preserve_all_verification_tasks(self):
|
|
phases = verification_phases()
|
|
expected = {f":{module}:{task}" for module in MODULES
|
|
for task in ("testDebugUnitTest", "ktlintCheck", "lintDebug")}
|
|
expected.update(f":{module}:assembleDebugAndroidTest" for module in ("app", "core", "rust-bridge", "platform"))
|
|
expected.update((":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease", ":app:assembleBenchmark", ":app:assembleProfile", ":performance:assembleBenchmark", ":performance:ktlintCheck", "androidVerification"))
|
|
self.assertEqual({task for phase in phases for task in phase}, expected)
|
|
self.assertEqual(phases[-1], ("androidVerification",))
|
|
self.assertTrue(all(len(phase) <= len(MODULES) for phase in phases))
|
|
|
|
def test_ci_cleanup_refuses_local_or_redirected_build_directories(self):
|
|
previous = os.environ.get("CI")
|
|
try:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory).resolve()
|
|
(root / "target").mkdir()
|
|
(root / "target/public-fixture").write_text("artifact")
|
|
os.environ.pop("CI", None)
|
|
with self.assertRaisesRegex(RuntimeError, "restricted to CI"):
|
|
clean_ci(root)
|
|
self.assertTrue((root / "target/public-fixture").exists())
|
|
os.environ["CI"] = "true"
|
|
(root / "android/app").mkdir(parents=True)
|
|
(root / "android/app/build").symlink_to(root / "target", target_is_directory=True)
|
|
with self.assertRaisesRegex(RuntimeError, "symlinks"):
|
|
clean_ci(root)
|
|
self.assertTrue((root / "target/public-fixture").exists())
|
|
(root / "android/app/build").unlink()
|
|
(root / "android/app/build").mkdir()
|
|
(root / "android/app/src").mkdir()
|
|
clean_ci(root)
|
|
self.assertFalse((root / "target").exists())
|
|
self.assertFalse((root / "android/app/build").exists())
|
|
self.assertTrue((root / "android/app/src").is_dir())
|
|
finally:
|
|
if previous is None:
|
|
os.environ.pop("CI", None)
|
|
else:
|
|
os.environ["CI"] = previous
|
|
|
|
def test_native_scratch_cleanup_keeps_host_tools_and_rejects_symlinks(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
target = Path(directory).resolve()
|
|
triples = ("aarch64-linux-android", "armv7-linux-androideabi")
|
|
for name in (*triples, "debug"):
|
|
(target / name).mkdir()
|
|
(target / name / "public-fixture").write_text("artifact")
|
|
clear_native_intermediates(target)
|
|
self.assertTrue((target / "debug/public-fixture").is_file())
|
|
self.assertTrue(all(not (target / name).exists() for name in triples))
|
|
(target / triples[0]).symlink_to(target / "debug", target_is_directory=True)
|
|
with self.assertRaisesRegex(RuntimeError, "symlinks"):
|
|
clear_native_intermediates(target)
|
|
self.assertTrue((target / "debug/public-fixture").is_file())
|
|
|
|
def test_manifest_must_select_the_audited_backup_resources(self):
|
|
manifest = '<manifest xmlns:android="http://schemas.android.com/apk/res/android"><application android:fullBackupContent="@ref/0x7f110000" android:dataExtractionRules="@ref/0x7f110001"/></manifest>'
|
|
symbols = " resource 0x7f110000 xml/backup_rules\n resource 0x7f110001 xml/data_extraction_rules\n"
|
|
backup_references(manifest, symbols)
|
|
for wrong in (manifest.replace("0x7f110000", "0x7f110002"), manifest.replace("@ref/0x7f110001", "false")):
|
|
with self.assertRaisesRegex(RuntimeError, "unreviewed backup resource"):
|
|
backup_references(wrong, symbols)
|
|
for wrong in (symbols.replace("backup_rules", "other_rules"), symbols + symbols, symbols.replace("0x7f110001", "0x7f110000")):
|
|
with self.assertRaises(RuntimeError):
|
|
backup_references(manifest, wrong)
|
|
with self.assertRaisesRegex(RuntimeError, "symbols overlap"):
|
|
backup_references(manifest.replace("0x7f110001", "0x7f110000"), symbols.replace("0x7f110001", "0x7f110000"))
|
|
|
|
def test_aapt2_adapter_preserves_paths_and_other_options(self):
|
|
adapter = runpy.run_path(ROOT / ".gitea/scripts/native-arm64/aapt2")
|
|
self.assertEqual(adapter["arguments"](["optimize", "input.apk", "--resource-path-shortening-map=/tmp/map with spaces=ok", "--shorten-resource-paths", "-o", "output.apk"]),
|
|
["optimize", "input.apk", "--resource-path-shortening-map", "/tmp/map with spaces=ok", "--shorten-resource-paths", "-o", "output.apk"])
|
|
self.assertEqual(adapter["arguments"](["daemon"]), ["daemon"])
|
|
|
|
def test_toolchain_checksum_rejects_changed_archive(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "fixture"
|
|
path.write_bytes(b"public archive fixture")
|
|
spec = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
download(spec, path)
|
|
path.write_bytes(b"changed")
|
|
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
|
|
download(spec, path)
|
|
|
|
def test_symbol_selection_and_repeatable_checksum_manifest(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
directory = Path(directory)
|
|
source = directory / "source.zip"
|
|
output = directory / "output.zip"
|
|
names = [f"{abi}/{lib}.dbg" for abi in ABIS for lib in ("libironstorage_android.so", "libjnidispatch.so")]
|
|
with zipfile.ZipFile(source, "w") as archive:
|
|
for name in names + ["unused/libjnidispatch.so.dbg"]:
|
|
archive.writestr(name, b"public symbols")
|
|
select_symbols(source, output)
|
|
with zipfile.ZipFile(output) as archive:
|
|
self.assertEqual(set(archive.namelist()), set(names))
|
|
checksum_artifacts(directory)
|
|
first = (directory / "sha256.json").read_bytes()
|
|
checksum_artifacts(directory)
|
|
self.assertEqual(first, (directory / "sha256.json").read_bytes())
|
|
self.assertNotIn("sha256.json", json.loads(first))
|
|
with zipfile.ZipFile(source, "w") as archive:
|
|
archive.writestr(names[0], b"public symbols")
|
|
with self.assertRaisesRegex(RuntimeError, "missing"):
|
|
select_symbols(source, output)
|
|
with zipfile.ZipFile(source, "w") as archive:
|
|
archive.writestr("arm64-v8a/unreviewed.dbg", b"public symbols")
|
|
with self.assertRaisesRegex(RuntimeError, "Unreviewed"):
|
|
select_symbols(source, output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("command", choices=["prepare-linux", "build", "distribution", "emulator", "performance", "profiles", "test", "clean-ci"])
|
|
parser.add_argument("serial", nargs="?")
|
|
args = parser.parse_args()
|
|
if args.command not in ("test", "clean-ci") and not (os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")):
|
|
parser.error("ANDROID_HOME or ANDROID_SDK_ROOT is required")
|
|
if args.command == "test":
|
|
unittest.main(argv=[__file__])
|
|
elif args.command == "clean-ci":
|
|
clean_ci()
|
|
elif args.command == "prepare-linux":
|
|
prepare_linux()
|
|
elif args.command == "build":
|
|
build()
|
|
elif args.command == "distribution":
|
|
distribution()
|
|
elif args.serial:
|
|
if args.command in ("performance", "profiles"):
|
|
performance(args.serial, profiles=args.command == "profiles")
|
|
else:
|
|
emulator(args.serial)
|
|
else:
|
|
parser.error("emulator, performance and profiles require an ADB serial")
|