diff --git a/scripts/collect-android-artifacts.py b/scripts/collect-android-artifacts.py new file mode 100644 index 0000000..3d47647 --- /dev/null +++ b/scripts/collect-android-artifacts.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Collect development/unsigned outputs with source identity and inspection evidence.""" +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +BUILD = Path(os.environ.get("YUVOMI_BUILD_DIR", ROOT / "android")) +APP = BUILD / "app" if "YUVOMI_BUILD_DIR" in os.environ else BUILD / "app/build" +SDK = Path(os.environ["ANDROID_HOME"]) +TOOLS = SDK / "build-tools/36.0.0" +DESTINATION = ROOT / "artifacts/android" +DESTINATION.mkdir(parents=True, exist_ok=True) + + +def tool(name, *arguments): + return subprocess.check_output([str(TOOLS / name), *map(str, arguments)], text=True) + + +release = APP / "outputs/apk/release/app-release-unsigned.apk" +manifest = tool("aapt2", "dump", "xmltree", release, "--file", "AndroidManifest.xml") +assert 'package="community.yuvomi.mobile"' in manifest +assert re.search(r":allowBackup\([^\n]*=false", manifest) +assert re.search(r":usesCleartextTraffic\([^\n]*=false", manifest) +assert not re.search(r":debuggable\([^\n]*=true", manifest) +resources = tool("aapt2", "dump", "resources", release) +network = re.search(r"xml/network_security_config\s+\(\) \(file\) (\S+)", resources) +assert network, "Missing production network security configuration" +policy = tool("aapt2", "dump", "xmltree", release, "--file", network.group(1)) +assert "cleartextTrafficPermitted=false" in policy +assert policy.count("E: certificates") == 1 and 'src="system"' in policy +(DESTINATION / "release-manifest.txt").write_text(manifest) +(DESTINATION / "release-network-policy.txt").write_text(policy) + +debug = APP / "outputs/apk/debug/app-debug.apk" +(DESTINATION / "debug-signature.txt").write_text(tool("apksigner", "verify", "--verbose", debug)) +unsigned = subprocess.run([str(TOOLS / "apksigner"), "verify", str(release)], capture_output=True, text=True) +assert unsigned.returncode != 0 and "Missing META-INF/MANIFEST.MF" in unsigned.stderr, unsigned.stderr + +entries = [] +for source in [debug, release, APP / "outputs/bundle/release/app-release.aab"]: + with zipfile.ZipFile(source) as archive: + for name in archive.namelist(): + assert "integration_ca" not in name and not name.endswith((".pem", ".jks", ".keystore", ".p12")), name + if source.suffix == ".aab": + assert not any(name.upper().startswith("META-INF/") and name.upper().endswith((".RSA", ".DSA", ".EC")) + for name in archive.namelist()), "Expected unsigned AAB" + destination = DESTINATION / source.name + shutil.copyfile(source, destination) + entries.append({"file": source.name, "bytes": destination.stat().st_size, + "sha256": hashlib.sha256(destination.read_bytes()).hexdigest()}) + +revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() +dirty = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT, text=True).strip()) +(DESTINATION / "SHA256SUMS").write_text("".join(f"{entry['sha256']} {entry['file']}\n" for entry in entries)) +(DESTINATION / "build-info.json").write_text(json.dumps({ + "source_revision": revision, "worktree_dirty_at_collection": dirty, + "upstream_revision": "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d", + "status": "Development debug APK; unsigned release APK/AAB. Not production-ready.", + "artifacts": entries, +}, indent=2) + "\n") +print(f"Inspected and collected {len(entries)} artifacts in {DESTINATION}") +print("Build source identity assumes the documented build ran immediately before collection.")