v1.0.0: multi-arch Android APK + GitHub Actions release job + install docs (#30)

Version bump reflects the scope — a unified Rust core that now ships
for desktop (Linux/macOS/Windows) AND Android from the same crate.

Android changes:
- build.gradle.kts: ABI filters expanded to arm64-v8a + armeabi-v7a
  + x86_64 + x86. cargoBuild{Debug,Release} pass all four ABIs to
  cargo-ndk in a single invocation. normalizeTun2proxySo() walks every
  ABI dir now (was arm64-only).
- Release buildType signs with the debug keystore — no Play Store
  target, so signature identity doesn't matter, installability does.
  Gradle auto-provisions ~/.android/debug.keystore if absent, so CI
  runners inherit this without extra setup.
- versionName 1.0.0, versionCode 100 (room to bump monotonically).

CI:
- release.yml gets a dedicated `android:` job that sets up JDK 17,
  Android SDK/NDK 26, all four rust-android targets, installs
  cargo-ndk, runs assembleRelease, and uploads a single universal APK
  named `mhrv-rs-android-universal-v<version>.apk` into the same
  `dist/` collected by the release job downstream.
- `release:` job now gates on `needs: [build, android]` so tagging
  v1.0.0 triggers both build matrices before cutting the GitHub
  release.

Docs:
- docs/android.md — full 10-step install walk-through: APK sideload,
  Apps Script deployment (with "Advanced → Go to (unsafe) → Allow"
  reality check), config paste, SNI reachability test, MITM CA
  install with OEM-specific nav paths (Pixel / Samsung / Xiaomi),
  Start, troubleshooting common failure modes. Also documents the
  known limitations — Cloudflare Turnstile loops (inherent to the
  Apps Script egress IP pool), UDP/QUIC not tunnelled, IPv6 leaks,
  Apps Script daily quota — so users know what to expect before
  trying it on a site that won't work.
- releases/README.md — APK row added to the English and Persian
  tables, version bumped everywhere to v1.0.0.
- Top-level README — Android listed under Platforms with a link
  to docs/android.md.

Release artifact:
- releases/mhrv-rs-android-universal-v1.0.0.apk — 38 MB universal
  APK built locally from this tree. Installs + launches on API 24+.
  The CI job will regenerate it on tag push; this is the copy
  committed for users who can't reach GitHub Releases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shin (Former Aleph)
2026-04-23 02:56:39 +03:00
committed by GitHub
parent 96d1352728
commit 91015b0594
8 changed files with 340 additions and 46 deletions
+58 -38
View File
@@ -14,15 +14,19 @@ android {
applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = 100
versionName = "1.0.0"
// Only arm64 for now — we can add armeabi-v7a in a second pass
// if field reports need it. Android emulators on Apple Silicon
// only run arm64 natively, so keeping things aarch64-only makes
// the dev loop fast.
// Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019
// - armeabi-v7a — older/cheaper devices still on 32-bit ARM
// - x86_64 — Android emulator on Intel Macs + Chromebooks
// - x86 — legacy 32-bit Intel emulator; cheap to include
// Per-ABI .so files push the APK up to ~50 MB, but users expect one
// APK that Just Works rather than "pick the right ABI" which nobody
// does correctly. Google Play would auto-split; we ship universal.
ndk {
abiFilters += listOf("arm64-v8a")
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
}
}
@@ -33,6 +37,15 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Sign release builds with the debug keystore so users can
// sideload the APK without us shipping a proper release key.
// The project has no Play Store presence, so signature
// identity per-build doesn't matter — installability does.
// Gradle auto-creates `~/.android/debug.keystore` on first use;
// CI runners inherit that behaviour. Anyone rebuilding from
// source gets their own signature, which is what we want for
// an open-source project: trust the source, not a key we hold.
signingConfig = signingConfigs.getByName("debug")
}
}
@@ -96,27 +109,34 @@ dependencies {
val rustCrateDir = rootProject.projectDir.parentFile
val jniLibsDir = file("src/main/jniLibs")
// After cargo-ndk dumps artifacts into jniLibs/arm64-v8a/, the tun2proxy
// cdylib lands as `libtun2proxy-<hash>.so` (rustc's deps/ naming convention,
// because tun2proxy is a transitive dep not a root crate). Android's
// System.loadLibrary expects a stable name, and the hash changes between
// builds, so we normalize it to `libtun2proxy.so` here. Also deletes any
// stale hash-suffixed copies from previous builds.
// After cargo-ndk dumps artifacts into each jniLibs/<abi>/ dir, the
// tun2proxy cdylib lands as `libtun2proxy-<hash>.so` (rustc's deps/ naming
// convention, because tun2proxy is a transitive dep not a root crate).
// Android's System.loadLibrary expects a stable name, and the hash changes
// between builds, so we normalize it to `libtun2proxy.so` in every ABI dir.
// Also deletes any stale hash-suffixed copies from previous builds.
fun normalizeTun2proxySo() {
val abiDir = file("src/main/jniLibs/arm64-v8a")
if (!abiDir.isDirectory) return
val hashed = abiDir.listFiles { f -> f.name.matches(Regex("libtun2proxy-[0-9a-f]+\\.so")) }
?: emptyArray()
// Keep only the newest (release build) and rename it.
val newest = hashed.maxByOrNull { it.lastModified() }
if (newest != null) {
val target = abiDir.resolve("libtun2proxy.so")
if (target.exists()) target.delete()
newest.copyTo(target, overwrite = true)
val jniLibsRoot = file("src/main/jniLibs")
if (!jniLibsRoot.isDirectory) return
jniLibsRoot.listFiles()?.filter { it.isDirectory }?.forEach { abiDir ->
val hashed = abiDir.listFiles { f -> f.name.matches(Regex("libtun2proxy-[0-9a-f]+\\.so")) }
?: emptyArray()
val newest = hashed.maxByOrNull { it.lastModified() }
if (newest != null) {
val target = abiDir.resolve("libtun2proxy.so")
if (target.exists()) target.delete()
newest.copyTo(target, overwrite = true)
}
hashed.forEach { it.delete() }
}
hashed.forEach { it.delete() }
}
// All ABIs we ship. Keep in sync with `android.defaultConfig.ndk.abiFilters`
// above; if these drift, the APK either includes .so files with no matching
// ABI entry (dead weight) or advertises ABIs with no .so (runtime
// UnsatisfiedLinkError on devices that pick that split).
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
tasks.register<Exec>("cargoBuildDebug") {
group = "build"
// Intentionally ALWAYS uses --release. The Rust debug build is 80+MB
@@ -124,27 +144,27 @@ tasks.register<Exec>("cargoBuildDebug") {
// never worth it just for a Rust stack trace you wouldn't see in
// logcat anyway. If you need Rust debug symbols, temporarily drop
// `--release` below and accept the APK size.
description = "Cross-compile mhrv_rs for arm64-v8a (release — same as cargoBuildRelease)"
description = "Cross-compile mhrv_rs for all ABIs (release — same as cargoBuildRelease)"
workingDir = rustCrateDir
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"-o", jniLibsDir.absolutePath,
"build", "--release",
)
commandLine(buildList<String> {
add("cargo"); add("ndk")
androidAbis.forEach { add("-t"); add(it) }
add("-o"); add(jniLibsDir.absolutePath)
add("build"); add("--release")
})
doLast { normalizeTun2proxySo() }
}
tasks.register<Exec>("cargoBuildRelease") {
group = "build"
description = "Cross-compile mhrv_rs for arm64-v8a (release)"
description = "Cross-compile mhrv_rs for all ABIs (release)"
workingDir = rustCrateDir
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"-o", jniLibsDir.absolutePath,
"build", "--release",
)
commandLine(buildList<String> {
add("cargo"); add("ndk")
androidAbis.forEach { add("-t"); add(it) }
add("-o"); add(jniLibsDir.absolutePath)
add("build"); add("--release")
})
doLast { normalizeTun2proxySo() }
}