Skip to content
Merged
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
67 changes: 66 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,73 @@ jobs:
dist/${{ matrix.name }}-app.zip
if-no-files-found: ignore

# Android build — separate job so it doesn't inflate the matrix. The
# Rust side here cross-compiles to FOUR ABIs (arm64-v8a, armeabi-v7a,
# x86_64, x86) via cargo-ndk and drops the .so files into the Gradle
# project's jniLibs/ tree, which then packages them into a single
# universal APK. Users pick it once, no per-ABI split.
android:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
cmdline-tools-version: 11076708

- name: Install NDK
run: |
yes | sdkmanager --install "ndk;26.1.10909125" >/dev/null
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> "$GITHUB_ENV"

- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android

- name: Install cargo-ndk
run: cargo install cargo-ndk --locked

# `./gradlew :app:assembleRelease` triggers cargoBuildRelease first
# which invokes cargo-ndk with all four targets, then Gradle packages
# the APK (release buildType signed with the debug keystore — see
# android/app/build.gradle.kts comment explaining why).
- name: Build release APK
working-directory: android
run: |
chmod +x ./gradlew
./gradlew :app:assembleRelease --no-daemon --stacktrace

- name: Rename APK with version
working-directory: android
run: |
VER="${GITHUB_REF#refs/tags/v}"
SRC="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$SRC" ]; then
# Some AGP versions name it differently when the release config
# can't be auto-signed. Catch that up front with a clear error
# instead of a silent missing-artifact later.
echo "::error::expected $SRC to exist; actual outputs:"
find app/build/outputs/apk -type f -name '*.apk' -print
exit 1
fi
mkdir -p ../dist
cp "$SRC" "../dist/mhrv-rs-android-universal-v${VER}.apk"

- uses: actions/upload-artifact@v4
with:
name: mhrv-rs-android-universal
path: dist/*.apk
if-no-files-found: error

release:
needs: build
needs: [build, android]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "0.9.4"
version = "1.0.0"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ For a handful of Google-owned domains (`google.com`, `youtube.com`, `fonts.googl

## Platforms

Linux (x86_64, aarch64), macOS (x86_64, aarch64), Windows (x86_64). Prebuilt binaries on the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases).
Linux (x86_64, aarch64), macOS (x86_64, aarch64), Windows (x86_64), **Android 7.0+** (universal APK covering arm64, armv7, x86_64, x86). Prebuilt binaries on the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases).

**Android users** — grab `mhrv-rs-android-universal-v*.apk` and follow the full walk-through in [docs/android.md](docs/android.md). The Android build runs the exact same `mhrv-rs` crate as the desktop (via JNI) and adds a TUN bridge via `tun2proxy`, so every app on the device routes through the proxy without per-app configuration.

## What's in a release

Expand Down
98 changes: 59 additions & 39 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

// 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.
versionCode = 100
versionName = "1.0.0"

// 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")
}
}

Expand All @@ -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")
}
}

Expand Down Expand Up @@ -96,55 +109,62 @@ 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
// of unoptimized object code vs 3MB with release; the 20x APK bloat is
// 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() }
}

Expand Down
Loading