diff --git a/.gitea/scripts/android_ci.py b/.gitea/scripts/android_ci.py
new file mode 100644
index 0000000..7d084ab
--- /dev/null
+++ b/.gitea/scripts/android_ci.py
@@ -0,0 +1,348 @@
+#!/usr/bin/env python3
+"""Build-only orchestration; never shipped or invoked by the Android application."""
+import argparse
+import hashlib
+import json
+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
+
+ROOT = Path(__file__).resolve().parents[2]
+ABIS = ("arm64-v8a", "armeabi-v7a")
+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 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 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 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 build():
+ ci = os.environ.get("CI") == "true"
+ scratch = ROOT / "android/build/native-cargo"
+ if ci:
+ 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)
+ 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)
+ 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 ("app", "core", "features", "platform", "rust-bridge"):
+ 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 ("app", "core", "features", "platform", "rust-bridge"):
+ 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"]
+ for path in files:
+ shutil.copy2(path, output / path.name)
+ checksum_artifacts(output)
+
+
+def emulator(serial):
+ adb = SDK / "platform-tools/adb"
+ def command(*args):
+ return run(adb, "-s", serial, *args, capture=True).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")
+ 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),
+ ("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None)]
+ native_timings = []
+ for module, package, _ 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 = command("shell", "am", "instrument", "-w", "-r", "-e", "class", "de.rfc1437.ironstorage.bridge.NativeLoadPerformanceTest", package + "/androidx.test.runner.AndroidJUnitRunner")
+ 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)
+ result = command("shell", "am", "instrument", "-w", "-r", package + "/androidx.test.runner.AndroidJUnitRunner")
+ 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)")
+ # 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, "instrumentation": "passed"}, indent=2) + "\n")
+ checksum_artifacts(output)
+ print(f"ARM API {api}, {pages}-byte pages: instrumentation and five cold starts passed")
+
+
+class SelfTest(unittest.TestCase):
+ 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 = ''
+ 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", "emulator", "test"])
+ parser.add_argument("serial", nargs="?")
+ args = parser.parse_args()
+ if args.command != "test" 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 == "prepare-linux":
+ prepare_linux()
+ elif args.command == "build":
+ build()
+ elif args.serial:
+ emulator(args.serial)
+ else:
+ parser.error("emulator requires an ADB serial")
diff --git a/.gitea/scripts/native-arm64/aapt2 b/.gitea/scripts/native-arm64/aapt2
new file mode 100755
index 0000000..05a3693
--- /dev/null
+++ b/.gitea/scripts/native-arm64/aapt2
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Adapt AGP's map argument to the pinned native ARM64 AAPT2 syntax."""
+import os
+from pathlib import Path
+import sys
+
+
+def arguments(args):
+ result = []
+ for arg in args:
+ if arg.startswith("--resource-path-shortening-map="):
+ option, value = arg.split("=", 1)
+ result.extend((option, value))
+ else:
+ result.append(arg)
+ return result
+
+
+if __name__ == "__main__":
+ binary = Path(os.environ["ANDROID_HOME"]) / "native-arm64/build-tools/aapt2"
+ os.execv(binary, [str(binary), *arguments(sys.argv[1:])])
diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml
new file mode 100644
index 0000000..1fd1434
--- /dev/null
+++ b/.gitea/workflows/android.yml
@@ -0,0 +1,92 @@
+name: Android ARM verification
+
+on:
+ push:
+ branches:
+ - main
+ - 'codex/**'
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ code: read
+ actions: write
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_BUILD_JOBS: '1'
+ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=lld
+
+jobs:
+ rust:
+ runs-on: linux-arm64
+ timeout-minutes: 90
+ env:
+ CARGO_INCREMENTAL: '0'
+ CARGO_PROFILE_TEST_DEBUG: line-tables-only
+ steps:
+ - uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - name: Install workspace system libraries
+ run: |
+ df -h .
+ df -i .
+ apt-get update
+ apt-get install -y --no-install-recommends lld pkg-config libfontconfig1-dev libasound2-dev libudev-dev libxkbcommon-dev libwayland-dev
+ - name: Install pinned Rust
+ uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af
+ with:
+ tool: rust@1.97.1
+ fallback: none
+ - name: Install formatting and Clippy components
+ run: rustup component add --toolchain 1.97.1 rustfmt clippy
+ - name: Formatting
+ run: cargo +1.97.1 fmt --all -- --check
+ - name: Warnings as errors
+ run: RUSTFLAGS="-D warnings" cargo +1.97.1 check --locked --workspace --all-targets
+ - name: Clippy
+ run: cargo +1.97.1 clippy --locked --workspace --all-targets -- -D warnings
+ - name: Workspace tests including deterministic clocks and secret backends
+ run: |
+ cargo +1.97.1 test --locked --workspace
+ du -sh target
+
+ android:
+ runs-on: linux-arm64
+ timeout-minutes: 150
+ env:
+ ANDROID_HOME: /opt/ironstorage-android-sdk
+ JAVA_HOME: /usr/lib/jvm/java-25-openjdk-arm64
+ CI: 'true'
+ CARGO_INCREMENTAL: '0'
+ CARGO_PROFILE_DEV_DEBUG: line-tables-only
+ GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.jvmargs=-Xmx1g -Dkotlin.daemon.jvm.options=-Xmx512m
+ steps:
+ - uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - name: Install native ARM64 Java and build prerequisites
+ run: |
+ df -h .
+ df -i .
+ apt-get update
+ apt-get install -y --no-install-recommends openjdk-25-jdk python3 ca-certificates curl unzip xz-utils gcc lld pkg-config libfontconfig1-dev
+ - name: Install pinned Rust
+ uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af
+ with:
+ tool: rust@1.97.1
+ fallback: none
+ - name: Install selected Android targets and cargo-ndk
+ run: |
+ rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi
+ cargo +1.97.1 install cargo-ndk --version 4.1.2 --locked
+ - name: Check build orchestration
+ run: python3 .gitea/scripts/android_ci.py test
+ - name: Install checksummed native Linux ARM64 SDK and NDK tools
+ run: python3 .gitea/scripts/android_ci.py prepare-linux
+ - name: Compile, test, lint and audit unsigned ARM packages
+ run: python3 .gitea/scripts/android_ci.py build
+ - name: Upload reviewed packages, matching symbols, safe test summaries and SBOM
+ uses: https://github.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: android-arm-verification
+ path: android/build/ci-artifacts/
+ if-no-files-found: error
+ retention-days: 14
diff --git a/Cargo.lock b/Cargo.lock
index 794b748..de4cab1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5450,6 +5450,11 @@ dependencies = [
"ironstorage",
"object",
"regex",
+ "roxmltree",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "toml 0.9.12+spec-1.1.0",
"uniffi",
"uniffi_bindgen",
"zip",
@@ -8748,9 +8753,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.43"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -8834,9 +8839,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.13"
+version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -9879,7 +9884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
diff --git a/android/README.md b/android/README.md
index f32026e..3e41f2b 100644
--- a/android/README.md
+++ b/android/README.md
@@ -1,9 +1,11 @@
# Native Android build
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
-[parity matrix](../docs/android/parity.md). The native foundation implements #89 and #92. Onboarding and secure storage
-are implemented by their milestone issues. Both variants now invoke Rust through
-the generated UniFFI API.
+[parity matrix](../docs/android/parity.md). The native foundation implements #89–#94, including private storage, Keystore
+callbacks and build gates. Both variants invoke Rust through the generated
+UniFFI API. Onboarding and the feature screens remain owned by their milestone
+issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
+ARM-only runner/toolchain policy and artifact audits.
## Reproducible setup
@@ -63,10 +65,11 @@ Version code 1 and name 1.0.1 are deterministic initial metadata. Debug signing
uses the standard local debug key. Release has no configured signing key and
produces an unsigned APK; #112 configures protected upload signing. Do not add
signing secrets or store passwords to the repository. Native symbol level FULL
-and the selected three ABIs are packaged. Native runtime packaging is described
+and the selected two ARM ABIs are packaged. Native runtime packaging is described
below; no development network endpoint is included.
-The manifest has only the exported launcher activity. Backup/cloud/device-transfer
+The manifest exports the launcher and the reviewed AndroidX profile receiver
+protected by the privileged DUMP permission. Backup/cloud/device-transfer
exclusions and cleartext denial are explicit. Feature-specific services/permissions
are added with their owning issues. Resources use values directories for future
localization; vector/adaptive/monochrome icons and R8 consumer rules are present.
@@ -93,14 +96,14 @@ Install the pinned build tools once:
```sh
rustup toolchain install 1.97.1 --profile minimal
-rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
+rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi
cargo install cargo-ndk --version 4.1.2 --locked
```
Gradle checks the actual Cargo/cargo-ndk/NDK revisions before building. Each
variant compiles crates/android with cargo-ndk into its own generated jniLibs,
then verifies ELF architecture, 16 KB LOAD alignment, and byte-identical UniFFI
-metadata across all three ABIs. A packaging step selects only the Android library
+metadata across both ARM ABIs. A packaging step selects only the Android library
and rejects accidental inclusion of the dependency Apple cdylib. The thin Android crate re-exports the existing
UniFFI component; it duplicates no storage rules or converters. #91 owns the
platform-neutral contract extraction. No handwritten JNI or unsafe Rust is added.
@@ -126,15 +129,14 @@ newer state. Blocking fixture calls finish in Rust when a coroutine is cancelled
no thread is forcibly stopped. Long storage operations must use their Rust-owned
cancellation methods when their feature is integrated.
-No Rust-to-Kotlin platform callbacks are installed by #92. When #93 adds them,
-use generated UniFFI/JNA callback adapters only: JNA attaches a calling native
+#93 installs Rust-to-Kotlin Keystore callbacks through generated UniFFI/JNA
+adapters: JNA attaches a calling native
thread to the JVM; retain the callback for the full Rust owner lifetime, release
it after unregistering, never assume the callback runs on the Android main thread,
and marshal UI/Activity work onto Main. Keep storage decisions in Rust and avoid
blocking the main thread or calling back into a held Rust lock.
-JNA 5.19.1 is packaged as its Android AAR, filtered to arm64-v8a, armeabi-v7a and
-x86_64. The release set may only be narrowed by changing the support decision in
+JNA 5.19.1 is packaged as its Android AAR, filtered to arm64-v8a, armeabi-v7a only. The release set is arm64-v8a and armeabi-v7a under the user amendment to
#88, not by silently omitting an ABI. All source/lock/config files are task inputs;
outputs live only under build/. Debug and release compile independently, and
unchanged builds reuse their outputs. R8 keeps generated/JNA reflection entry
@@ -144,17 +146,16 @@ that file and outputs/mapping/release/mapping.txt with the matching release.
AAB base/lib entries let Play produce per-ABI splits.
The auditNativePackages gate inspects every native dependency in both APKs and
-the AAB, checks all three ABI directories and both Rust/JNA libraries, and rejects
+the AAB, checks both ARM ABI directories and both Rust/JNA libraries, and rejects
ELF segments below 16 KB alignment or APK native entries that are compressed or
not ZIP-aligned to 16 KB. Follow the official [16 KB runtime validation](https://developer.android.com/guide/practices/page-sizes):
use an actual 16 KB system image, check adb shell getconf PAGE_SIZE is 16384,
disable page-size compatibility mode, and run both instrumented test suites.
An ELF/ZIP audit is not a substitute for that runtime test. Milestone validation
is emulator-only per the user instruction of 2026-10-08; physical devices are
-outside the test scope for every issue. Keep x86_64 emulator evidence separate
-from the Apple Silicon arm64 emulator run.
+outside the test scope for every issue. The user removed x86_64 Android from the current support scope on 2026-10-08.
-Validation recorded on 2026-10-08 for #92:
+Historical validation recorded on 2026-10-08 for #92 (before the ARM-only scope update):
| Environment | Verified |
| --- | --- |
@@ -165,5 +166,5 @@ Validation recorded on 2026-10-08 for #92:
| Physical devices | Excluded from milestone testing by user instruction |
| x86_64 runtime | System image installed; QEMU2 rejects x86_64 on this aarch64 Mac, including with acceleration disabled |
-The outstanding x86_64 emulator check needs a compatible host; cross-compilation
-and static package checks do not establish x86_64 runtime coverage.
+The historical x86_64 runtime gap is no longer a required check under the current
+ARM-only support decision.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 165b347..a46ad45 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -18,7 +18,7 @@ android {
versionCode = 1
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
+ ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -41,12 +41,17 @@ android {
lint {
abortOnError = true
warningsAsErrors = true
+ // The user scoped this milestone to ARM Android; x86 ChromeOS is excluded.
+ disable += "ChromeOsAbiSupport"
}
}
kotlin {
jvmToolchain(25)
- compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
}
dependencies {
@@ -69,7 +74,6 @@ dependencies {
androidTestImplementation(libs.compose.ui.test)
androidTestImplementation(libs.android.test.runner)
androidTestImplementation(libs.android.test.espresso)
- debugImplementation(libs.compose.ui.test.manifest)
implementation(libs.coroutines.android)
testImplementation(libs.coroutines.test)
testImplementation(libs.junit)
diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile
index b957dbe..7ba2c08 100644
--- a/android/app/gradle.lockfile
+++ b/android/app/gradle.lockfile
@@ -51,7 +51,6 @@ androidx.compose.ui:ui-graphics:1.12.1=debugAndroidTestCompileClasspath,debugAnd
androidx.compose.ui:ui-test-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
-androidx.compose.ui:ui-test-manifest:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-test:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 18c4a75..9fab16f 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -1,4 +1,7 @@
+import groovy.json.JsonOutput
import java.io.File
+import java.security.MessageDigest
+import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.artifacts.ExternalModuleDependency
import org.gradle.api.artifacts.ProjectDependency
@@ -39,8 +42,15 @@ subprojects {
listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3").none { group.startsWith(it) }
}) { "Domain libraries must stay in crates/storage: $group:$name" }
}
+ val enforceDomain = name.endsWith("RuntimeClasspath") || name.endsWith("CompileClasspath")
resolutionStrategy.eachDependency {
- require(!requested.version.orEmpty().contains('+')) { "Dynamic versions are forbidden" }
+ require(requested.version.orEmpty().let { version ->
+ !version.contains('+') && !version.contains("SNAPSHOT") &&
+ !version.startsWith("latest.") && version.none { it in "[]()," }
+ }) { "Dynamic versions are forbidden" }
+ require(!enforceDomain || listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3", "org.pgpainless", "com.google.mlkit").none {
+ requested.group == it || requested.group.startsWith("$it.")
+ }) { "Transitive domain libraries must stay in crates/storage" }
}
}
}
@@ -79,3 +89,51 @@ tasks.register("auditNativePackages") {
) + packages.map { it.asFile.absolutePath }
)
}
+
+
+// Each project resolves its own configurations under Gradle's project lock.
+subprojects {
+ val owner = project
+ tasks.register("dependencyInventory") {
+ val output = layout.buildDirectory.file("reports/android-dependencies.json")
+ outputs.file(output)
+ outputs.upToDateWhen { false }
+ doLast {
+ val rows = listOf("debugRuntimeClasspath", "releaseRuntimeClasspath", "debugAndroidTestRuntimeClasspath", "debugUnitTestRuntimeClasspath").flatMap { name ->
+ val configuration = owner.configurations.findByName(name)
+ if (configuration == null || !configuration.isCanBeResolved) emptyList() else {
+ configuration.incoming.artifactView {
+ componentFilter { it is ModuleComponentIdentifier }
+ }.artifacts.artifacts.mapNotNull { artifact ->
+ val id = artifact.id.componentIdentifier as? ModuleComponentIdentifier
+ id?.let {
+ mapOf(
+ "group" to it.group, "name" to it.module, "version" to it.version,
+ "sha256" to MessageDigest.getInstance("SHA-256").digest(artifact.file.readBytes())
+ .joinToString("") { byte -> "%02x".format(byte) },
+ "scope" to "${owner.path}:$name"
+ )
+ }
+ }
+ }
+ }.sortedWith(compareBy({ it.getValue("scope") }, { it.getValue("group") }, { it.getValue("name") }, { it.getValue("version") }, { it.getValue("sha256") }))
+ output.get().asFile.apply {
+ parentFile.mkdirs()
+ writeText(JsonOutput.prettyPrint(JsonOutput.toJson(rows)) + "\n")
+ }
+ }
+ notCompatibleWithConfigurationCache("Resolves reviewed artifact graphs")
+ }
+}
+
+tasks.register("dependencyInventory") {
+ dependsOn(subprojects.map { "${it.path}:dependencyInventory" })
+}
+
+tasks.register("androidVerification") {
+ dependsOn("checkArchitecture", "dependencyInventory", "auditNativePackages")
+ dependsOn(subprojects.map { "${it.path}:ktlintCheck" })
+ dependsOn(subprojects.map { "${it.path}:lintDebug" })
+ dependsOn(subprojects.map { "${it.path}:testDebugUnitTest" })
+ dependsOn(":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest")
+}
diff --git a/android/ci-toolchains.json b/android/ci-toolchains.json
new file mode 100644
index 0000000..0d3939b
--- /dev/null
+++ b/android/ci-toolchains.json
@@ -0,0 +1,14 @@
+{
+ "sdk_cli": {
+ "url": "https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip",
+ "sha256": "04453066b540409d975c676d781da1477479dde3761310f1a7eb92a1dfb15af7"
+ },
+ "ndk": {
+ "url": "https://github.com/HomuHomu833/android-ndk-custom/releases/download/r30/android-ndk-r30-aarch64-linux-gnu.tar.xz",
+ "sha256": "f8eeb06b7c97415d883a8ecdd0a725e759406edab4502e13b1a6a8561461a0c8"
+ },
+ "sdk_native": {
+ "url": "https://github.com/lzhiyong/android-sdk-tools/releases/download/35.0.2/android-sdk-tools-static-aarch64.zip",
+ "sha256": "db1cea2c4454d5f9c5a802646b2d1cf560b4ee7badbe23e51ab8e1881bb50fc2"
+ }
+}
diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts
index e14e483..7db1351 100644
--- a/android/core/build.gradle.kts
+++ b/android/core/build.gradle.kts
@@ -25,7 +25,10 @@ android {
kotlin {
jvmToolchain(25)
- compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
}
dependencies {
diff --git a/android/dependency-policy.json b/android/dependency-policy.json
new file mode 100644
index 0000000..e4db954
--- /dev/null
+++ b/android/dependency-policy.json
@@ -0,0 +1,165 @@
+{
+ "android": {
+ "androidx.activity:activity-compose:1.12.0": "Apache-2.0",
+ "androidx.activity:activity-compose:1.13.0": "Apache-2.0",
+ "androidx.activity:activity-ktx:1.12.0": "Apache-2.0",
+ "androidx.activity:activity-ktx:1.13.0": "Apache-2.0",
+ "androidx.activity:activity-ktx:1.8.1": "Apache-2.0",
+ "androidx.activity:activity:1.12.0": "Apache-2.0",
+ "androidx.activity:activity:1.13.0": "Apache-2.0",
+ "androidx.activity:activity:1.8.1": "Apache-2.0",
+ "androidx.annotation:annotation-experimental:1.4.1": "Apache-2.0",
+ "androidx.annotation:annotation-experimental:1.5.1": "Apache-2.0",
+ "androidx.annotation:annotation-jvm:1.11.0": "Apache-2.0",
+ "androidx.appcompat:appcompat-resources:1.2.0": "Apache-2.0",
+ "androidx.appcompat:appcompat:1.2.0": "Apache-2.0",
+ "androidx.arch.core:core-common:2.2.0": "Apache-2.0",
+ "androidx.arch.core:core-runtime:2.2.0": "Apache-2.0",
+ "androidx.autofill:autofill:1.0.0": "Apache-2.0",
+ "androidx.biometric:biometric:1.1.0": "Apache-2.0",
+ "androidx.collection:collection-jvm:1.5.0": "Apache-2.0",
+ "androidx.collection:collection-jvm:1.6.0": "Apache-2.0",
+ "androidx.collection:collection-ktx:1.5.0": "Apache-2.0",
+ "androidx.collection:collection-ktx:1.6.0": "Apache-2.0",
+ "androidx.compose.animation:animation-android:1.12.1": "Apache-2.0",
+ "androidx.compose.animation:animation-core-android:1.12.1": "Apache-2.0",
+ "androidx.compose.foundation:foundation-android:1.12.1": "Apache-2.0",
+ "androidx.compose.foundation:foundation-layout-android:1.12.1": "Apache-2.0",
+ "androidx.compose.material3.adaptive:adaptive-android:1.3.0": "Apache-2.0",
+ "androidx.compose.material3:material3-android:1.4.0": "Apache-2.0",
+ "androidx.compose.material:material-ripple-android:1.12.1": "Apache-2.0",
+ "androidx.compose.runtime:runtime-android:1.12.1": "Apache-2.0",
+ "androidx.compose.runtime:runtime-annotation-android:1.12.1": "Apache-2.0",
+ "androidx.compose.runtime:runtime-retain-android:1.12.1": "Apache-2.0",
+ "androidx.compose.runtime:runtime-saveable-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-geometry-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-graphics-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-test-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-test-junit4-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-text-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-unit-android:1.12.1": "Apache-2.0",
+ "androidx.compose.ui:ui-util-android:1.12.1": "Apache-2.0",
+ "androidx.concurrent:concurrent-futures-ktx:1.2.0": "Apache-2.0",
+ "androidx.concurrent:concurrent-futures:1.1.0": "Apache-2.0",
+ "androidx.concurrent:concurrent-futures:1.2.0": "Apache-2.0",
+ "androidx.core:core-ktx:1.13.1": "Apache-2.0",
+ "androidx.core:core-ktx:1.16.0": "Apache-2.0",
+ "androidx.core:core-ktx:1.18.0": "Apache-2.0",
+ "androidx.core:core-viewtree:1.0.0": "Apache-2.0",
+ "androidx.core:core:1.13.1": "Apache-2.0",
+ "androidx.core:core:1.16.0": "Apache-2.0",
+ "androidx.core:core:1.18.0": "Apache-2.0",
+ "androidx.cursoradapter:cursoradapter:1.0.0": "Apache-2.0",
+ "androidx.customview:customview-poolingcontainer:1.0.0": "Apache-2.0",
+ "androidx.customview:customview:1.0.0": "Apache-2.0",
+ "androidx.drawerlayout:drawerlayout:1.0.0": "Apache-2.0",
+ "androidx.emoji2:emoji2:1.4.0": "Apache-2.0",
+ "androidx.fragment:fragment:1.5.1": "Apache-2.0",
+ "androidx.fragment:fragment:1.9.1": "Apache-2.0",
+ "androidx.graphics:graphics-path:1.0.1": "Apache-2.0",
+ "androidx.interpolator:interpolator:1.0.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-common-java8:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-common-jvm:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-common-jvm:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-livedata-core:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-livedata-core:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-livedata:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-livedata:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-process:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-runtime-android:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-runtime-android:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-android:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-android:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel:2.10.0": "Apache-2.0",
+ "androidx.lifecycle:lifecycle-viewmodel:2.11.0": "Apache-2.0",
+ "androidx.loader:loader:1.0.0": "Apache-2.0",
+ "androidx.navigation3:navigation3-runtime-android:1.2.0": "Apache-2.0",
+ "androidx.navigation3:navigation3-ui-android:1.2.0": "Apache-2.0",
+ "androidx.navigationevent:navigationevent-android:1.1.1": "Apache-2.0",
+ "androidx.navigationevent:navigationevent-compose-android:1.1.1": "Apache-2.0",
+ "androidx.profileinstaller:profileinstaller:1.4.0": "Apache-2.0",
+ "androidx.savedstate:savedstate-android:1.4.0": "Apache-2.0",
+ "androidx.savedstate:savedstate-compose-android:1.4.0": "Apache-2.0",
+ "androidx.savedstate:savedstate-ktx:1.4.0": "Apache-2.0",
+ "androidx.startup:startup-runtime:1.1.1": "Apache-2.0",
+ "androidx.test.espresso:espresso-core:3.7.0": "Apache-2.0",
+ "androidx.test.espresso:espresso-idling-resource:3.7.0": "Apache-2.0",
+ "androidx.test.ext:junit:1.1.5": "Apache-2.0",
+ "androidx.test.services:storage:1.6.0": "Apache-2.0",
+ "androidx.test:core:1.7.0": "Apache-2.0",
+ "androidx.test:monitor:1.8.0": "Apache-2.0",
+ "androidx.test:runner:1.7.0": "Apache-2.0",
+ "androidx.tracing:tracing-android:2.0.0": "Apache-2.0",
+ "androidx.tracing:tracing:1.1.0": "Apache-2.0",
+ "androidx.tracing:tracing:1.2.0": "Apache-2.0",
+ "androidx.vectordrawable:vectordrawable-animated:1.1.0": "Apache-2.0",
+ "androidx.vectordrawable:vectordrawable:1.1.0": "Apache-2.0",
+ "androidx.versionedparcelable:versionedparcelable:1.1.1": "Apache-2.0",
+ "androidx.viewpager:viewpager:1.0.0": "Apache-2.0",
+ "androidx.window:window-core-android:1.5.0": "Apache-2.0",
+ "androidx.window:window:1.5.0": "Apache-2.0",
+ "com.google.code.findbugs:jsr305:3.0.2": "Apache-2.0",
+ "com.google.dagger:dagger-lint-aar:2.60.1": "Apache-2.0",
+ "com.google.dagger:dagger:2.60.1": "Apache-2.0",
+ "com.google.dagger:hilt-android:2.60.1": "Apache-2.0",
+ "com.google.dagger:hilt-core:2.60.1": "Apache-2.0",
+ "com.google.errorprone:error_prone_annotations:2.30.0": "Apache-2.0",
+ "com.google.guava:listenablefuture:1.0": "Apache-2.0",
+ "jakarta.inject:jakarta.inject-api:2.0.1": "Apache-2.0",
+ "javax.inject:javax.inject:1": "Apache-2.0",
+ "junit:junit:4.13.2": "EPL-1.0",
+ "net.java.dev.jna:jna:5.19.1": "Apache-2.0 OR LGPL-2.1-or-later",
+ "org.hamcrest:hamcrest-core:1.3": "BSD-3-Clause",
+ "org.hamcrest:hamcrest-library:1.3": "BSD-3-Clause",
+ "org.jetbrains.kotlin:kotlin-stdlib:2.4.21": "Apache-2.0",
+ "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0": "Apache-2.0",
+ "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0": "Apache-2.0",
+ "org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0": "Apache-2.0",
+ "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3": "Apache-2.0",
+ "org.jetbrains:annotations:13.0": "Apache-2.0",
+ "org.jetbrains:annotations:23.0.0": "Apache-2.0",
+ "org.jspecify:jspecify:1.0.0": "Apache-2.0"
+ },
+ "rust_licenses": [
+ "(Apache-2.0 OR MIT) AND BSD-3-Clause",
+ "(MIT OR Apache-2.0) AND ISC",
+ "(MIT OR Apache-2.0) AND Unicode-3.0",
+ "0BSD OR MIT OR Apache-2.0",
+ "Apache-2.0",
+ "Apache-2.0 / MIT",
+ "Apache-2.0 OR ISC OR MIT",
+ "Apache-2.0 OR MIT",
+ "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT",
+ "BSD-2-Clause",
+ "BSD-2-Clause OR Apache-2.0 OR MIT",
+ "BSD-3-Clause",
+ "BSD-3-Clause OR Apache-2.0",
+ "BSD-3-Clause OR MIT OR Apache-2.0",
+ "CC0-1.0 OR MIT-0 OR Apache-2.0",
+ "ISC",
+ "ISC AND (Apache-2.0 OR ISC)",
+ "ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)",
+ "MIT",
+ "MIT OR Apache-2.0",
+ "MIT OR Apache-2.0 OR Zlib",
+ "MIT OR Zlib OR Apache-2.0",
+ "MIT/Apache-2.0",
+ "MPL-2.0",
+ "Unicode-3.0",
+ "Unlicense OR MIT",
+ "Unlicense/MIT",
+ "Zlib",
+ "Zlib OR Apache-2.0 OR MIT"
+ ]
+}
diff --git a/android/features/build.gradle.kts b/android/features/build.gradle.kts
index db18bab..4bea6c6 100644
--- a/android/features/build.gradle.kts
+++ b/android/features/build.gradle.kts
@@ -28,7 +28,10 @@ android {
kotlin {
jvmToolchain(25)
- compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
}
dependencies {
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index a4332e8..a24747f 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -31,7 +31,6 @@ compose-ui = { module = "androidx.compose.ui:ui" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml
index df050a9..7d9d06d 100644
--- a/android/gradle/verification-metadata.xml
+++ b/android/gradle/verification-metadata.xml
@@ -3946,11 +3946,17 @@
+
+
+
+
+
+
@@ -4108,5 +4114,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index a78c88e..471f9b3 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -2,9 +2,9 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
-networkTimeout=10000
-retries=0
-retryBackOffMs=500
+networkTimeout=60000
+retries=3
+retryBackOffMs=2000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/android/platform/build.gradle.kts b/android/platform/build.gradle.kts
index da0f1f1..61d9646 100644
--- a/android/platform/build.gradle.kts
+++ b/android/platform/build.gradle.kts
@@ -32,7 +32,10 @@ android {
kotlin {
jvmToolchain(25)
- compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
}
dependencies {
diff --git a/android/rust-bridge/build.gradle.kts b/android/rust-bridge/build.gradle.kts
index 6d8bece..86b03a7 100644
--- a/android/rust-bridge/build.gradle.kts
+++ b/android/rust-bridge/build.gradle.kts
@@ -12,7 +12,7 @@ android {
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
+ ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -22,12 +22,17 @@ android {
lint {
abortOnError = true
warningsAsErrors = true
+ // The user scoped this milestone to ARM Android; x86 ChromeOS is excluded.
+ disable += "ChromeOsAbiSupport"
}
}
kotlin {
jvmToolchain(25)
- compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
}
dependencies {
@@ -73,6 +78,12 @@ androidComponents.onVariants { variant ->
val nativeOutput = layout.buildDirectory.dir("generated/native/$name/jniLibs")
val rawBindings = layout.buildDirectory.dir("generated/bindings-raw/$name")
val bindings = layout.buildDirectory.dir("generated/bindings/$name")
+ val rustFlags = listOf(
+ "-D warnings",
+ "-C link-arg=-Wl,-z,max-page-size=16384",
+ "-C link-arg=-Wl,-z,common-page-size=16384",
+ "-C link-arg=-Wl,--build-id=sha1"
+ ).joinToString(" ")
val native = tasks.register("build${title}Rust") {
dependsOn(checkRustToolchain)
workingDir = workspace
@@ -81,22 +92,33 @@ androidComponents.onVariants { variant ->
inputs.property("cargoNdk", libs.versions.cargoNdk.get())
inputs.property("ndkVersion", libs.versions.ndk.get())
inputs.property("minSdk", libs.versions.minSdk.get())
+ inputs.property("rustFlags", rustFlags)
+ inputs.property("abis", listOf("arm64-v8a", "armeabi-v7a"))
+ inputs.property("releaseDebug", "2")
+ inputs.property(
+ "devDebug",
+ providers.environmentVariable("CARGO_PROFILE_DEV_DEBUG").orElse("cargo-default")
+ )
+ inputs.property(
+ "incremental",
+ providers.environmentVariable("CARGO_INCREMENTAL").orElse("cargo-default")
+ )
+ inputs.property(
+ "cargoTargetDirectory",
+ providers.environmentVariable("CARGO_TARGET_DIR").orElse("workspace-default")
+ )
outputs.dir(rawNative)
environment("RUSTUP_TOOLCHAIN", rustVersion)
environment("RUSTC", rustcExecutable.get())
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath)
environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath)
- environment(
- "RUSTFLAGS",
- "-D warnings -C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384"
- )
+ environment("RUSTFLAGS", rustFlags)
environment("CARGO_PROFILE_RELEASE_DEBUG", "2")
commandLine(
listOf(
cargoExecutable.get(), "+$rustVersion", "ndk",
"--target", "arm64-v8a",
"--target", "armeabi-v7a",
- "--target", "x86_64",
"--platform", libs.versions.minSdk.get(),
"--output-dir", rawNative.get().asFile.absolutePath,
"build", "--locked", "--package", "ironstorage-android", "--lib"
diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt
new file mode 100644
index 0000000..948186a
--- /dev/null
+++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt
@@ -0,0 +1,26 @@
+package de.rfc1437.ironstorage.bridge
+
+import android.os.Bundle
+import android.os.SystemClock
+import androidx.test.platform.app.InstrumentationRegistry
+import de.rfc1437.ironstorage.bridge.generated.MobileShellState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NativeLoadPerformanceTest {
+ @Test
+ fun reportsNativeLoadAndFirstRustFixtureWithoutSecretData() {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val runtime = NativeRuntime(instrumentation.targetContext)
+ val started = SystemClock.elapsedRealtimeNanos()
+ val shell = runtime.fixture(MobileShellState.EMPTY)
+ val elapsed = SystemClock.elapsedRealtimeNanos() - started
+ assertTrue(elapsed > 0)
+ assertEquals(5, shell.pages.size)
+ instrumentation.sendStatus(
+ 0,
+ Bundle().apply { putLong("ironstorage_native_load_ns", elapsed) }
+ )
+ }
+}
diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt
index 4dbb720..8dadd80 100644
--- a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt
+++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt
@@ -14,6 +14,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileTab
import de.rfc1437.ironstorage.bridge.generated.UniffiRustCallStatus
import de.rfc1437.ironstorage.bridge.generated.uniffiTraitInterfaceCall
import java.io.File
+import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -102,12 +103,17 @@ class NativeRuntimeTest {
@Test
fun missingLibraryAndUnsupportedAbiFailBeforeEnteringRust() {
val abiFailure = assertThrows(NativeBridgeException::class.java) {
- NativeRuntime.supportedAbi(listOf("unsupported-fixture-abi"))
+ NativeRuntime.supportedAbi(listOf("x86_64", "unsupported-fixture-abi"))
}
assertEquals(NativeFailureKind.UnsupportedAbi, abiFailure.kind)
+ assertEquals("arm64-v8a", NativeRuntime.supportedAbi(listOf("x86_64", "arm64-v8a")))
val packageFile = File.createTempFile("native-empty-package", ".apk", context.cacheDir)
try {
- ZipOutputStream(packageFile.outputStream()).close()
+ ZipOutputStream(packageFile.outputStream()).use { archive ->
+ // API 26 rejects a ZIP with no entries; keep this valid package free of native code.
+ archive.putNextEntry(ZipEntry("non-native-fixture"))
+ archive.closeEntry()
+ }
val info = ApplicationInfo(context.applicationInfo).apply {
sourceDir = packageFile.absolutePath
splitSourceDirs = null
diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt
index ce32ca6..bf4dc2b 100644
--- a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt
+++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt
@@ -85,7 +85,7 @@ class NativeRuntime(context: Context) {
private const val LIBRARY_FILE = "libironstorage_android.so"
internal fun supportedAbi(deviceAbis: List): String =
- deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a", "x86_64") }
+ deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a") }
?: throw NativeBridgeException(NativeFailureKind.UnsupportedAbi)
}
}
diff --git a/android/rust-toolchain.toml b/android/rust-toolchain.toml
index 739e30f..f72e1f1 100644
--- a/android/rust-toolchain.toml
+++ b/android/rust-toolchain.toml
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.97.1"
profile = "minimal"
-targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
+targets = ["aarch64-linux-android", "armv7-linux-androideabi"]
diff --git a/docs/android/architecture.md b/docs/android/architecture.md
index 2e55a0d..8ad8f34 100644
--- a/docs/android/architecture.md
+++ b/docs/android/architecture.md
@@ -66,11 +66,12 @@ policy. The installed android-37.0 SDK reports an empty codename and
PreviewSdkInt=0 (revision 2); verify the same package metadata in CI before
pinning the build, rather than relying on preview wording in older instructions.
-Release ABIs: **arm64-v8a**, **armeabi-v7a**, **x86_64**, corresponding to
-**aarch64-linux-android**, **armv7-linux-androideabi**,
-**x86_64-linux-android**. An ABI may be removed only with a dependency audit and
-an amendment to this decision; emulator success alone proves no arm64 hardware
-coverage. All shipped native dependencies, including JNA, must pass ELF LOAD
+Release ABIs: **arm64-v8a** and **armeabi-v7a**, corresponding to
+**aarch64-linux-android** and **armv7-linux-androideabi**. On 2026-10-08 the user
+removed x86_64 Android from the support and CI requirements and specified
+emulator-only testing for every issue. This supersedes the original three-ABI
+decision in #88; historical test evidence remains historical.
+All shipped native dependencies, including JNA, must pass ELF LOAD
alignment, APK ZIP alignment and actual 16 KB runtime checks. Do not depend on
compatibility mode. [Android's page-size guidance](https://developer.android.com/guide/practices/page-sizes)
requires auditing dependencies as well as the app library.
diff --git a/docs/android/ci.md b/docs/android/ci.md
new file mode 100644
index 0000000..f2bdf28
--- /dev/null
+++ b/docs/android/ci.md
@@ -0,0 +1,210 @@
+# Android verification and reproducible ARM builds
+
+Issue #94 uses the existing Gitea `linux-arm64` runner. No macOS runner, physical
+Android device, or x86 Android target is required. The release set is
+`arm64-v8a` and `armeabi-v7a`; the minimum SDK is 26 and compile/target SDK is 37.
+
+## CI jobs and local commands
+
+`.gitea/workflows/android.yml` runs for main, Codex branches and pull requests.
+The Rust job runs the repository formatting, warnings-as-errors workspace
+check, Clippy and workspace tests with Rust 1.97.1. System build libraries are
+installed inside the job container. Native Linux LLD and one build worker bound
+workspace test-linking memory. The CI host test profile uses `line-tables-only`
+debug information for backtrace file/line diagnostics and disables incremental
+caches to bound disk use; every test remains enabled. This does not change the
+Android release profile's required FULL debug-symbol archive.
+The Android job compiles the UniFFI host tool before starting Gradle. Host/debug
+builds use line-table debug information and no incremental caches.
+Release Rust libraries retain their explicit FULL debug information. These
+profile/environment inputs participate in Gradle's native-task cache keys.
+With `CI=true`, Cargo uses the owned `android/build/native-cargo` scratch
+directory. Each native variant is compiled, audited and converted to bindings
+before its Android-target compiler intermediates are removed. Copied libraries,
+generated bindings and the host audit tool remain for the full Gradle verification
+and assembly phase. This bounds peak disk use without removing tests or release
+symbols; ordinary local development retains its Cargo intermediates.
+The Gradle wrapper retries transient distribution-download failures three times
+with bounded exponential backoff; its pinned distribution checksum remains required.
+The Android job has a 150-minute timeout for the measured single-runner build
+and uses one worker, a 1 GB Gradle heap and a 512 MB Kotlin compiler heap. It uses native ARM64 Java 25,
+Rust 1.97.1, cargo-ndk 4.1.2, locked Gradle dependencies and strict SHA-256
+verification. It builds both debug/release Rust ABIs, compiles Kotlin with
+warnings as errors, runs JVM tests, KtLint and Android Lint, compiles Compose
+instrumentation, and builds a debug APK, unsigned release APK and unsigned AAB.
+There are no Play credentials or upload signing keys in this workflow. #112
+owns protected distribution signing; Gitea's `environment` key alone is not a
+protection mechanism.
+
+From the repository root, with `JAVA_HOME` and `ANDROID_HOME` set:
+
+```sh
+cargo fmt --all -- --check
+RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
+cargo clippy --workspace --all-targets -- -D warnings
+cargo test --workspace
+python3 .gitea/scripts/android_ci.py test
+python3 .gitea/scripts/android_ci.py build
+python3 .gitea/scripts/android_ci.py emulator emulator-5554
+```
+
+`CARGO` can specify the absolute rustup Cargo executable. `androidVerification`
+is also directly available through `android/gradlew -p android`. The dependency
+inventory task resolves each module under its own Gradle project lock and
+requires configuration caching to be disabled. Ordinary build tasks retain
+the existing incremental/configuration-cache behavior. Linker flags, selected
+ABIs, SDK/NDK versions and release debug settings are explicit native task inputs.
+
+## Native Linux ARM64 host tools
+
+Google's [NDK r30 Linux download](https://developer.android.com/ndk/downloads)
+contains x86_64 host binaries, independently of the target Android ABI. CI uses
+an explicitly pinned native ARM64 community rebuild of that NDK, following the
+approach described by [zongou/android-build](https://github.com/zongou/android-build).
+`android/ci-toolchains.json` records exact URLs and reviewed SHA-256 hashes for:
+
+- [HomuHomu833 NDK r30, aarch64-linux-gnu](https://github.com/HomuHomu833/android-ndk-custom/releases/tag/r30):
+ revision 30.0.16248370, Clang/LLD 21, Android compiler base r574158c. The inspected
+ compiler identifies its rebuild source as `a6285403ecfb895b7515518d40da02422ffffb20`.
+- [lzhiyong native SDK tools 35.0.2](https://github.com/lzhiyong/android-sdk-tools/releases/tag/35.0.2):
+ statically linked ARM64 AAPT2 selected through Gradle's documented
+ `android.aapt2FromMavenOverride`. This host tool is separately pinned; it is
+ not represented as Google's build-tools 36 binary. A small argument adapter
+ splits AGP's `--resource-path-shortening-map=path` into the option and path
+ required by this AAPT2. Optimization remains enabled and exit codes propagate.
+- Google's Java-based command-line tools 20.0, build 14742923. The newer 23.0
+ SDK manager delegates to a Linux x86_64 executable; the pinned Java manager
+ provisions the exact `platforms;android-37.0` and `build-tools;36.0.0` packages
+ without CPU emulation. APK Analyzer is also Java-based.
+
+`prepare-linux` verifies every downloaded archive before extraction and runs
+native compiler/linker/AAPT2 version probes. cargo-ndk 4.1.2 hardcodes the
+`linux-x86_64` directory name on Linux, so a compatibility directory symlink
+points at the native `linux-arm64` tools. It does not change executable
+architecture. No binfmt registration, privileged container or emulator host
+patch is used. Mac development retains its official installed SDK/NDK.
+
+The community toolchain is an additional supply-chain input. Changing an asset,
+compiler base, hash or host tool requires review and a clean build plus ARM
+emulator validation. A changed release asset fails its committed checksum.
+Retain the NDK's NOTICE files; toolchains are build inputs, not app runtime code.
+
+## Artifact gates and reports
+
+The Rust build tool checks module/source boundaries, subprocess references,
+matching UniFFI metadata across ABIs and every packaged Rust/JNA ELF. APK native
+libraries must be stored and aligned to 16 KB; ELF LOAD segments must also be
+16 KB compatible. Generated bindings preserve contract/function checksums and
+fail closed on a changed generator hardening template. Only the upstream eager
+object-initialization function suppresses Kotlin's `UNUSED_EXPRESSION` false
+positive; application warnings remain errors.
+
+The packaged APK manifest is decoded and audited for the exact application ID,
+backup and cleartext flags, reviewed permissions and exported launcher. The reviewed
+AndroidX profile receiver must retain the privileged `DUMP` permission and exact
+profile actions. Other components must explicitly remain unexported. The unused
+Compose test-host manifest dependency is removed so it cannot export another
+Activity in debug APKs. The packaged manifest's backup resource IDs must match
+the APK's compiled resource table for the reviewed rules. APK Analyzer resolves their actual
+paths, including resource-shortened release paths, and Rust audits those decoded
+APK XML files. Backup rules must exclude
+every credential/device-protected domain from cloud backup and device transfer;
+instrumentation verifies the actual installed XML resources as well.
+The ChromeOS x86 ABI recommendation is disabled because it conflicts with the
+user's explicit ARM-only Android scope. No general Lint baseline is used.
+
+Release Rust libraries must have no debug/symbol-table sections, and each FULL
+`.so.dbg` file must contain debug information and the identical ELF build ID.
+JNA's supplied symbol table must also match its stripped library's build ID.
+The only packaged native libraries allowed are Rust, JNA and AndroidX graphics;
+unreviewed names or duplicate entries fail.
+AGP can include symbols for JNA's unselected ABIs; the published symbol ZIP
+selects only both supported ARM directories, then the Rust gate verifies it
+against the actual release APK. The artifact checksum manifest covers the
+packages, symbol ZIP, R8 mapping, decoded manifests, test summaries and SBOM.
+
+The CycloneDX inventory includes actual resolved Android application/test
+artifacts, their configurations and SHA-256 values, plus the Rust normal feature
+graph for both ARM targets with Cargo.lock registry hashes. It is a dependency
+inventory, not a claim that every graph crate survives linker elimination.
+Exact Android component versions and Rust license expressions require review
+in `dependency-policy.json`. Runtime/test JGit, Bouncy Castle, Room, Retrofit,
+OkHttp, PGPainless and ML Kit dependencies fail even transitively. Lint's own
+build-tool dependencies are separate from the app's domain boundary. Gradle
+strict verification additionally covers build/plugin/POM/module artifacts;
+RustSec runs in the existing security-audit workflow.
+
+Published JVM reports retain only hashed identifiers and pass/fail/error/skip
+status. Assertion messages, parameter values, stack traces, properties, test
+stdout/stderr and XML names are omitted. Failures remain failures and make the
+gate fail; empty/missing/invalid reports are not accepted. Raw instrumentation
+failures remain in `android/build/local-validation` with private permissions,
+never in `ci-artifacts`. Do not upload emulator snapshots, app containers,
+credential files, screenshot dumps or raw logs containing application data.
+
+## Emulator matrix and deterministic fixtures
+
+The existing Gitea runner has no supported native ARM Android Emulator package.
+Google's current Linux emulator archives are x86_64 host binaries. Instrumented
+validation therefore runs locally on the available ARM emulators; this limitation
+is explicit rather than a silently skipped/passing CI emulator job. No additional
+runner or physical device is provisioned.
+
+Required local matrix for this issue:
+
+| ARM emulator | Checks |
+| --- | --- |
+| API 26, 4 KB | Minimum-SDK native/JNI and Compose tests; installed backup resources; cold starts and native load |
+| API 37, 4 KB | Current-SDK same tests; update over existing app data |
+| API 37, strict 16 KB | Same tests with compatibility mode disabled; ELF/ZIP gate is not a substitute |
+
+ARMv7 binaries are compiled and audited; an ARM64-only system image cannot run
+an ARMv7-only APK. Record that runtime limit without requiring an x86 or physical
+device. API 26 lacks `getconf`; the controller reads its kernel page-size facts
+from `/proc/self/smaps`. ADB validates emulator identity and rejects physical
+and unsupported-ABI targets. Installs use `-r`; force-stop/relaunch changes only
+process state. Never uninstall or wipe existing test state.
+
+Rust's shell fixtures cover all 25 tab/state combinations. Existing fake clocks
+exercise lease expiry, background/foreground boundaries and activity refresh in
+workspace tests. Rust secret-backend fixtures cover every typed protection/error
+result and late completion. Kotlin's controlled coroutine fixture verifies that
+cancelled work cannot replace newer state. Generated JNI tests cover secret
+redaction, checksum/contract rejection, callback ownership/cancellation and
+Keystore lockout. The private-runtime fixture creates a public-only password
+store through Rust and reopens it, checking durable layout/tab state. Platform
+Keystore tests from #93 remain available separately for real emulator biometrics;
+they require synthetic enrollment and active-prompt ADB control, not a real
+user's passphrase.
+
+## Performance baseline
+
+The controller records five process-cold Activity `TotalTime` samples and five
+fresh-process native-load/first-Rust-fixture samples using monotonic Android
+nanoseconds. Reports record SDK, ABI and actual page size. These are observations,
+not flaky latency pass/fail thresholds. Never compare a debug emulator result to
+a signed physical-device release measurement.
+
+Initial #94 local debug baseline (five samples, medians):
+
+| ARM64 image | Activity cold start | Native load and first Rust fixture |
+| --- | ---: | ---: |
+| API 26, 4 KB | 161 ms | 25.4 ms |
+| API 37, 4 KB | 896 ms | 42.7 ms |
+| API 37, strict 16 KB | 730 ms | 62.0 ms |
+
+All three images passed ten JNI tests and two app/Compose tests covering all
+25 Rust shell states and installed backup resources. The community-toolchain
+Linux debug APK and both test APKs additionally passed those same suites on the
+strict 16 KB image after re-signing with the existing local debug identity;
+installation preserved the app data. Original Linux release packages remain
+unsigned. Emulators ran concurrently with build work, so these observations
+are a reproducible measurement method, not a calibrated performance budget.
+
+#105 will add Macrobenchmark/Baseline Profiles once representative Android
+screens exist. Its case definitions are: first page and scroll in 10,000 synthetic
+entries; search update after each query edit; warm cached TOTP page with 100
+synthetic seeds and an injected clock; editor open, field edits, reorder and save.
+Use generated public fixtures, fixed queries/clocks, ten warm-up iterations and
+30 measured iterations; record median/p95, allocations and device/API/toolchain.
+Do not substitute the current shell for measurements of unimplemented features.
diff --git a/docs/android/parity.md b/docs/android/parity.md
index 272f513..1c842e7 100644
--- a/docs/android/parity.md
+++ b/docs/android/parity.md
@@ -64,7 +64,7 @@ milestone. This list gives ownership, not permission to skip acceptance criteria
| [#89 Gradle scaffold/module boundaries](https://git.rfc1437.de/hugo/IronStorage/issues/89) | Android build/app/core/platform/bridge modules | Kotlin DSL/catalog/wrapper, Compose/Hilt/KSP, source/dependency gates, all shell fixtures; clean debug APK |
| [#90 Private paths/config/cache/backup](https://git.rfc1437.de/hugo/IronStorage/issues/90) | crates/storage plus Android path adapter | Context roots/capabilities, atomic layout/migrations, backup rules; Rust and sandbox/update tests |
| [#91 Platform-neutral mobile contract](https://git.rfc1437.de/hugo/IronStorage/issues/91) | crates/storage and shared UniFFI bridge | Typed semantic actions/icons, opt-in Watch data, platform callbacks, shared Swift/Kotlin inventory tests |
-| [#92 Native Rust/UniFFI packaging](https://git.rfc1437.de/hugo/IronStorage/issues/92) | Shared/Android Rust bridge and Gradle rust-bridge | cargo-ndk, generated immutable Kotlin/JNA Android, three ABIs, checksums/16 KB/loading/debug symbols |
+| [#92 Native Rust/UniFFI packaging](https://git.rfc1437.de/hugo/IronStorage/issues/92) | Shared/Android Rust bridge and Gradle rust-bridge | cargo-ndk, generated immutable Kotlin/JNA Android, both ARM ABIs, checksums/16 KB/loading/debug symbols |
| [#93 Keystore adapter](https://git.rfc1437.de/hugo/IronStorage/issues/93) | Delivered: Rust sealed-record orchestration and Android OS adapter | [Keystore policy and recovery](keystore.md); native AES-GCM/CryptoObject and Rust lifecycle tests |
| [#94 CI/static analysis/reproducibility](https://git.rfc1437.de/hugo/IronStorage/issues/94) | Build/CI/test infrastructure | Cargo gates, Gradle build/Lint/ktlint, JVM/Compose/instrumented tests, verification/locks/SBOM/ABI checks |
| [#95 Local-first/HTTPS onboarding](https://git.rfc1437.de/hugo/IronStorage/issues/95) | storage mobile_onboarding plus Compose features | Existing Rust initialization/discovery/clone/credentials; no Kotlin transport |
diff --git a/tools/android-build/Cargo.toml b/tools/android-build/Cargo.toml
index 882278d..52d25a4 100644
--- a/tools/android-build/Cargo.toml
+++ b/tools/android-build/Cargo.toml
@@ -18,6 +18,12 @@ camino = "1.2"
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
+serde.workspace = true
+serde_json.workspace = true
+sha2.workspace = true
+roxmltree = "0.20.0"
+toml.workspace = true
+
[[bin]]
name = "ironstorage-uniffi-bindgen"
path = "src/bindgen.rs"
diff --git a/tools/android-build/src/bindings.rs b/tools/android-build/src/bindings.rs
index 28b7aaa..51c5b00 100644
--- a/tools/android-build/src/bindings.rs
+++ b/tools/android-build/src/bindings.rs
@@ -59,6 +59,17 @@ fn harden_source(source: &str) -> Result> {
if !source.contains(internal) || source.matches(callback).count() != 2 {
return Err("UniFFI unexpected-error template changed; review secret redaction".into());
}
+ let initializer = "public fun uniffiEnsureInitialized() {";
+ if source.matches(initializer).count() != 1 {
+ return Err(
+ "UniFFI initialization template changed; review eager contract initialization".into(),
+ );
+ }
+ // Object references deliberately trigger Kotlin initialization and integrity guards.
+ let source = source.replace(
+ initializer,
+ "@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized() {",
+ );
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
// Callback results are operation-owned; erase plaintext after copying into the wire buffer.
@@ -121,11 +132,16 @@ fn harden_source(source: &str) -> Result> {
mod tests {
use super::*;
- const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
+ const SOURCE: &str = "public fun uniffiEnsureInitialized() {\n IntegrityCheckingUniffiLib\n UniffiLib\n}\ndata class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
#[test]
fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() {
let hardened = harden_source(SOURCE).unwrap();
+ assert!(
+ hardened
+ .contains("@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized()")
+ );
+ assert!(hardened.contains(" IntegrityCheckingUniffiLib\n"));
assert!(hardened.contains("val value: String"));
assert!(hardened.contains("Secret([REDACTED])"));
assert!(!hardened.contains("get() = \"value="));
diff --git a/tools/android-build/src/gates.rs b/tools/android-build/src/gates.rs
new file mode 100644
index 0000000..a79894a
--- /dev/null
+++ b/tools/android-build/src/gates.rs
@@ -0,0 +1,579 @@
+//! Build artifact gates. Inputs and diagnostics never contain application data.
+use roxmltree::{Document, Node};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ error::Error,
+ fs,
+ path::Path,
+};
+
+const ANDROID: &str = "http://schemas.android.com/apk/res/android";
+const DOMAINS: [&str; 9] = [
+ "root",
+ "file",
+ "database",
+ "sharedpref",
+ "external",
+ "device_root",
+ "device_file",
+ "device_database",
+ "device_sharedpref",
+];
+
+pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(), Box> {
+ manifest_text(&fs::read_to_string(manifest)?)?;
+ backup_text(&fs::read_to_string(backup)?, false)?;
+ backup_text(&fs::read_to_string(extraction)?, true)?;
+ println!("Verified packaged manifest and complete backup/transfer exclusions");
+ Ok(())
+}
+
+fn manifest_text(source: &str) -> Result<(), Box> {
+ let document = Document::parse(source)?;
+ let root = document.root_element();
+ if root.tag_name().name() != "manifest"
+ || root.attribute("package") != Some("de.rfc1437.ironstorage")
+ {
+ return Err("unexpected manifest identity".into());
+ }
+ let application = root
+ .children()
+ .find(|node| node.has_tag_name("application"))
+ .ok_or("application missing")?;
+ for (name, expected) in [("allowBackup", "false"), ("usesCleartextTraffic", "false")] {
+ if application.attribute((ANDROID, name)) != Some(expected) {
+ return Err(format!("unsafe application flag: {name}").into());
+ }
+ }
+ for name in ["fullBackupContent", "dataExtractionRules"] {
+ if !application
+ .attribute((ANDROID, name))
+ .is_some_and(|value| value.starts_with('@'))
+ {
+ return Err("backup rules missing from packaged manifest".into());
+ }
+ }
+ let allowed = [
+ "android.permission.USE_BIOMETRIC",
+ "android.permission.USE_FINGERPRINT",
+ "de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
+ ];
+ for node in root.children().filter(|node| node.is_element()) {
+ if node.tag_name().name().starts_with("uses-permission")
+ && !allowed.contains(&node.attribute((ANDROID, "name")).unwrap_or(""))
+ {
+ return Err("unreviewed packaged permission".into());
+ }
+ if node.has_tag_name("permission")
+ && (node.attribute((ANDROID, "name")) != Some(allowed[2])
+ || !matches!(
+ node.attribute((ANDROID, "protectionLevel")),
+ Some("signature" | "0x2")
+ ))
+ {
+ return Err("unreviewed custom permission".into());
+ }
+ }
+ let mut launchers = 0;
+ for node in application.children().filter(|node| {
+ [
+ "activity",
+ "activity-alias",
+ "service",
+ "receiver",
+ "provider",
+ ]
+ .contains(&node.tag_name().name())
+ }) {
+ let launcher =
+ node.attribute((ANDROID, "name")) == Some("de.rfc1437.ironstorage.MainActivity");
+ if launcher {
+ launchers += 1;
+ if node.attribute((ANDROID, "exported")) != Some("true") {
+ return Err("launcher must be exported".into());
+ }
+ let actions: BTreeSet<_> = node
+ .descendants()
+ .filter(|n| n.has_tag_name("action") || n.has_tag_name("category"))
+ .filter_map(|n| n.attribute((ANDROID, "name")))
+ .collect();
+ if actions
+ != BTreeSet::from([
+ "android.intent.action.MAIN",
+ "android.intent.category.LAUNCHER",
+ ])
+ {
+ return Err("unreviewed launcher intent".into());
+ }
+ } else if node.has_tag_name("receiver")
+ && node.attribute((ANDROID, "name"))
+ == Some("androidx.profileinstaller.ProfileInstallReceiver")
+ {
+ let actions: BTreeSet<_> = node
+ .descendants()
+ .filter(|n| n.has_tag_name("action"))
+ .filter_map(|n| n.attribute((ANDROID, "name")))
+ .collect();
+ if node.attribute((ANDROID, "permission")) != Some("android.permission.DUMP")
+ || node.attribute((ANDROID, "exported")) != Some("true")
+ || actions
+ != BTreeSet::from([
+ "androidx.profileinstaller.action.INSTALL_PROFILE",
+ "androidx.profileinstaller.action.SKIP_FILE",
+ "androidx.profileinstaller.action.SAVE_PROFILE",
+ "androidx.profileinstaller.action.BENCHMARK_OPERATION",
+ ])
+ {
+ return Err(
+ "profile receiver must retain its privileged permission and reviewed actions"
+ .into(),
+ );
+ }
+ } else if node.attribute((ANDROID, "exported")) != Some("false") {
+ return Err("unreviewed exported component".into());
+ }
+ }
+ if launchers != 1 {
+ return Err("exactly one launcher required".into());
+ }
+ Ok(())
+}
+
+fn backup_text(source: &str, extraction: bool) -> Result<(), Box> {
+ let document = Document::parse(source)?;
+ let root = document.root_element();
+ let sections = if extraction {
+ if !root.has_tag_name("data-extraction-rules") {
+ return Err("wrong extraction root".into());
+ }
+ let nodes: Vec<_> = root.children().filter(Node::is_element).collect();
+ if nodes.len() != 2
+ || nodes
+ .iter()
+ .map(|n| n.tag_name().name())
+ .collect::>()
+ != BTreeSet::from(["cloud-backup", "device-transfer"])
+ {
+ return Err("backup/transfer section missing".into());
+ }
+ nodes
+ } else {
+ if !root.has_tag_name("full-backup-content") {
+ return Err("wrong backup root".into());
+ }
+ vec![root]
+ };
+ for section in sections {
+ let mut domains = BTreeSet::new();
+ for node in section.children().filter(Node::is_element) {
+ if !node.has_tag_name("exclude")
+ || node.attribute("path") != Some(".")
+ || !domains.insert(node.attribute("domain").ok_or("missing domain")?)
+ {
+ return Err("backup rules must exclude each entire domain once".into());
+ }
+ }
+ if domains != BTreeSet::from(DOMAINS) {
+ return Err("backup excludes an incomplete domain set".into());
+ }
+ }
+ Ok(())
+}
+
+fn hash(bytes: &[u8]) -> String {
+ format!("{:x}", Sha256::digest(bytes))
+}
+
+#[derive(Serialize)]
+struct TestResult {
+ id: String,
+ status: &'static str,
+}
+
+fn report(source: &str) -> Result, Box> {
+ let document = Document::parse(source)?;
+ let mut results = Vec::new();
+ for node in document
+ .descendants()
+ .filter(|n| n.has_tag_name("testsuite"))
+ {
+ for attribute in ["errors", "failures"] {
+ let count: usize = node.attribute(attribute).unwrap_or("0").parse()?;
+ let actual = node
+ .children()
+ .filter(|n| {
+ n.has_tag_name("testcase")
+ && n.children().any(|child| {
+ child.has_tag_name(if attribute == "errors" {
+ "error"
+ } else {
+ "failure"
+ })
+ })
+ })
+ .count();
+ if count > actual {
+ results.push(TestResult {
+ id: hash(
+ format!("{}:{attribute}", node.attribute("name").unwrap_or("")).as_bytes(),
+ ),
+ status: "error",
+ });
+ }
+ }
+ }
+ for node in document
+ .descendants()
+ .filter(|n| n.has_tag_name("testcase"))
+ {
+ let status = if node.children().any(|n| n.has_tag_name("failure")) {
+ "failure"
+ } else if node.children().any(|n| n.has_tag_name("error")) {
+ "error"
+ } else if node.children().any(|n| n.has_tag_name("skipped")) {
+ "skipped"
+ } else {
+ "passed"
+ };
+ let identity = format!(
+ "{}:{}",
+ node.attribute("classname").unwrap_or(""),
+ node.attribute("name").unwrap_or("")
+ );
+ results.push(TestResult {
+ id: hash(identity.as_bytes()),
+ status,
+ });
+ }
+ if results.is_empty() {
+ return Err("JUnit report has no results".into());
+ }
+ Ok(results)
+}
+
+pub fn reports(input: &Path, output: &Path) -> Result<(), Box> {
+ let input = input.canonicalize()?;
+ let destination = if output.exists() {
+ output.canonicalize()?
+ } else {
+ output
+ .parent()
+ .ok_or("safe report directory missing")?
+ .canonicalize()?
+ .join(output.file_name().ok_or("safe report filename missing")?)
+ };
+ if destination.starts_with(&input) {
+ return Err("safe reports must be outside raw report directory".into());
+ }
+ let mut results = Vec::new();
+ collect_reports(&input, &mut results)?;
+ if results.is_empty() {
+ return Err("JUnit reports missing".into());
+ }
+ let failed = results
+ .iter()
+ .any(|r| matches!(r.status, "error" | "failure"));
+ fs::write(output, serde_json::to_vec_pretty(&results)?)?;
+ if failed {
+ return Err("test failures retained in sanitized report".into());
+ }
+ println!(
+ "Sanitized {} test results; only hashed identifiers and status retained",
+ results.len()
+ );
+ Ok(())
+}
+
+fn collect_reports(input: &Path, output: &mut Vec) -> Result<(), Box> {
+ let mut entries: Vec<_> = fs::read_dir(input)?.collect::>()?;
+ entries.sort_by_key(|e| e.file_name());
+ for entry in entries {
+ let kind = entry.file_type()?;
+ if kind.is_symlink() {
+ return Err("report symlink rejected".into());
+ }
+ if kind.is_dir() {
+ collect_reports(&entry.path(), output)?;
+ } else if entry.file_name().to_string_lossy().starts_with("TEST-")
+ && entry.path().extension().is_some_and(|ext| ext == "xml")
+ {
+ if entry.metadata()?.len() > 16 * 1024 * 1024 {
+ return Err("report exceeds bound".into());
+ }
+ output.extend(report(&fs::read_to_string(entry.path())?)?);
+ }
+ }
+ Ok(())
+}
+
+#[derive(Deserialize)]
+struct Policy {
+ android: BTreeMap,
+ rust_licenses: BTreeSet,
+}
+#[derive(Deserialize)]
+struct AndroidDependency {
+ group: String,
+ name: String,
+ version: String,
+ sha256: String,
+ scope: String,
+}
+
+pub fn sbom(
+ android: &Path,
+ rust: &Path,
+ lock: &Path,
+ policy: &Path,
+ output: &Path,
+) -> Result<(), Box> {
+ let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?;
+ let dependencies: Vec = serde_json::from_slice(&fs::read(android)?)?;
+ if dependencies.is_empty() {
+ return Err("Android dependency inventory empty".into());
+ }
+ let lock: toml::Value = toml::from_str(&fs::read_to_string(lock)?)?;
+ let packages = lock
+ .get("package")
+ .and_then(toml::Value::as_array)
+ .ok_or("Cargo lock packages missing")?;
+ let mut components = BTreeMap::new();
+ for dependency in dependencies {
+ let license = android_license(
+ &dependency.group,
+ &dependency.name,
+ &dependency.version,
+ &policy,
+ )?;
+ if dependency.version.is_empty()
+ || dependency.version.contains(['+', '[', ']', '(', ')', ','])
+ || dependency.version.contains("SNAPSHOT")
+ || dependency.sha256.len() != 64
+ || !dependency.sha256.bytes().all(|b| b.is_ascii_hexdigit())
+ {
+ return Err("dependency must have a fixed version and SHA-256".into());
+ }
+ let purl = format!(
+ "pkg:maven/{}/{}@{}",
+ dependency.group, dependency.name, dependency.version
+ );
+ let key = format!("{purl}#{}#{}", dependency.sha256, dependency.scope);
+ components.insert(key.clone(), serde_json::json!({"type":"library", "bom-ref":key, "group":dependency.group, "name":dependency.name, "version":dependency.version, "purl":purl, "hashes":[{"alg":"SHA-256","content":dependency.sha256}], "licenses":[{"expression":license}], "properties":[{"name":"ironstorage:configuration","value":dependency.scope}]}));
+ }
+ for line in fs::read_to_string(rust)?
+ .lines()
+ .filter(|line| !line.is_empty())
+ {
+ let fields: Vec<_> = line.split('|').collect();
+ if fields.len() != 3 || !policy.rust_licenses.contains(fields[1]) {
+ return Err("Rust dependency license needs review".into());
+ }
+ let (name, remainder) = fields[0]
+ .split_once(" v")
+ .ok_or("invalid Rust dependency")?;
+ let version = remainder
+ .split_whitespace()
+ .next()
+ .ok_or("missing Rust version")?;
+ let purl = format!("pkg:cargo/{name}@{version}");
+ let package = packages
+ .iter()
+ .find(|p| {
+ p.get("name").and_then(toml::Value::as_str) == Some(name)
+ && p.get("version").and_then(toml::Value::as_str) == Some(version)
+ })
+ .ok_or("Rust inventory differs from Cargo.lock")?;
+ let mut component = serde_json::json!({"type":"library", "bom-ref":purl, "name":name,"version":version,"purl":purl,"licenses":[{"expression":fields[1].replace(" / ", " OR ").replace('/', " OR ")}],"properties":[{"name":"ironstorage:configuration","value":"Android ARM normal dependencies (Cargo feature graph)"}]});
+ if let Some(checksum) = package.get("checksum").and_then(toml::Value::as_str) {
+ component["hashes"] = serde_json::json!([{"alg":"SHA-256", "content":checksum}]);
+ } else if !name.starts_with("ironstorage") {
+ return Err("Rust dependency lacks a registry checksum".into());
+ }
+ components.insert(purl.clone(), component);
+ }
+ if !components
+ .keys()
+ .any(|k| k.starts_with("pkg:cargo/ironstorage-android@"))
+ {
+ return Err("Android Rust root missing".into());
+ }
+ let value = serde_json::json!({"bomFormat":"CycloneDX", "specVersion":"1.6", "version":1, "components":components.into_values().collect::>()});
+ fs::write(output, serde_json::to_vec_pretty(&value)?)?;
+ println!("Dependency/license policy passed; deterministic ARM CycloneDX inventory written");
+ Ok(())
+}
+
+fn android_license<'a>(
+ group: &str,
+ name: &str,
+ version: &str,
+ policy: &'a Policy,
+) -> Result<&'a str, Box> {
+ for forbidden in [
+ "org.eclipse.jgit",
+ "org.bouncycastle",
+ "androidx.room",
+ "com.squareup.retrofit2",
+ "com.squareup.okhttp3",
+ "org.pgpainless",
+ "com.google.mlkit",
+ ] {
+ if group == forbidden || group.starts_with(&format!("{forbidden}.")) {
+ return Err("Android domain dependency forbidden".into());
+ }
+ }
+ policy
+ .android
+ .get(&format!("{group}:{name}:{version}"))
+ .map(String::as_str)
+ .ok_or_else(|| "Android dependency license needs review".into())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn manifest_rejects_backup_permissions_and_exports() {
+ let source = format!(
+ r#""#
+ );
+ assert!(manifest_text(&source).is_ok());
+ for bad in [
+ source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
+ source.replace(
+ "provider android:name=\"fixture\" android:exported=\"false\"",
+ "provider android:name=\"fixture\" android:exported=\"true\"",
+ ),
+ source.replace(
+ ""#
+ );
+ assert!(manifest_text(&launcher).is_ok());
+ assert!(manifest_text(&launcher.replace("0x2", "0x0")).is_err());
+ assert!(
+ manifest_text(
+ &launcher.replace("android.permission.DUMP", "android.permission.INTERNET")
+ )
+ .is_err()
+ );
+ assert!(manifest_text(&launcher.replace("INSTALL_PROFILE", "UNREVIEWED")).is_err());
+ }
+
+ #[test]
+ fn backup_requires_both_sections_all_domains_and_no_include() {
+ let exclusions = DOMAINS
+ .map(|d| format!(r#""#))
+ .join("");
+ let source = format!(
+ "{exclusions}{exclusions}"
+ );
+ assert!(backup_text(&source, true).is_ok());
+ assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err());
+ assert!(backup_text(&source.replace("", ""), true).is_err());
+ }
+ #[test]
+ fn reports_preserve_failures_without_names_messages_or_output() {
+ let secret = "otpauth://totp/secret?secret=FIXTURE";
+ let source = format!(
+ r#"{secret}{secret}"#
+ );
+ let results = report(&source).unwrap();
+ assert_eq!(
+ results.iter().map(|r| r.status).collect::>(),
+ ["error", "failure", "passed"]
+ );
+ let encoded = serde_json::to_string(&results).unwrap();
+ assert!(!encoded.contains("otpauth"));
+ assert!(!encoded.contains("FIXTURE"));
+ assert!(report("").is_err());
+ }
+ #[test]
+ fn dependency_policy_rejects_transitive_domain_libraries_and_unknown_groups() {
+ let policy = Policy {
+ android: BTreeMap::from([
+ ("androidx.core:core:1".into(), "Apache-2.0".into()),
+ ("org.bouncycastle:fixture:1".into(), "MIT".into()),
+ ]),
+ rust_licenses: BTreeSet::new(),
+ };
+ assert_eq!(
+ android_license("androidx.core", "core", "1", &policy).unwrap(),
+ "Apache-2.0"
+ );
+ assert!(android_license("org.bouncycastle", "fixture", "1", &policy).is_err());
+ assert!(android_license("org.bouncycastle.extra", "fixture", "1", &policy).is_err());
+ assert!(android_license("unknown", "fixture", "1", &policy).is_err());
+ assert!(android_license("androidx.core", "core", "2", &policy).is_err());
+ }
+
+ #[test]
+ fn filesystem_gates_reject_missing_hashes_lock_drift_and_raw_report_destination() {
+ let directory = std::env::temp_dir().join(format!(
+ "ironstorage-artifact-gates-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ fs::create_dir(&directory).unwrap();
+ let android = directory.join("android.json");
+ let rust = directory.join("rust.txt");
+ let lock = directory.join("Cargo.lock");
+ let policy = directory.join("policy.json");
+ let output = directory.join("sbom.json");
+ let inventory = serde_json::json!([{"group":"androidx.core", "name":"core", "version":"1", "sha256":"a".repeat(64), "scope":"debug"}]);
+ fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
+ fs::write(
+ &rust,
+ "ironstorage-android v1.0.1|MIT|\nfixture v1.0.0|MIT|\n",
+ )
+ .unwrap();
+ fs::write(&lock, format!("[[package]]\nname='ironstorage-android'\nversion='1.0.1'\n[[package]]\nname='fixture'\nversion='1.0.0'\nchecksum='{}'\n", "b".repeat(64))).unwrap();
+ fs::write(
+ &policy,
+ r#"{"android":{"androidx.core:core:1":"Apache-2.0"},"rust_licenses":["MIT"]}"#,
+ )
+ .unwrap();
+ sbom(&android, &rust, &lock, &policy, &output).unwrap();
+ let encoded: serde_json::Value =
+ serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
+ assert_eq!(encoded["components"].as_array().unwrap().len(), 3);
+ let mut invalid = inventory.clone();
+ invalid[0]["sha256"] = "missing".into();
+ fs::write(&android, serde_json::to_vec(&invalid).unwrap()).unwrap();
+ assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
+ fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
+ fs::write(
+ &rust,
+ "ironstorage-android v1.0.1|MIT|\nfixture v2.0.0|MIT|\n",
+ )
+ .unwrap();
+ assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
+ fs::write(&rust, "fixture v1.0.0|MIT|\n").unwrap();
+ assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
+ let raw = directory.join("raw");
+ fs::create_dir(&raw).unwrap();
+ fs::write(
+ raw.join("TEST-fixture.xml"),
+ "",
+ )
+ .unwrap();
+ assert!(reports(&raw, &raw.join("safe.json")).is_err());
+ assert!(!raw.join("safe.json").exists());
+ reports(&raw, &directory.join("safe.json")).unwrap();
+ fs::remove_dir_all(directory).unwrap();
+ }
+}
diff --git a/tools/android-build/src/main.rs b/tools/android-build/src/main.rs
index 8ce3436..5c2e3e8 100644
--- a/tools/android-build/src/main.rs
+++ b/tools/android-build/src/main.rs
@@ -5,6 +5,7 @@ use std::{env, error::Error, fs, path::Path};
mod bindings;
mod contract;
+mod gates;
mod native;
mod toolchain;
@@ -51,6 +52,28 @@ fn main() -> Result<(), Box> {
[_, command, directory] if command == "audit-native" => {
native::audit_directory(Path::new(directory))?
}
+ [_, command, manifest, backup, extraction] if command == "audit-manifest" => {
+ gates::manifest(
+ Path::new(manifest),
+ Path::new(backup),
+ Path::new(extraction),
+ )?;
+ }
+ [_, command, package, symbols] if command == "audit-release" => {
+ native::audit_release(Path::new(package), Path::new(symbols))?;
+ }
+ [_, command, input, output] if command == "safe-reports" => {
+ gates::reports(Path::new(input), Path::new(output))?;
+ }
+ [_, command, android, rust, lock, policy, output] if command == "sbom" => {
+ gates::sbom(
+ Path::new(android),
+ Path::new(rust),
+ Path::new(lock),
+ Path::new(policy),
+ Path::new(output),
+ )?;
+ }
[_, command, cargo, rust, ndk, ndk_version, cargo_ndk] if command == "check-toolchain" => {
toolchain::check(
Path::new(cargo),
@@ -62,7 +85,7 @@ fn main() -> Result<(), Box> {
}
_ => {
return Err(
- "usage: ironstorage-android-build ".into(),
+ "usage: ironstorage-android-build ".into(),
);
}
}
diff --git a/tools/android-build/src/native.rs b/tools/android-build/src/native.rs
index 18edd99..bbb10a1 100644
--- a/tools/android-build/src/native.rs
+++ b/tools/android-build/src/native.rs
@@ -5,7 +5,7 @@ use std::{collections::BTreeMap, error::Error, fs::File, io::Read, path::Path};
use object::{Architecture, BinaryFormat, Object, ObjectSection, ObjectSegment, ObjectSymbol};
const PAGE_SIZE: u64 = 16_384;
-const ABIS: [&str; 3] = ["arm64-v8a", "armeabi-v7a", "x86_64"];
+const ABIS: [&str; 2] = ["arm64-v8a", "armeabi-v7a"];
pub fn package_directory(input: &Path, output: &Path) -> Result<(), Box> {
audit_directory(input)?;
@@ -71,7 +71,7 @@ pub fn audit_directory(directory: &Path) -> Result<(), Box> {
expected = Some(metadata);
}
}
- println!("Verified three native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
+ println!("Verified both ARM native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
Ok(())
}
@@ -79,7 +79,6 @@ pub fn audit_elf(bytes: &[u8], abi: &str) -> Result<(), Box> {
let expected = match abi {
"arm64-v8a" => Architecture::Aarch64,
"armeabi-v7a" => Architecture::Arm,
- "x86_64" => Architecture::X86_64,
_ => return Err("unapproved native ABI".into()),
};
let library = object::File::parse(bytes)?;
@@ -111,6 +110,61 @@ pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box> {
audit_package_abis(path, &[abi])
}
+pub fn audit_release(package: &Path, symbols: &Path) -> Result<(), Box> {
+ audit_package(package)?;
+ let mut package = zip::ZipArchive::new(File::open(package)?)?;
+ let mut symbols = zip::ZipArchive::new(File::open(symbols)?)?;
+ for abi in ABIS {
+ for library in ["libironstorage_android.so", "libjnidispatch.so"] {
+ let mut bytes = Vec::new();
+ package
+ .by_name(&format!("lib/{abi}/{library}"))?
+ .read_to_end(&mut bytes)?;
+ let mut debug = Vec::new();
+ let mut entry = symbols.by_name(&format!("{abi}/{library}.dbg"))?;
+ if entry.size() > 512 * 1024 * 1024 {
+ return Err("native debug symbols exceed the audit bound".into());
+ }
+ entry.read_to_end(&mut debug)?;
+ audit_symbol_pair(&bytes, &debug, abi, library == "libironstorage_android.so")?;
+ }
+ }
+ println!("Release libraries are stripped; FULL symbols match both ARM build IDs");
+ Ok(())
+}
+
+fn audit_symbol_pair(
+ bytes: &[u8],
+ debug: &[u8],
+ abi: &str,
+ require_debug_info: bool,
+) -> Result<(), Box> {
+ audit_elf(bytes, abi)?;
+ let release = object::File::parse(bytes)?;
+ let symbols = object::File::parse(debug)?;
+ if symbols.format() != BinaryFormat::Elf || symbols.architecture() != release.architecture() {
+ return Err("debug archive architecture does not match release".into());
+ }
+ if release.sections().any(|s| {
+ s.name()
+ .is_ok_and(|n| n.starts_with(".debug_") || n == ".symtab")
+ }) {
+ return Err("release library contains unstripped debug symbols".into());
+ }
+ if (require_debug_info && symbols.section_by_name(".debug_info").is_none())
+ || symbols.symbols().next().is_none()
+ {
+ return Err("native symbol archive lacks required symbols/debug information".into());
+ }
+ let id = release
+ .build_id()?
+ .ok_or("release library lacks a build ID")?;
+ if id.is_empty() || Some(id) != symbols.build_id()? {
+ return Err("native symbol archive does not match release build ID".into());
+ }
+ Ok(())
+}
+
fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box> {
let apk = path.extension().is_some_and(|ext| ext == "apk");
if !apk && !path.extension().is_some_and(|ext| ext == "aab") {
@@ -134,6 +188,18 @@ fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box>
if !abis.contains(&abi) || library.contains('/') {
return Err("package contains an unapproved native library path".into());
}
+ if ![
+ "libironstorage_android.so",
+ "libjnidispatch.so",
+ "libandroidx.graphics.path.so",
+ ]
+ .contains(&library)
+ || libraries
+ .get(abi)
+ .is_some_and(|names| names.iter().any(|name| name == library))
+ {
+ return Err("package contains an unreviewed or duplicate native library".into());
+ }
if apk
&& (file.compression() != zip::CompressionMethod::Stored
|| file.data_start() % PAGE_SIZE != 0)
@@ -205,4 +271,14 @@ mod tests {
assert!(audit_elf(b"\x7fELF", "x86_64").is_err());
assert!(audit_elf(&[], "mips").is_err());
}
+
+ #[test]
+ fn symbol_gate_rejects_missing_full_symbols_and_build_identity() {
+ assert!(
+ audit_symbol_pair(&elf(PAGE_SIZE, 0), &elf(PAGE_SIZE, 0), "arm64-v8a", true).is_err()
+ );
+ assert!(
+ audit_symbol_pair(&elf(PAGE_SIZE, 0), b"wrong archive", "arm64-v8a", false).is_err()
+ );
+ }
}