diff --git a/.github/workflows/android-arm64-debug.yml b/.github/workflows/android-arm64-debug.yml new file mode 100644 index 000000000..cfedc79a1 --- /dev/null +++ b/.github/workflows/android-arm64-debug.yml @@ -0,0 +1,87 @@ +name: Android ARM64 debug + +on: + push: + branches: + - 'candidate/android-*' + +permissions: + contents: read + +concurrency: + group: android-arm64-debug-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.repository == 'Mafei/voltius' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_BUILD_JOBS: '4' + VOLTIUS_REQUIRE_SEEDED_PLUGINS: '1' + defaults: + run: + shell: bash + steps: + - name: Checkout the triggering commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '24.19.0' + + - name: Setup Java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: '21' + + - name: Setup Android SDK + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 + with: + packages: platform-tools + + - name: Install the pinned build tools + run: | + sdkmanager 'platforms;android-36' 'build-tools;36.0.0' 'ndk;27.1.12297006' + echo "NDK_HOME=$ANDROID_HOME/ndk/27.1.12297006" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/27.1.12297006" >> "$GITHUB_ENV" + npm install --global pnpm@11.5.2 + rustup show + rustup target add aarch64-linux-android + + - name: Install locked frontend dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Check formatting and relevant regressions + run: | + mkdir -p android-ci-artifacts + cargo fmt --all --check 2>&1 | tee android-ci-artifacts/rustfmt.log + pnpm test src/services/appLock.test.ts src/hooks/useSessionExpiration.test.ts src/components/layout/SplashScreen.appLock.test.tsx src/components/layout/SplashScreen.keychainHint.test.tsx src/services/androidKeyboard.lock.test.ts src/components/terminal/androidDownloadDir.test.ts 2>&1 | tee android-ci-artifacts/frontend-tests.log + cargo test --locked -p voltius-crypto 2>&1 | tee android-ci-artifacts/crypto-tests.log + + - name: Build the development-signed ARM64 APK + run: pnpm tauri android build --target aarch64 --apk --debug 2>&1 | tee android-ci-artifacts/android-build.log + + - name: Verify and collect APK evidence + run: | + python3 scripts/verify-android-apk.py src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk android-ci-artifacts + cp LICENSE android-ci-artifacts/ + cp docs/android-fork-build.md android-ci-artifacts/BUILD-NOTICE.md + + - name: Preserve APK and build evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: Voltius-arm64-debug-${{ github.sha }} + path: android-ci-artifacts/ + if-no-files-found: error + compression-level: 9 + retention-days: 14 diff --git a/Dockerfile.android b/Dockerfile.android index 8d371df98..ccc2e44ac 100644 --- a/Dockerfile.android +++ b/Dockerfile.android @@ -19,7 +19,7 @@ RUN dpkg --add-architecture amd64 \ # Node & PNPM (same as Dockerfile.cross-compile) RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ - && corepack enable && corepack prepare pnpm@10.33.0 --activate \ + && npm install -g pnpm@11.5.2 \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Android SDK command-line tools @@ -34,8 +34,8 @@ ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tool RUN yes | sdkmanager --licenses >/dev/null \ && sdkmanager \ "platform-tools" \ - "platforms;android-34" \ - "build-tools;34.0.0" \ + "platforms;android-36" \ + "build-tools;36.0.0" \ "ndk;${NDK_VERSION}" # Rust — channel pinned by rust-toolchain.toml (single source of truth) diff --git a/docs/android-fork-build.md b/docs/android-fork-build.md new file mode 100644 index 000000000..386a8755c --- /dev/null +++ b/docs/android-fork-build.md @@ -0,0 +1,96 @@ +# Mafei Android ARM64 build + +This fork preserves the upstream Git history and AGPLv3 license. The first Android +build is frozen at upstream `dev` commit +`caf470f37ecbdc5d32b91cf288b15a6802abaa35` (version 0.51.0), including #593's +launch-lock fix and #594's Windows terminal fix after the v0.51.0 release. The fork's +existing `main` and `dev` had advanced to 0.52.1 when the fork was connected; they +are preserved. Later upstream changes belong in a separate daily candidate. + +## First delivered APK provenance + +- Original local build commit: `ed14a22ace398dc9d4180f24f950cb9d7026e9e6`. +- Published equivalent: `e92ea05f34d05348c44c48e1da5bbc30f21733f8`. +- Both have exactly the same tree: `7e15e7b44216face7654047c61160aab785e905b`. +- The two local build commits used a private email, so GitHub rejected their push + with GH007. Their original local branches remain. Published equivalents use the + account's GitHub noreply identity without changing account settings or source. +- Delivered APK SHA256: + `f0a1517f21a88027fdd656880aa2fc00b61a05e8b9a00b7d69c16beabbc321f7`. +- Public development certificate SHA256: + `975c51aa7858bd9af3a8e75e96d36778ac133e29aa09a7a2a71eb5c206fe590a`. + +`Dockerfile.android` aligns pnpm with package.json and installs SDK 36. +`src-tauri/build.rs` applies 16 KiB max/common page alignment to the Android +cdylib. The CI workflow, verification script and this document do not change +application logic. CI APK bytes may differ from the cloud-delivered APK; each +run records its own commit, toolchain, hash and certificate. + +## Repeat the build + +Use an isolated Linux x86_64 checkout with Node 24.19.0, pnpm 11.5.2, JDK 21, +Rust from `rust-toolchain.toml`, Android SDK 36, build-tools 36.0.0 and NDK +27.1.12297006. Set ANDROID_HOME/ANDROID_SDK_ROOT and NDK_HOME/ANDROID_NDK_ROOT. + +```bash +rustup show +rustup target add aarch64-linux-android +pnpm install --frozen-lockfile --ignore-scripts +cargo fmt --all --check +pnpm test src/services/appLock.test.ts src/hooks/useSessionExpiration.test.ts src/components/layout/SplashScreen.appLock.test.tsx src/components/layout/SplashScreen.keychainHint.test.tsx src/services/androidKeyboard.lock.test.ts src/components/terminal/androidDownloadDir.test.ts +cargo test --locked -p voltius-crypto +CARGO_PROFILE_DEV_DEBUG=0 CARGO_BUILD_JOBS=4 VOLTIUS_REQUIRE_SEEDED_PLUGINS=1 \ + pnpm tauri android build --target aarch64 --apk --debug +python3 scripts/verify-android-apk.py \ + src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk \ + android-ci-artifacts +``` + +The `Android ARM64 debug` workflow runs on normal pushes to +`candidate/android-*`. It checks out the triggering SHA, runs the relevant +regressions, builds a debug APK and verifies the package/version, minSdk 24, +targetSdk 36, ARM64-only native library, public development signature, APK ZIP +alignment and ELF LOAD/RELRO alignment for 16 KiB pages. It uploads only the APK, +public metadata, license/notice and logs as a 14-day Actions artifact. It has +read-only repository permissions and does not publish releases or use signing +secrets. Static alignment checks are not a runtime test. Phone/emulator startup, +InputConnection, biometric unlock and SSH/SFTP remain untested for this build. + +## Daily candidate and maintenance branches + +Track upstream `dev`; use `mafei/android` as the validated maintenance branch. +Preserve `build/upstream-dev-caf470f3` as the review baseline for this first build. +Create a new branch such as `candidate/android-YYYYMMDD-` for +each merge. The main thread owns the existing daily schedule. + +```bash +git fetch upstream dev +git fetch origin mafei/android +maintenance_sha=$(git rev-parse origin/mafei/android) +upstream_sha=$(git rev-parse upstream/dev) +candidate="candidate/android-$(date -u +%Y%m%d)-${upstream_sha:0:8}" +git switch -c "$candidate" "$maintenance_sha" +git merge --no-edit "$upstream_sha" +git push origin "HEAD:refs/heads/$candidate" +``` + +On conflicts or failed checks, retain the candidate and report it; keep maintenance +at its last validated commit. Resolve conflicts on the candidate, use ordinary +commits and revalidate. Before advancing maintenance, verify this candidate's +Actions run succeeded for its exact SHA and the remote maintenance SHA still +equals `maintenance_sha`. If it changed, stop and reconcile the concurrent work. +Then fast-forward the local maintenance branch and push normally: + +```bash +candidate_sha=$(git rev-parse HEAD) +git fetch origin mafei/android +test "$(git rev-parse origin/mafei/android)" = "$maintenance_sha" +git switch mafei/android +git merge --ff-only "$candidate_sha" +git push origin mafei/android +``` + +Never force-push or delete history. Do not merge this first-build review into +`main` or trigger the upstream release workflows. The committed public debug key +is a temporary test identity, not a long-term production upgrade-signing key; +never upload the private key as an artifact. diff --git a/scripts/verify-android-apk.py b/scripts/verify-android-apk.py new file mode 100644 index 000000000..f2fcf475a --- /dev/null +++ b/scripts/verify-android-apk.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Verify the debug ARM64 APK and save public build evidence (no key material).""" + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import struct +import subprocess +import sys +import zipfile + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def main(): + require(len(sys.argv) == 3, "usage: verify-android-apk.py APK OUTPUT_DIRECTORY") + apk, output = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve() + root = Path(__file__).resolve().parents[1] + output.mkdir(parents=True, exist_ok=True) + sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + require(sdk, "ANDROID_HOME or ANDROID_SDK_ROOT is required") + tools = Path(sdk) / "build-tools" / "36.0.0" + + def command(args, logfile=None): + result = subprocess.run(args, cwd=root, text=True, capture_output=True) + text = result.stdout + result.stderr + if logfile: + (output / logfile).write_text(text) + require(result.returncode == 0, f"command failed ({result.returncode}): {args[0]}\n{text}") + return text + + signature = command([str(tools / "apksigner"), "verify", "--verbose", "--print-certs", str(apk)], "signature.log") + badging = command([str(tools / "aapt"), "dump", "badging", str(apk)], "badging.log") + command([str(tools / "zipalign"), "-c", "-P", "16", "-v", "4", str(apk)], "zipalign.log") + package = re.search(r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'", badging) + require(package, "APK package metadata missing") + version = json.loads((root / "package.json").read_text())["version"] + major, minor, patch = map(int, version.split(".")) + require(package.groups() == ("com.voltius.app", str(major * 1000000 + minor * 1000 + patch), version), "unexpected APK package/version") + require("sdkVersion:'24'" in badging and "targetSdkVersion:'36'" in badging, "unexpected Android SDK levels") + require("application-debuggable" in badging, "APK is not debuggable") + cert = re.search(r"Signer #1 certificate SHA-256 digest: ([0-9a-f]+)", signature) + expected_cert = "975c51aa7858bd9af3a8e75e96d36778ac133e29aa09a7a2a71eb5c206fe590a" + require(cert and cert[1] == expected_cert, "unexpected public development signing certificate") + require("Number of signers: 1" in signature and "Verified using v2 scheme (APK Signature Scheme v2): true" in signature, "APK signature v2 verification failed") + + with zipfile.ZipFile(apk) as archive: + require(archive.testzip() is None, "APK ZIP CRC check failed") + native = [name for name in archive.namelist() if name.startswith("lib/") and name.endswith(".so")] + require(native == ["lib/arm64-v8a/libvoltius_lib.so"], f"unexpected native libraries: {native}") + require(not any(name.lower().endswith((".keystore", ".jks", ".p12", ".pfx", ".key")) for name in archive.namelist()), "APK contains a private-key file") + library = archive.read(native[0]) + require(library[:6] == b"\x7fELF\x02\x01", "expected little-endian ELF64 library") + require(struct.unpack_from(" 0, "invalid ELF program headers") + headers = [struct.unpack_from("= 16384 and (h["virtual_address"] - h["offset"]) % 16384 == 0 for h in loads), "ELF LOAD segments are not aligned for 16 KiB pages") + require(relro_ends and all(end % 16384 == 0 for end in relro_ends), "ELF RELRO ends are not aligned for 16 KiB pages") + built_library = root / "target/aarch64-linux-android/debug/libvoltius_lib.so" + require(built_library.exists() and sha256(built_library.read_bytes()) == sha256(library), "APK does not contain the just-built native library") + + commit = command(["git", "rev-parse", "HEAD"]).strip() + tree = command(["git", "rev-parse", "HEAD^{tree}"]).strip() + name = f"Voltius_{version}_{commit[:8]}_arm64-debug.apk" + digest = sha256(apk.read_bytes()) + shutil.copy2(apk, output / name) + (output / "APK-SHA256.txt").write_text(f"{digest} {name}\n") + manifest = { + "source_commit": commit, + "source_tree": tree, + "workflow_run": os.environ.get("GITHUB_SERVER_URL", "https://github.com") + "/" + os.environ.get("GITHUB_REPOSITORY", "Mafei/voltius") + "/actions/runs/" + os.environ.get("GITHUB_RUN_ID", "local"), + "apk": {"filename": name, "size_bytes": apk.stat().st_size, "sha256": digest, "package": package[1], "version_code": int(package[2]), "version_name": version, "min_sdk": 24, "target_sdk": 36, "debuggable": True, "abi": "arm64-v8a", "signature_v2_verified": True, "certificate_sha256": cert[1], "zip_alignment_16k_verified": True}, + "native_library": {"sha256": sha256(library), "load_segments": loads, "relro_end_addresses": relro_ends, "elf_alignment_16k_verified": True}, + "toolchain": {"rust": command(["rustc", "--version"]).strip(), "node": command(["node", "--version"]).strip(), "pnpm": command(["pnpm", "--version"]).strip(), "tauri": command(["pnpm", "tauri", "--version"]).strip(), "java": command(["java", "-version"]).strip(), "ndk": "27.1.12297006", "sdk_build_tools": "36.0.0"}, + "runtime_tested": False, + "signing_notice": "Existing public development test key; unsuitable for long-term production upgrades. No private key is included in this artifact.", + } + (output / "BUILD-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps(manifest, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0e9773232..da196bae0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -24,6 +24,12 @@ type Discovered = BTreeMap>; fn main() { tauri_build::build(); + // NDK r27 and older default to 4 KiB pages; align the Android library for 16 KiB devices. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") { + println!( + "cargo:rustc-link-arg-cdylib=-Wl,-z,max-page-size=16384,-z,common-page-size=16384" + ); + } embed_seeded_plugins(); }