Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/android-arm64-debug.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions Dockerfile.android
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions docs/android-fork-build.md
Original file line number Diff line number Diff line change
@@ -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-<upstream-short-sha>` 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.
97 changes: 97 additions & 0 deletions scripts/verify-android-apk.py
Original file line number Diff line number Diff line change
@@ -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("<H", library, 18)[0] == 183, "native library is not AArch64")
phoff = struct.unpack_from("<Q", library, 32)[0]
entry_size, count = struct.unpack_from("<HH", library, 54)
require(entry_size == 56 and count > 0, "invalid ELF program headers")
headers = [struct.unpack_from("<IIQQQQQQ", library, phoff + i * entry_size) for i in range(count)]
loads = [{"offset": h[2], "virtual_address": h[3], "alignment": h[7]} for h in headers if h[0] == 1]
relro_ends = [h[3] + h[6] for h in headers if h[0] == 0x6474E552]
require(loads and all(h["alignment"] >= 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()
6 changes: 6 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ type Discovered = BTreeMap<String, Vec<(String, String)>>;

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();
}

Expand Down