Prepare Android release artifacts and listing (#112)
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.
This commit is contained in:
1 parent
b54636b7bc
commit
bf83a0ba41
51 files changed
+1493
-41
No files matched your search
@@ -17,6 +17,7 @@ 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")
|
||||
@@ -139,6 +140,55 @@ def checksum_artifacts(output):
|
||||
(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:
|
||||
@@ -242,7 +292,8 @@ def build():
|
||||
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/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)
|
||||
@@ -450,6 +501,55 @@ def performance(serial, profiles=False):
|
||||
|
||||
|
||||
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):
|
||||
@@ -607,7 +707,7 @@ class SelfTest(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=["prepare-linux", "build", "emulator", "performance", "profiles", "test", "clean-ci"])
|
||||
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")):
|
||||
@@ -620,6 +720,8 @@ if __name__ == "__main__":
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build-time license text; never reads app data or a password-store repository."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
from android_ci import ROOT, CARGO, download
|
||||
|
||||
|
||||
def rust_notices(data, expected):
|
||||
covered = set()
|
||||
sections = []
|
||||
for license in data["licenses"]:
|
||||
if not license["text"].strip():
|
||||
raise RuntimeError("Empty Rust license text")
|
||||
users = []
|
||||
for used in license["used_by"]:
|
||||
crate = used["crate"]
|
||||
key = (crate["name"], crate["version"])
|
||||
covered.add(key)
|
||||
users.append(f"{key[0]} {key[1]} — {', '.join(crate['authors'])}\n"
|
||||
f"https://crates.io/crates/{key[0]}/{key[1]} (source archive)\n"
|
||||
f"{crate.get('repository') or ''}")
|
||||
sections.append(license["name"] + "\n" + "\n".join(sorted(users)) + "\n\n" + license["text"])
|
||||
if expected - covered:
|
||||
raise RuntimeError("Rust normal dependency is missing a license notice: " + repr(sorted(expected - covered)))
|
||||
return "\n\n".join(sorted(set(sections)))
|
||||
|
||||
|
||||
def android_notices(rows, policy):
|
||||
common = (ROOT / "android/licenses/Apache-2.0.txt").read_text()
|
||||
extras = {
|
||||
"androidx.camera:camera-core:1.6.2": ("Apache-2.0 AND BSD-3-Clause", ["libyuv-LICENSE.txt", "libyuv-PATENTS.txt"]),
|
||||
"net.java.dev.jna:jna:5.19.1": ("Apache-2.0 OR LGPL-2.1-or-later", ["jna-LICENSE.txt", "libffi.txt"]),
|
||||
"org.checkerframework:checker-qual:3.43.0": ("MIT", ["checker-qual.txt"]),
|
||||
"rustls:rustls-platform-verifier:0.1.1": ("MIT OR Apache-2.0", []),
|
||||
}
|
||||
sections = ["Apache License 2.0 (selected where a dual license permits it)\n\n" + common]
|
||||
for row in sorted(rows, key=lambda r: r["component"]):
|
||||
component = row["component"]
|
||||
license = policy.get(component)
|
||||
if license is None or (license != "Apache-2.0" and extras.get(component, (None,))[0] != license):
|
||||
raise RuntimeError("Android notice needs review: " + component)
|
||||
notices = []
|
||||
with zipfile.ZipFile(row["path"]) as archive:
|
||||
for name in sorted(archive.namelist()):
|
||||
if re.search(r"(?i)(?:^|/)(?:licen[cs]e|notice|copying|copyright|al2\.0)(?:[._-].*)?$", name):
|
||||
entry = archive.getinfo(name)
|
||||
if entry.is_dir() or entry.file_size > 1024 * 1024:
|
||||
raise RuntimeError("Unexpected Android notice archive entry")
|
||||
notices.append(archive.read(name).decode("utf-8"))
|
||||
for name in extras.get(component, (None, []))[1]:
|
||||
notices.append((ROOT / "android/licenses" / name).read_text())
|
||||
sections.append(component + "\nLicense: " + license + "\n\n" + "\n\n".join(sorted(set(notices))))
|
||||
return "\n\n".join(dict.fromkeys(sections))
|
||||
|
||||
|
||||
def generate(inventory, output):
|
||||
if platform.machine() not in ("arm64", "aarch64"):
|
||||
raise RuntimeError("License tools require the supported native ARM build host")
|
||||
scratch = ROOT / "android/build/license-tools"
|
||||
scratch.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = scratch / "cargo-about.tar.gz"
|
||||
spec = json.loads((ROOT / "android/license-tool.json").read_text())[platform.system()]
|
||||
download(spec, archive_path)
|
||||
with tarfile.open(archive_path) as archive:
|
||||
members = [m for m in archive.getmembers() if Path(m.name).name == "cargo-about" and m.isfile()]
|
||||
if len(members) != 1 or members[0].size > 100 * 1024 * 1024:
|
||||
raise RuntimeError("Unexpected cargo-about package")
|
||||
binary = scratch / "cargo-about"
|
||||
binary.write_bytes(archive.extractfile(members[0]).read())
|
||||
binary.chmod(0o755)
|
||||
data = json.loads(subprocess.check_output([str(binary), "generate", "--locked", "--fail", "--manifest-path",
|
||||
str(ROOT / "crates/android/Cargo.toml"), "--config",
|
||||
str(ROOT / "android/about.toml"), "--format", "json"]))
|
||||
expected = set()
|
||||
workspace = json.loads(subprocess.check_output([CARGO, "metadata", "--locked", "--no-deps", "--format-version", "1"]))
|
||||
local_names = {package["name"] for package in workspace["packages"] if package["source"] is None}
|
||||
for triple in ("aarch64-linux-android", "armv7-linux-androideabi"):
|
||||
tree = subprocess.check_output([CARGO, "tree", "--locked", "-p", "ironstorage-android", "--target", triple,
|
||||
"--edges", "normal", "--prefix", "none", "--format", "{p}"], text=True)
|
||||
for line in tree.splitlines():
|
||||
match = re.match(r"([^ ]+) v([^ ]+)", line)
|
||||
if match and match[1] not in local_names:
|
||||
expected.add((match[1], match[2]))
|
||||
rows = json.loads(inventory.read_text())
|
||||
if not rows:
|
||||
raise RuntimeError("Android release notice inventory is empty")
|
||||
text = "IronStorage — Third-party notices\n\nAndroid release libraries\n\n"
|
||||
text += android_notices(rows, json.loads((ROOT / "android/dependency-policy.json").read_text())["android"])
|
||||
text += "\n\nRust ARM libraries\n\n" + rust_notices(data, expected)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(text, encoding="utf-8")
|
||||
print(f"Packaged full notices for {len({r['component'] for r in rows})} Android and {len(expected)} Rust components")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate(Path(sys.argv[1]), Path(sys.argv[2]))
|
||||
Reference in new issue
Block a user