Add Android app with full TUN bridge + two proxy fixes the desktop also wants (#29)

The app is a Kotlin/Compose front-end that reuses the mhrv-rs crate
via JNI. It speaks VpnService to get a TUN fd, hands that to tun2proxy,
and funnels every app's traffic through the in-process SOCKS5 listener —
no per-app proxy setup on the device.

Two fixes in `src/proxy_server.rs` apply to desktop builds too:

* SNI peek via `LazyConfigAcceptor`. When a browser uses DoH (Chrome's
  default), tun2proxy hands us a raw IP in the SOCKS5 CONNECT. Minting
  a MITM cert for the IP produced `ERR_CERT_COMMON_NAME_INVALID` on
  Cloudflare-fronted sites. We now read the ClientHello's SNI first
  and use that both as the cert subject and as the upstream host for
  the Apps Script relay (fetching `https://<IP>/...` with an IP in the
  Host header gets rejected by CF anyway).
* Short-circuit CORS preflight at the MITM boundary. `UrlFetchApp.fetch()`
  rejects `OPTIONS` with a Swedish "Ett attribut med ogiltigt värde
  har angetts: method" error, which silently broke every fetch()/XHR
  preflight and was the root cause of "JS doesn't load" on Discord,
  Yahoo, and similar. Since we already terminate the TLS the browser
  talks to, answering the preflight with a permissive 204 is safe —
  the real request still goes through the relay.

Android-side capabilities (feature-parity with `mhrv-rs-ui` where it
fits on a phone):

* multi-deployment ID editor
* SNI rotation pool + per-SNI "Test" + "Test all" (JNI into scan_sni)
* live logs panel (JNI ring buffer drained on a 500 ms poll)
* Advanced section: verify_ssl, parallel_relay, log_level, upstream_socks5
* CA install flow that matches modern Android's reality: saves
  `Downloads/mhrv-ca.crt` via MediaStore, deep-links Security settings,
  then verifies post-hoc by fingerprint lookup in AndroidCAStore (the
  KeyChain intent dead-ends with a Close-only dialog on Android 11+)
* Start/Stop debounced to dodge an emulator EGL renderer crash on
  rapid taps

Theme matches the desktop palette exactly — always-dark, accent
`#4678B4`, card fill `#1C1E22`, 4dp button / 6dp card radii.
No dynamic color, no light scheme: the desktop is always dark and
we follow.

Build wiring:

* `Cargo.toml`: `cdylib` crate-type added; `jni` + `tun2proxy`
  scoped to `cfg(target_os = "android")` so desktop builds pay
  nothing.
* `src/data_dir.rs`: `set_data_dir()` override so the Android app's
  private filesDir replaces the `directories` crate's desktop default.
* `src/android_jni.rs`: JNI entry points for start/stop/exportCa plus
  a ring buffer draining to `Native.drainLogs()` and `testSni()` that
  wraps `scan_sni::probe_one`.
* Gradle task chain runs `cargo ndk` before each assemble; post-step
  normalizes tun2proxy's hash-suffixed cdylib to a stable filename
  so `System.loadLibrary("tun2proxy")` works.

Verified end-to-end on an API 34 emulator: ipleak, yahoo, discord,
cloudflare.com all render; TLS is MITM-ed under our user-installed
CA; service survives rapid Stop/Start cycles.

🤖 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:44:17 +03:00
committed by GitHub
parent 4cfd9d9652
commit 96d1352728
32 changed files with 4301 additions and 34 deletions
Generated
+1237 -12
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -8,6 +8,10 @@ license = "MIT"
[lib]
name = "mhrv_rs"
path = "src/lib.rs"
# `cdylib` lets the Android app dlopen libmhrv_rs.so via System.loadLibrary.
# `rlib` keeps the desktop binaries linking normally — same .rlib is used
# for `mhrv-rs` and `mhrv-rs-ui` builds on macOS/Linux/Windows.
crate-type = ["rlib", "cdylib"]
[[bin]]
name = "mhrv-rs"
@@ -62,6 +66,19 @@ eframe = { version = "0.28", default-features = false, features = [
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Android-only deps: jni gives us the extern "system" wrappers used in
# src/android_jni.rs; zero cost on any other platform because the whole
# module is `#[cfg(target_os = "android")]`.
#
# tun2proxy is the TUN <-> SOCKS5 bridge — it reads raw IP packets from the
# fd VpnService hands us, runs a userspace TCP/IP stack (smoltcp under the
# hood), and funnels every TCP/UDP flow through our local SOCKS5. Without
# this, VpnService establishes a TUN device nothing reads from and all
# traffic black-holes (symptom: Chrome shows DNS_PROBE_STARTED).
[target.'cfg(target_os = "android")'.dependencies]
jni = { version = "0.21", default-features = false }
tun2proxy = { version = "0.7", default-features = false }
[dev-dependencies]
# Used in mitm tests to sanity-check the cert extensions we emit.
x509-parser = "0.16"
+8
View File
@@ -0,0 +1,8 @@
.gradle/
build/
app/build/
app/src/main/jniLibs/
local.properties
.idea/
*.iml
.DS_Store
+157
View File
@@ -0,0 +1,157 @@
import org.gradle.api.tasks.Exec
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.therealaleph.mhrv"
compileSdk = 34
defaultConfig {
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.
ndk {
abiFilters += listOf("arm64-v8a")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
// libmhrv_rs.so is produced by `cargo ndk` in the repo root and dropped
// under app/src/main/jniLibs/<abi>/. The cargoBuild task below runs
// that before each assembleDebug / assembleRelease.
sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs")
packaging {
resources.excludes += setOf(
"META-INF/AL2.0",
"META-INF/LGPL2.1",
)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
// Compose UI.
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
// --------------------------------------------------------------------------
// Cross-compile the Rust crate to arm64 Android and drop the .so into the
// place Android's packager looks. We hand the work off to `cargo ndk` which
// wraps the right CC / AR / linker env vars for us.
//
// This ties to the `assemble*` task so every debug/release build triggers
// a `cargo ndk` — no manual step. In CI we'd cache the target/ dir to
// avoid full rebuilds.
// --------------------------------------------------------------------------
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.
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)
}
hashed.forEach { it.delete() }
}
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)"
workingDir = rustCrateDir
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"-o", jniLibsDir.absolutePath,
"build", "--release",
)
doLast { normalizeTun2proxySo() }
}
tasks.register<Exec>("cargoBuildRelease") {
group = "build"
description = "Cross-compile mhrv_rs for arm64-v8a (release)"
workingDir = rustCrateDir
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"-o", jniLibsDir.absolutePath,
"build", "--release",
)
doLast { normalizeTun2proxySo() }
}
// Hook the right cargo task in front of each Android build variant.
tasks.configureEach {
when (name) {
"mergeDebugJniLibFolders" -> dependsOn("cargoBuildDebug")
"mergeReleaseJniLibFolders" -> dependsOn("cargoBuildRelease")
}
}
+5
View File
@@ -0,0 +1,5 @@
# Keep our JNI entry points so R8 doesn't strip them in release builds.
# These methods are declared `external` in Kotlin the JNI linker looks
# them up by exact name at load time.
-keep class com.therealaleph.mhrv.Native { *; }
-keep class com.therealaleph.mhrv.MhrvVpnService { *; }
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Mhrv"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Mhrv">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
VpnService: Android captures all traffic at the IP layer and feeds
it to us via a TUN file descriptor. The android.net.VpnService action
is what lets the system show us in the "VPN" section of settings
and route through us.
-->
<service
android:name=".MhrvVpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn_relay" />
</service>
</application>
</manifest>
@@ -0,0 +1,67 @@
package com.github.shadowsocks.bg
/**
* JNI bridge to the tun2proxy crate's Android entry points.
*
* The tun2proxy Rust crate (already pulled in as an Android-only dep of
* libmhrv_rs) defines its C entry points under this exact package path:
* Java_com_github_shadowsocks_bg_Tun2proxy_run
* Java_com_github_shadowsocks_bg_Tun2proxy_stop
*
* That's why this file lives at `com.github.shadowsocks.bg` — we did NOT
* pick this package. Renaming it would break the JNI name mangling and
* the native functions would fail to resolve at runtime.
*
* The crate is reusing Shadowsocks-Android's original JNI convention.
*
* NOTE: the tun2proxy JNI symbols live in libtun2proxy.so (not libmhrv_rs.so).
* tun2proxy is pulled in as a Rust dep of mhrv-rs, but because nothing in
* mhrv-rs calls these symbols directly, Rust's rlib-level dead-code
* elimination drops them from libmhrv_rs.so. The cdylib variant of tun2proxy
* (which rustc builds alongside the rlib) retains them, so we ship that .so
* separately and load it explicitly here.
*/
object Tun2proxy {
init {
System.loadLibrary("tun2proxy")
}
/**
* Start the TUN <-> proxy bridge.
*
* @param proxyUrl e.g. "socks5://127.0.0.1:1081"
* @param tunFd raw fd from VpnService.Builder#establish().detachFd()
* @param closeFdOnDrop whether tun2proxy should close the fd on shutdown.
* We detach and hand over ownership, so this is true.
* @param tunMtu MTU to match the VpnService setMtu() call.
* @param verbosity 0=off, 1=error, 2=warn, 3=info, 4=debug, 5=trace.
* Logs land in logcat under tag "tun2proxy".
* @param dnsStrategy 0=Virtual (fake-IP DNS, tun2proxy resolves via proxy),
* 1=OverTcp (UDP DNS tunneled as TCP via proxy),
* 2=Direct (DNS goes straight through VpnService.protect).
* Virtual is the right default here: app asks DNS for
* example.com, gets a fake 198.18.x.y, tries to connect,
* tun2proxy intercepts, knows the real hostname, opens
* SOCKS5 to our proxy with "example.com:443" as target.
* Our proxy does its own resolution via the Apps Script
* relay, so this gives us end-to-end name resolution
* without leaking plaintext DNS to the ISP.
*
* Returns 0 on normal shutdown, non-zero on error. BLOCKS until the TUN
* is torn down or `stop()` is called — call this from a background thread.
*/
@JvmStatic
external fun run(
proxyUrl: String,
tunFd: Int,
closeFdOnDrop: Boolean,
tunMtu: Char,
verbosity: Int,
dnsStrategy: Int,
): Int
/** Signals the running `run()` to shut down. Idempotent. */
@JvmStatic
external fun stop(): Int
}
@@ -0,0 +1,226 @@
package com.therealaleph.mhrv
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.security.KeyChain
import android.util.Base64
import java.io.File
import java.security.KeyStore
import java.security.MessageDigest
import java.security.cert.CertificateFactory
/**
* Helpers for the MITM-CA install UX.
*
* The flow has three steps:
* 1. `export()` — copy the CA cert from the Rust-managed dir
* (`<filesDir>/ca/ca.crt`) to a stable location (`<filesDir>/ca.crt`)
* the UI can hand to Android's system picker.
* 2. `buildInstallIntent()` — build a `KeyChain.createInstallIntent` loaded
* with the cert's DER bytes. Passing the bytes via `EXTRA_CERTIFICATE`
* is cleaner than handing Android a file Uri: no content-provider
* plumbing, no external-storage permission, and Android resolves the
* "VPN and apps" / "Wi-Fi" category on its own.
* 3. `isInstalled()` — after the system dialog returns we can't rely on
* the `resultCode` (Android 11+ opens a Settings activity that always
* returns `RESULT_CANCELED`), so we walk `AndroidCAStore` looking for
* a cert whose SHA-256 fingerprint matches ours. That keystore spans
* both system and user-installed CAs, so it's the ground truth.
*/
object CaInstall {
private const val CA_FILENAME = "ca.crt"
private const val CA_FRIENDLY_NAME = "mhrv-rs MITM CA"
/** Stable path where the UI stages the exported CA. */
fun caFile(ctx: Context): File = File(ctx.filesDir, CA_FILENAME)
/**
* Copy the current Rust-side CA cert to a UI-accessible path.
* Returns true only if the file exists and is non-empty on return —
* Native.exportCa is supposed to do that, but we re-check because a
* truncated write would make the install dialog look empty and the
* user would have no idea why.
*/
fun export(ctx: Context): Boolean {
val dest = caFile(ctx)
if (!Native.exportCa(dest.absolutePath)) return false
return dest.exists() && dest.length() > 0
}
/** DER-encoded bytes of the exported CA, or null if export hasn't run. */
fun readDer(ctx: Context): ByteArray? {
val f = caFile(ctx)
if (!f.exists()) return null
val raw = try { f.readBytes() } catch (_: Throwable) { return null }
return pemToDer(raw) ?: raw // fall back to treating it as DER
}
/** SHA-256 fingerprint of the CA cert (over DER bytes). */
fun fingerprint(ctx: Context): ByteArray? {
val der = readDer(ctx) ?: return null
return sha256(der)
}
/** Pretty-print a fingerprint like "AA:BB:CC:...". */
fun fingerprintHex(bytes: ByteArray): String =
bytes.joinToString(":") { "%02X".format(it) }
/**
* Build the KeyChain install intent. The intent launches the system
* certificate picker pre-loaded with our cert — the user confirms a
* category (for modern Android that's "VPN and apps" or "Wi-Fi") and
* gives it a display name.
*/
fun buildInstallIntent(ctx: Context): Intent? {
val der = readDer(ctx) ?: return null
return KeyChain.createInstallIntent()
.putExtra(KeyChain.EXTRA_CERTIFICATE, der)
.putExtra(KeyChain.EXTRA_NAME, CA_FRIENDLY_NAME)
}
/**
* Save a PEM copy of the CA to the user's Downloads folder so they
* can find it in the Files app and pick it from Settings →
* Encryption & credentials → Install a certificate → CA certificate.
*
* On Android 10+ (API 29) this goes through MediaStore so we don't need
* WRITE_EXTERNAL_STORAGE. On older Android we fall back to the app's
* external files dir — visible via Files app but not in the system
* Downloads collection, so the user needs to navigate to
* `Android/data/<pkg>/files/Download/` themselves.
*
* Returns a human-readable location string ("Downloads/mhrv-ca.crt" or
* the filesystem path) on success, null on failure.
*/
fun saveToDownloads(ctx: Context, displayName: String = "mhrv-ca.crt"): String? {
val der = readDer(ctx) ?: return null
// Rewrap as PEM so users can open the file in a text editor and
// verify it's a cert before trusting it — also, the system cert
// installer expects PEM or DER but PEM is the more common form
// for user-visible files.
val pem = derToPem(der)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveViaMediaStore(ctx, displayName, pem)?.let { "Downloads/$displayName" }
} else {
// Pre-Q fallback: app-private external storage. Does NOT require
// a storage permission. Less discoverable for the user, but
// dodging the runtime-permission dance is worth it here — we
// can still deep-link Settings and tell them the path.
try {
val dir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return null
dir.mkdirs()
val f = File(dir, displayName)
f.writeBytes(pem)
f.absolutePath
} catch (_: Throwable) { null }
}
}
private fun saveViaMediaStore(ctx: Context, displayName: String, bytes: ByteArray): Boolean? {
val resolver = ctx.contentResolver
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/x-x509-ca-cert")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
}
// Delete any previous copy with the same name before inserting, so
// we don't accumulate `mhrv-ca (1).crt`, `mhrv-ca (2).crt` on repeat
// installs (MediaStore appends suffixes instead of overwriting).
try {
val sel = "${MediaStore.MediaColumns.DISPLAY_NAME}=?"
resolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, sel, arrayOf(displayName))
} catch (_: Throwable) { /* best-effort */ }
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return null
return try {
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return null
true
} catch (_: Throwable) { null }
}
/**
* Intent that opens the system "Security" settings screen. The exact
* landing page depends on OEM and Android version:
* - Pixel / stock AOSP: Settings → Security
* - from there the user navigates Encryption & credentials →
* Install a certificate → CA certificate → pick our .crt file.
*
* We tried KeyChain.createInstallIntent first (nicer flow) but on
* Android 11+ that intent just opens a dialog saying "Install CA
* certificates in Settings" with a Close button and no path forward —
* Google intentionally removed the inline install path. Settings is
* the fallback Google themselves point users at.
*/
fun buildSettingsIntent(): Intent = Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/**
* True iff a CA with this SHA-256 fingerprint lives in the
* AndroidCAStore (union of system + user-installed CAs). This is how
* we verify install success because the picker activity's result code
* is not reliable across Android versions.
*/
fun isInstalled(targetFingerprint: ByteArray): Boolean {
return try {
val ks = KeyStore.getInstance("AndroidCAStore")
ks.load(null)
val aliases = ks.aliases()
while (aliases.hasMoreElements()) {
val alias = aliases.nextElement()
val cert = ks.getCertificate(alias) ?: continue
val encoded = try { cert.encoded } catch (_: Throwable) { continue }
if (sha256(encoded).contentEquals(targetFingerprint)) return true
}
false
} catch (_: Throwable) { false }
}
/** Subject CN of the exported CA, for display. */
fun subjectCn(ctx: Context): String? {
val der = readDer(ctx) ?: return null
return try {
val cf = CertificateFactory.getInstance("X.509")
val cert = cf.generateCertificate(der.inputStream()) as java.security.cert.X509Certificate
val dn = cert.subjectX500Principal.name // RFC 2253, CN=foo,O=bar
Regex("""CN=([^,]+)""").find(dn)?.groupValues?.get(1)
} catch (_: Throwable) { null }
}
private fun sha256(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(data)
/**
* Rewrap DER bytes as PEM. We intentionally produce a textual cert —
* the user can `cat` it, the Settings cert picker accepts it, and it
* survives any copy/paste or email round-trip without binary mangling.
*/
private fun derToPem(der: ByteArray): ByteArray {
val b64 = Base64.encodeToString(der, Base64.NO_WRAP)
val chunks = b64.chunked(64).joinToString("\n")
val s = "-----BEGIN CERTIFICATE-----\n$chunks\n-----END CERTIFICATE-----\n"
return s.toByteArray(Charsets.US_ASCII)
}
/**
* Accept either DER or PEM bytes; return DER. PEM files carry a base64
* payload between -----BEGIN CERTIFICATE----- markers.
*/
private fun pemToDer(bytes: ByteArray): ByteArray? {
val s = try { bytes.toString(Charsets.US_ASCII) } catch (_: Throwable) { return null }
if (!s.contains("BEGIN CERTIFICATE")) return null // caller falls back to treating as DER
val body = s
.substringAfter("-----BEGIN CERTIFICATE-----", "")
.substringBefore("-----END CERTIFICATE-----", "")
.replace(Regex("\\s+"), "")
if (body.isEmpty()) return null
return try { Base64.decode(body, Base64.DEFAULT) } catch (_: Throwable) { null }
}
}
@@ -0,0 +1,172 @@
package com.therealaleph.mhrv
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Config I/O. The source of truth is a JSON file in the app's files dir —
* the Rust side parses the same file, so we don't maintain two schemas.
*
* What the Android UI exposes is a pragmatic subset of the full mhrv-rs
* config, but we now track parity with the desktop UI on the dimensions
* that actually matter on a phone:
* - multiple deployment IDs (round-robin)
* - an SNI rotation pool
* - log level / verify_ssl / parallel_relay knobs
* Anything else gets phone-appropriate defaults.
*/
data class MhrvConfig(
val listenHost: String = "127.0.0.1",
val listenPort: Int = 8080,
val socks5Port: Int? = 1081,
/** One Apps Script ID or deployment URL per entry. */
val appsScriptUrls: List<String> = emptyList(),
val authKey: String = "",
val frontDomain: String = "www.google.com",
/** Rotation pool of SNI hostnames; empty means "let Rust auto-expand". */
val sniHosts: List<String> = emptyList(),
val googleIp: String = "142.251.36.68",
val verifySsl: Boolean = true,
val logLevel: String = "info",
val parallelRelay: Int = 1,
val upstreamSocks5: String = "",
) {
/**
* Extract just the deployment ID from either a full
* `https://script.google.com/macros/s/<ID>/exec` URL or a bare ID.
*
* Implementation note (this used to be buggy): never use the chained
* `substringBefore(delim, missingDelimiterValue)` form passing the
* original input as the fallback. Example of what that caused:
* "https://.../macros/s/X/exec"
* .substringAfter("/macros/s/", s) -> "X/exec"
* .substringBefore("/", s) -> "X"
* .substringBefore("?", s) -> FALLBACK fires because
* "?" isn't in "X",
* returning the ORIGINAL URL
* → we'd then save the full URL as the "ID", and on reload the UI
* would build `https://.../macros/s/<full-URL>/exec`, producing the
* "extra https:// and extra /exec" symptom users reported. Keep the
* extraction linear and don't reach for a fallback.
*/
private fun extractId(input: String): String {
var s = input.trim()
if (s.isEmpty()) return s
val marker = "/macros/s/"
val i = s.indexOf(marker)
if (i >= 0) s = s.substring(i + marker.length)
// Strip /exec or /dev suffix (or any path after the ID).
val slash = s.indexOf('/')
if (slash >= 0) s = s.substring(0, slash)
// Strip query string.
val q = s.indexOf('?')
if (q >= 0) s = s.substring(0, q)
return s.trim()
}
fun toJson(): String {
val ids = appsScriptUrls
.map { extractId(it) }
.filter { it.isNotEmpty() }
val obj = JSONObject().apply {
// `mode` is required — without it serde errors with
// "missing field `mode`" and startProxy silently returns 0.
put("mode", "apps_script")
put("listen_host", listenHost)
put("listen_port", listenPort)
socks5Port?.let { put("socks5_port", it) }
put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
put("auth_key", authKey)
put("front_domain", frontDomain)
if (sniHosts.isNotEmpty()) {
put("sni_hosts", JSONArray().apply { sniHosts.forEach { put(it) } })
}
put("google_ip", googleIp)
put("verify_ssl", verifySsl)
put("log_level", logLevel)
put("parallel_relay", parallelRelay)
if (upstreamSocks5.isNotBlank()) {
put("upstream_socks5", upstreamSocks5.trim())
}
// Phone-scoped scan defaults. We don't expose these in the UI
// because a phone isn't where you'd run a full /16 scan; users
// who need it can do that on the desktop UI and paste the IP.
put("fetch_ips_from_api", false)
put("max_ips_to_scan", 20)
}
return obj.toString(2)
}
/** Convenience: is there at least one usable deployment ID? */
val hasDeploymentId: Boolean get() =
appsScriptUrls.any { extractId(it).isNotEmpty() }
}
object ConfigStore {
private const val FILE = "config.json"
fun load(ctx: Context): MhrvConfig {
val f = File(ctx.filesDir, FILE)
if (!f.exists()) return MhrvConfig()
return try {
val obj = JSONObject(f.readText())
val ids = obj.optJSONArray("script_ids")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
// For display we turn each ID back into the full URL form —
// easier to paste-verify, and the Kotlin side doesn't depend
// on it (extractId re-parses on save).
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
MhrvConfig(
listenHost = obj.optString("listen_host", "127.0.0.1"),
listenPort = obj.optInt("listen_port", 8080),
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
appsScriptUrls = urls,
authKey = obj.optString("auth_key", ""),
frontDomain = obj.optString("front_domain", "www.google.com"),
sniHosts = sni,
googleIp = obj.optString("google_ip", "142.251.36.68"),
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
)
} catch (_: Throwable) {
MhrvConfig()
}
}
fun save(ctx: Context, cfg: MhrvConfig) {
val f = File(ctx.filesDir, FILE)
f.writeText(cfg.toJson())
}
}
/**
* Default SNI rotation pool. Mirrors `DEFAULT_GOOGLE_SNI_POOL` from the
* Rust `domain_fronter` module — keep the lists in sync, or leave the
* user's sniHosts empty and let Rust auto-expand.
*/
val DEFAULT_SNI_POOL: List<String> = listOf(
"www.google.com",
"mail.google.com",
"drive.google.com",
"docs.google.com",
"calendar.google.com",
)
@@ -0,0 +1,142 @@
package com.therealaleph.mhrv
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.therealaleph.mhrv.ui.CaInstallOutcome
import com.therealaleph.mhrv.ui.HomeScreen
import com.therealaleph.mhrv.ui.theme.MhrvTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Native.setDataDir(filesDir.absolutePath)
// Android 13+ needs runtime permission for foreground service
// notifications. Ask once at launch — if the user declines the
// service still runs, it just won't surface a notification.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQ_NOTIF,
)
}
}
setContent {
MhrvTheme {
AppRoot()
}
}
}
@Composable
private fun AppRoot() {
// The system VpnService.prepare() returns an Intent if the user
// hasn't approved VPN access yet; if null, we're already approved
// and can start directly.
val vpnPrepareLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
startVpnService()
}
}
// CA install flow. We hold the fingerprint of the cert we fired the
// intent with so we can look it up in AndroidCAStore after the
// picker returns — the resultCode itself is unreliable on Android
// 11+ (the system always returns RESULT_CANCELED from the Settings
// shim), so fingerprint verification is our ground truth.
var pendingFingerprint by remember { mutableStateOf<ByteArray?>(null) }
// Human-readable path where we saved the cert copy (e.g.
// "Downloads/mhrv-ca.crt"). Shown in the outcome snackbar so the
// user knows where to find it if they need to install manually
// or share it.
var pendingDownloadPath by remember { mutableStateOf<String?>(null) }
var caOutcome by remember { mutableStateOf<CaInstallOutcome?>(null) }
val installCaLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { _ ->
val fp = pendingFingerprint
caOutcome = when {
fp == null -> CaInstallOutcome.Failed("Internal error: no fingerprint")
CaInstall.isInstalled(fp) -> CaInstallOutcome.Installed
else -> CaInstallOutcome.NotInstalled(pendingDownloadPath)
}
pendingFingerprint = null
pendingDownloadPath = null
}
HomeScreen(
onStart = {
val prepareIntent = VpnService.prepare(this)
if (prepareIntent == null) {
startVpnService()
} else {
vpnPrepareLauncher.launch(prepareIntent)
}
},
onStop = {
val i = Intent(this, MhrvVpnService::class.java)
.setAction(MhrvVpnService.ACTION_STOP)
startService(i)
},
onInstallCaConfirmed = {
// The flow is (1) export cert, (2) copy it to Downloads so
// the user can find it in the Files app, (3) deep-link to
// Security Settings where they can tap "Install a
// certificate". On return we verify via AndroidCAStore.
//
// We explicitly DO NOT use KeyChain.createInstallIntent —
// on Android 11+ that intent just opens a dead-end
// "Install in Settings" dialog with no path forward, which
// is confusing for users.
val fp = CaInstall.fingerprint(this)
val downloadPath = CaInstall.saveToDownloads(this)
if (fp != null) {
pendingFingerprint = fp
pendingDownloadPath = downloadPath
installCaLauncher.launch(CaInstall.buildSettingsIntent())
} else {
caOutcome = CaInstallOutcome.Failed(
"Couldn't read the CA cert. Tap Start once so the proxy creates it, then try again.",
)
}
},
caOutcome = caOutcome,
onCaOutcomeConsumed = { caOutcome = null },
)
}
private fun startVpnService() {
val i = Intent(this, MhrvVpnService::class.java)
startService(i)
}
companion object {
private const val REQ_NOTIF = 42
}
}
@@ -0,0 +1,280 @@
package com.therealaleph.mhrv
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import com.github.shadowsocks.bg.Tun2proxy
import java.util.concurrent.atomic.AtomicBoolean
/**
* Foreground VpnService that:
* 1. Runs the mhrv-rs Rust proxy (HTTP + SOCKS5 on 127.0.0.1).
* 2. Establishes a VPN TUN interface capturing all device traffic.
* 3. Spawns tun2proxy in a background thread — it reads IP packets from
* the TUN fd, runs a userspace TCP/IP stack, and funnels every TCP/UDP
* flow through our local SOCKS5. Without step 3 the TUN captures
* traffic but nothing reads it → DNS_PROBE_STARTED in Chrome (the
* symptom that bit us on the first run).
*
* Loop-avoidance note: our own proxy's OUTBOUND connections to
* google_ip:443 would normally be re-captured by the TUN ("traffic goes in
* circles"). We break the loop by excluding this app's UID from the VPN
* via `addDisallowedApplication(packageName)`. Everything else on the
* device still gets routed through us.
*/
class MhrvVpnService : VpnService() {
private var tun: ParcelFileDescriptor? = null
private var proxyHandle: Long = 0L
private var tun2proxyThread: Thread? = null
private val tun2proxyRunning = AtomicBoolean(false)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand action=${intent?.action ?: "<null>"} startId=$startId")
return when (intent?.action) {
ACTION_STOP -> {
// Drop foreground FIRST — that's what makes the status-bar
// key icon disappear and lets the user see "Stop worked"
// even if the native teardown below takes a few seconds
// (e.g. a dozen in-flight Apps Script requests stuck in
// their 30s timeout). The service itself stays alive until
// stopSelf + the background thread below finish.
try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (t: Throwable) {
Log.w(TAG, "stopForeground: ${t.message}")
}
// Teardown can block on native shutdown (rt.shutdown_timeout
// is 5s max, plus 2s for the tun2proxy join). Do it off the
// main thread so we don't ANR.
Thread({
teardown()
stopSelf()
Log.i(TAG, "teardown done, service stopping")
}, "mhrv-teardown").start()
START_NOT_STICKY
}
else -> {
startEverything()
START_STICKY
}
}
}
private fun startEverything() {
// 1) Seed native with our app's private dir and boot the proxy.
Native.setDataDir(filesDir.absolutePath)
val cfg = ConfigStore.load(this)
if (!cfg.hasDeploymentId || cfg.authKey.isBlank()) {
Log.e(TAG, "Config is incomplete — can't start proxy")
stopSelf()
return
}
// Defensive stop: if a previous startEverything left a handle behind
// (e.g. the user tapped Start twice, or a Stop path errored out
// mid-teardown), release it first. Without this, Native.startProxy
// below binds a brand-new listener while the old one still holds
// :listenPort → "Address already in use" from the Rust side and the
// app looks stuck in a half-configured state.
if (proxyHandle != 0L) {
Log.w(TAG, "startEverything: stale proxyHandle=$proxyHandle; stopping old proxy first")
try { Native.stopProxy(proxyHandle) } catch (_: Throwable) {}
proxyHandle = 0L
}
proxyHandle = Native.startProxy(cfg.toJson())
if (proxyHandle == 0L) {
Log.e(TAG, "Native.startProxy returned 0 — see logcat tag mhrv_rs")
stopSelf()
return
}
val socks5Port = cfg.socks5Port ?: (cfg.listenPort + 1)
// 2) Establish the TUN. Key Builder calls:
// - addAddress(10.0.0.2/32): our local IP inside the tunnel.
// - addRoute(0.0.0.0/0): capture ALL IPv4 traffic. IPv6 isn't added,
// so v6 leaks stay up the normal route — fine for this app.
// - addDnsServer(1.1.1.1): DNS queries go to this IP, which ALSO
// hits our TUN — tun2proxy intercepts in Virtual DNS mode.
// - addDisallowedApplication(packageName): our OWN outbound
// connections bypass the TUN. Without this, the proxy's
// outbound to google_ip loops back through the TUN forever.
// - setBlocking(false): we're going to hand the fd to tun2proxy,
// which does its own async I/O.
val builder = Builder()
.setSession("mhrv-rs")
.setMtu(MTU)
.addAddress("10.0.0.2", 32)
.addRoute("0.0.0.0", 0)
.addDnsServer("1.1.1.1")
.setBlocking(false)
try {
builder.addDisallowedApplication(packageName)
} catch (e: Throwable) {
// Shouldn't happen for our own package, but don't hard-fail.
Log.w(TAG, "addDisallowedApplication failed: ${e.message}")
}
val parcelFd = try {
builder.establish()
} catch (t: Throwable) {
Log.e(TAG, "VpnService.establish() failed: ${t.message}")
null
}
if (parcelFd == null) {
Log.e(TAG, "establish() returned null — is VPN permission granted?")
Native.stopProxy(proxyHandle)
proxyHandle = 0L
stopSelf()
return
}
tun = parcelFd
// 3) Start tun2proxy on a worker thread. It blocks until stop() or
// shutdown. We detach the fd so ownership transfers cleanly; the
// ParcelFileDescriptor (`tun`) still holds a reference, so closing
// it at teardown reliably tears down the TUN even if tun2proxy
// doesn't cleanly exit.
val detachedFd = parcelFd.detachFd()
tun2proxyRunning.set(true)
tun2proxyThread = Thread({
try {
val rc = Tun2proxy.run(
"socks5://127.0.0.1:$socks5Port",
detachedFd,
/* closeFdOnDrop = */ true,
MTU.toChar(),
/* verbosity = info */ 3,
/* dnsStrategy = virtual */ 0,
)
Log.i(TAG, "tun2proxy exited rc=$rc")
} catch (t: Throwable) {
Log.e(TAG, "tun2proxy crashed: ${t.message}", t)
} finally {
tun2proxyRunning.set(false)
}
}, "tun2proxy").apply { start() }
startForeground(NOTIF_ID, buildNotif(cfg.listenPort))
}
/**
* Tear down everything this service owns. Safe to call more than once:
* - `Tun2proxy.stop()` is idempotent on its side.
* - tun2proxyRunning gating means we skip the stop call when the
* worker thread has already exited.
* - `tun` and `proxyHandle` are nulled/zeroed after one pass, so a
* second call is a no-op.
*
* Shutdown order matters. Doing it wrong (we did originally) leaves
* tun2proxy still forwarding packets into a half-dead Rust runtime
* while the runtime is force-aborting its tasks — that's the scenario
* that manifested as "Stop crashes the app" when there were in-flight
* relay requests piled up against a dead Apps Script deployment. The
* correct order is:
* 1. Signal tun2proxy to stop (cooperative).
* 2. Close the TUN fd — forces tun2proxy's read() to return EBADF.
* 3. Join the tun2proxy thread (now it really will exit).
* 4. Shut down the Rust proxy runtime (nothing left to forward to).
*/
private fun teardown() {
Log.i(TAG, "teardown: begin (tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)")
// 1. Cooperative stop signal.
if (tun2proxyRunning.get()) {
try { Tun2proxy.stop() } catch (t: Throwable) {
Log.w(TAG, "Tun2proxy.stop: ${t.message}")
}
}
// 2. Close the TUN fd. Since we called detachFd earlier the
// ParcelFileDescriptor no longer owns the fd and close() here
// is a no-op; the real fd is owned by tun2proxy (closeFdOnDrop
// = true), which closes it on return from run().
try { tun?.close() } catch (_: Throwable) {}
tun = null
// 3. Join the worker. 4s is enough in the happy case; if tun2proxy
// is stuck on something untoward we'd rather move on and force
// the runtime shutdown than hang forever.
try {
tun2proxyThread?.join(4_000)
} catch (_: InterruptedException) {}
val stillAlive = tun2proxyThread?.isAlive == true
tun2proxyThread = null
if (stillAlive) {
Log.w(TAG, "tun2proxy thread still alive after join timeout — proceeding anyway")
}
// 4. Shut down the Rust proxy. Backed by `rt.shutdown_timeout(3s)`
// on the Rust side, so this is bounded even if the runtime
// has in-flight tasks (common when the Apps Script relay has
// piled up pending 30s timeouts).
if (proxyHandle != 0L) {
Log.i(TAG, "teardown: stopping proxy handle=$proxyHandle")
try { Native.stopProxy(proxyHandle) } catch (t: Throwable) {
Log.e(TAG, "Native.stopProxy threw: ${t.message}", t)
}
proxyHandle = 0L
}
Log.i(TAG, "teardown: done")
}
override fun onDestroy() {
teardown()
super.onDestroy()
}
private fun buildNotif(proxyPort: Int): Notification {
val mgr = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(
CHANNEL_ID,
"mhrv-rs",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Status of the mhrv-rs VPN"
setShowBadge(false)
}
mgr.createNotificationChannel(ch)
}
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stopIntent = PendingIntent.getService(
this,
1,
Intent(this, MhrvVpnService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("mhrv-rs VPN is active")
.setContentText("Routing via SOCKS5 127.0.0.1:${proxyPort + 1}")
.setSmallIcon(android.R.drawable.presence_online)
.setContentIntent(openIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
companion object {
private const val TAG = "MhrvVpnService"
private const val CHANNEL_ID = "mhrv.vpn.status"
private const val NOTIF_ID = 0x1001
private const val MTU = 1500
const val ACTION_STOP = "com.therealaleph.mhrv.STOP"
}
}
@@ -0,0 +1,68 @@
package com.therealaleph.mhrv
/**
* JNI bindings for the mhrv_rs Rust crate. The crate is compiled to
* libmhrv_rs.so and loaded at app start.
*
* All methods are blocking on a short-lived native call — the proxy itself
* runs on a Rust-side tokio runtime, not on the JVM thread that calls in.
* The returned handles are opaque to Kotlin; pass them back to stop() /
* statsJson() / etc.
*
* Thread-safe: the underlying Rust side guards its state with a mutex.
*/
object Native {
init {
System.loadLibrary("mhrv_rs")
}
/**
* Tell the Rust side where to put config + CA + cache. Must be called
* once before any other call. The path we hand over is our app's
* private filesDir — guaranteed writable, auto-cleaned on uninstall.
*/
external fun setDataDir(path: String)
/**
* Spin up the proxy. `configJson` is the full config.json contents as
* a String. Returns the handle (positive) on success, or 0 on failure
* (inspect logcat for the failure reason).
*/
external fun startProxy(configJson: String): Long
/**
* Stop a running proxy. Idempotent: returns false if the handle is
* unknown (e.g. already stopped).
*/
external fun stopProxy(handle: Long): Boolean
/**
* Copy the MITM CA cert to a destination path. Used by the UI to
* surface ca.crt in Downloads so the user can feed it to Android's
* system "Install certificate" picker.
*/
external fun exportCa(destPath: String): Boolean
/** mhrv_rs crate version. Smoke test for JNI linkage. */
external fun version(): String
/**
* Drain the in-memory log ring buffer (populated by the same tracing
* subscriber that feeds logcat). Returns a `\n`-joined blob of any
* events the UI hasn't seen yet, or an empty string.
*
* Cheap to call — the Kotlin side polls this on a timer. Single blob
* instead of `String[]` because one JNI crossing is much faster than N.
*/
external fun drainLogs(): String
/**
* Probe a single SNI against `googleIp`. Returns a JSON string of the
* form `{"ok":true,"latencyMs":123}` on success or
* `{"ok":false,"error":"..."}` on failure.
*
* BLOCKS (does a TLS handshake); call from a background dispatcher.
*/
external fun testSni(googleIp: String, sni: String): String
}
@@ -0,0 +1,796 @@
package com.therealaleph.mhrv.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.HourglassBottom
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.therealaleph.mhrv.CaInstall
import com.therealaleph.mhrv.ConfigStore
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
import com.therealaleph.mhrv.MhrvConfig
import com.therealaleph.mhrv.Native
import com.therealaleph.mhrv.ui.theme.OkGreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* UI state returned by the Activity after the CA install flow finishes,
* so the screen can show a matching snackbar. Kept as a sum type — a raw
* string message would conflate "installed" vs. "failed to export".
*/
sealed class CaInstallOutcome {
object Installed : CaInstallOutcome()
/**
* Cert not found in the AndroidCAStore after the Settings activity
* returned. Carries an optional downloadPath so the snackbar can tell
* the user where the file landed (Downloads or app-private external).
*/
data class NotInstalled(val downloadPath: String?) : CaInstallOutcome()
data class Failed(val message: String) : CaInstallOutcome()
}
/**
* Top-level screen. Intentionally one scrollable page rather than tabs —
* first-run users need to see everything (deployment IDs, cert button,
* Start) on one surface. Anything that isn't first-run critical lives in
* collapsible sections (SNI pool, Advanced, Logs) so the default view
* stays short.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onStart: () -> Unit,
onStop: () -> Unit,
onInstallCaConfirmed: () -> Unit,
caOutcome: CaInstallOutcome?,
onCaOutcomeConsumed: () -> Unit,
) {
val ctx = LocalContext.current
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
// Persisted form state. Any edit writes back to disk immediately —
// cheap at this write rate, avoids "I tapped Start before saving" bugs.
var cfg by remember { mutableStateOf(ConfigStore.load(ctx)) }
fun persist(new: MhrvConfig) {
cfg = new
ConfigStore.save(ctx, new)
}
// CA install dialog visibility.
var showInstallDialog by rememberSaveable { mutableStateOf(false) }
// Cooldown on Start/Stop. Rapid taps during a VPN transition trigger
// an emulator-specific EGL renderer crash
// (F OpenGLRenderer: EGL_NOT_INITIALIZED during rendering) — the
// service survives, but the Compose UI process dies and the app
// appears to close. On real hardware this is rare, but debouncing
// is useful UX anyway: neither start nor stop is truly instant,
// and the user gets no feedback if they tap while one is in flight.
var transitionCooldown by remember { mutableStateOf(false) }
LaunchedEffect(transitionCooldown) {
if (transitionCooldown) {
delay(2000)
transitionCooldown = false
}
}
// Surface CA install result as a snackbar. We consume the outcome
// after showing so a recomposition doesn't re-trigger it.
LaunchedEffect(caOutcome) {
val o = caOutcome ?: return@LaunchedEffect
val msg = when (o) {
is CaInstallOutcome.Installed ->
"Certificate installed ✓"
is CaInstallOutcome.NotInstalled -> buildString {
append("Certificate not yet installed.")
if (!o.downloadPath.isNullOrBlank()) {
append(" Saved to ${o.downloadPath}. ")
append("In Settings: Encryption & credentials → Install a certificate → \"CA certificate\" (not VPN, not Wi-Fi) → pick that file.")
} else {
append(" Tap Install again to retry.")
}
}
is CaInstallOutcome.Failed -> o.message
}
snackbar.showSnackbar(msg, withDismissAction = true)
onCaOutcomeConsumed()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("mhrv-rs") },
actions = {
Text(
text = "v" + runCatching { Native.version() }.getOrDefault("?"),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(end = 12.dp),
)
},
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
Column(
modifier = Modifier
.padding(inner)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SectionHeader("Apps Script relay")
DeploymentIdsField(
urls = cfg.appsScriptUrls,
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
)
OutlinedTextField(
value = cfg.authKey,
onValueChange = { persist(cfg.copy(authKey = it)) },
label = { Text("auth_key") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
supportingText = {
Text("The shared secret you set in the Apps Script.")
},
)
Spacer(Modifier.height(4.dp))
SectionHeader("Network")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = cfg.googleIp,
onValueChange = { persist(cfg.copy(googleIp = it)) },
label = { Text("google_ip") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = cfg.frontDomain,
onValueChange = { persist(cfg.copy(frontDomain = it)) },
label = { Text("front_domain") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.weight(1f),
)
}
// SNI pool: collapsed by default. Users without a reason to
// touch it should leave Rust's auto-expansion to handle it.
CollapsibleSection(title = "SNI pool + tester") {
SniPoolEditor(
cfg = cfg,
onChange = ::persist,
)
}
// Advanced settings: collapsed by default.
CollapsibleSection(title = "Advanced") {
AdvancedSettings(
cfg = cfg,
onChange = ::persist,
)
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
transitionCooldown = true
onStart()
},
enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
modifier = Modifier.weight(1f),
) {
Text(if (transitionCooldown) "" else "Start")
}
OutlinedButton(
onClick = {
transitionCooldown = true
onStop()
},
enabled = !transitionCooldown,
modifier = Modifier.weight(1f),
) {
Text(if (transitionCooldown) "" else "Stop")
}
}
Spacer(Modifier.height(4.dp))
// Secondary accent button — FilledTonalButton reads as a lower-
// priority action next to Start/Stop, matching the desktop UI's
// visual hierarchy where Install CA is offered as a helper
// button rather than the headline action.
FilledTonalButton(
onClick = { showInstallDialog = true },
modifier = Modifier.fillMaxWidth(),
) {
Text("Install MITM certificate")
}
CollapsibleSection(title = "Live logs", initiallyExpanded = false) {
LiveLogPane()
}
Spacer(Modifier.height(16.dp))
HowToUseCard(cfg.listenPort)
}
}
// ---- CA install confirmation dialog ---------------------------------
if (showInstallDialog) {
// Export eagerly so we can show the fingerprint in the dialog body
// — builds user confidence ("yes, that's the cert I'm trusting")
// and gives us a usable failure path if the CA doesn't exist yet.
val exported = remember { CaInstall.export(ctx) }
val fp = remember(exported) { if (exported) CaInstall.fingerprint(ctx) else null }
val cn = remember(exported) { if (exported) CaInstall.subjectCn(ctx) else null }
AlertDialog(
onDismissRequest = { showInstallDialog = false },
title = { Text("Install MITM certificate?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"mhrv-rs creates a local certificate authority so it can decrypt " +
"and re-encrypt HTTPS traffic before tunnelling it through the Apps " +
"Script relay. Without this CA installed as trusted, apps will show " +
"certificate errors."
)
Text(
"On Android 11+ the system removed the inline install path, so " +
"tapping Install will: (1) save a PEM copy to Downloads/mhrv-ca.crt, " +
"(2) open Security settings. From there navigate to Encryption & " +
"credentials → Install a certificate → pick \"CA certificate\" (NOT " +
"\"VPN & app user certificate\" or \"Wi-Fi certificate\") → select " +
"mhrv-ca.crt from Downloads. If you don't have a screen lock, Android " +
"will ask you to add one first."
)
if (fp != null) {
Text("Subject: ${cn ?: "(unknown)"}", style = MaterialTheme.typography.labelMedium)
Text(
text = "SHA-256: ${CaInstall.fingerprintHex(fp)}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
)
} else {
Text(
"Could not read the CA cert yet. Tap Start once so the " +
"proxy generates it, then come back.",
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
showInstallDialog = false
if (fp != null) onInstallCaConfirmed()
},
enabled = fp != null,
) { Text("Install") }
},
dismissButton = {
TextButton(onClick = { showInstallDialog = false }) { Text("Cancel") }
},
)
}
}
// =========================================================================
// Deployment IDs editor (multi-line, one URL/ID per line).
// =========================================================================
@Composable
private fun DeploymentIdsField(
urls: List<String>,
onChange: (List<String>) -> Unit,
) {
// Treat the list as newline-joined text. Keep trailing newlines so the
// cursor behaves naturally while the user is adding a new entry.
var raw by remember(urls) { mutableStateOf(urls.joinToString("\n")) }
OutlinedTextField(
value = raw,
onValueChange = {
raw = it
val parsed = it.split("\n").map(String::trim).filter(String::isNotBlank)
onChange(parsed)
},
label = { Text("Deployment URL(s) or script ID(s)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 6,
supportingText = {
Text(
"One per line. Full URLs (https://script.google.com/macros/s/.../exec) " +
"or bare IDs — mix as you like. Multiple IDs are rotated round-robin.",
)
},
)
}
// =========================================================================
// SNI pool editor + per-SNI probe.
// =========================================================================
private sealed class ProbeState {
object Idle : ProbeState()
object InFlight : ProbeState()
data class Ok(val latencyMs: Int) : ProbeState()
data class Err(val message: String) : ProbeState()
}
@Composable
private fun SniPoolEditor(
cfg: MhrvConfig,
onChange: (MhrvConfig) -> Unit,
) {
val scope = rememberCoroutineScope()
// Build the displayed list: union of the default pool + the config's
// sniHosts + the current front_domain. Order: front_domain first,
// defaults, then user customs. Deduped.
val displayed: List<String> = remember(cfg) {
val seen = linkedSetOf<String>()
if (cfg.frontDomain.isNotBlank()) seen.add(cfg.frontDomain.trim())
DEFAULT_SNI_POOL.forEach { seen.add(it) }
cfg.sniHosts.forEach { if (it.isNotBlank()) seen.add(it.trim()) }
seen.toList()
}
// A host is enabled if it appears in cfg.sniHosts. Empty sniHosts
// means "let Rust auto-expand" — we reflect that as "default pool
// enabled, customs not".
val enabledSet: Set<String> = remember(cfg.sniHosts) {
if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts.toSet()
else DEFAULT_SNI_POOL.toSet() + setOfNotNull(cfg.frontDomain.takeIf { it.isNotBlank() })
}
val probeState = remember { mutableStateMapOf<String, ProbeState>() }
fun probe(sni: String) {
probeState[sni] = ProbeState.InFlight
scope.launch {
val json = withContext(Dispatchers.IO) {
runCatching { Native.testSni(cfg.googleIp, sni) }.getOrNull()
}
probeState[sni] = parseProbeResult(json)
}
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"Enabled SNIs are rotated when connecting to google_ip. Leaving all unchecked " +
"lets Rust auto-expand the default Google pool.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
displayed.forEach { sni ->
val enabled = sni in enabledSet
SniRow(
sni = sni,
enabled = enabled,
state = probeState[sni] ?: ProbeState.Idle,
onToggle = { nowEnabled ->
val next = if (nowEnabled) {
(cfg.sniHosts.takeIf { it.isNotEmpty() } ?: emptyList()) + sni
} else {
val current = if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts else enabledSet.toList()
current.filter { it != sni }
}
onChange(cfg.copy(sniHosts = next.distinct()))
},
onTest = { probe(sni) },
)
}
// Custom-add row.
var custom by remember { mutableStateOf("") }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = custom,
onValueChange = { custom = it },
label = { Text("Add custom SNI") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.weight(1f),
)
TextButton(
onClick = {
val s = custom.trim()
if (s.isNotEmpty()) {
val next = (cfg.sniHosts.takeIf { it.isNotEmpty() } ?: enabledSet.toList()) + s
onChange(cfg.copy(sniHosts = next.distinct()))
custom = ""
}
},
enabled = custom.isNotBlank(),
) { Text("Add") }
}
TextButton(
onClick = { displayed.forEach { probe(it) } },
modifier = Modifier.align(Alignment.End),
) { Text("Test all") }
}
}
@Composable
private fun SniRow(
sni: String,
enabled: Boolean,
state: ProbeState,
onToggle: (Boolean) -> Unit,
onTest: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Checkbox(checked = enabled, onCheckedChange = onToggle)
Text(
sni,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
ProbeBadge(state)
Spacer(Modifier.width(4.dp))
TextButton(onClick = onTest, enabled = state !is ProbeState.InFlight) {
Text("Test")
}
}
// Show the error reason on its own line when the probe failed —
// a red dot with no explanation was confusing ("SNI test also
// fails despite having internet"). Common reasons: "dns: ..." or
// "connect: ...".
if (state is ProbeState.Err) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 48.dp, bottom = 4.dp),
)
}
}
}
@Composable
private fun ProbeBadge(state: ProbeState) {
when (state) {
is ProbeState.Idle -> {}
is ProbeState.InFlight -> {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
}
is ProbeState.Ok -> {
Row(verticalAlignment = Alignment.CenterVertically) {
// Same green the desktop UI uses for OK status (OK_GREEN
// in src/bin/ui.rs line 510) — kept in sync via Theme.kt.
Icon(
Icons.Default.CheckCircle, null,
tint = OkGreen,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(2.dp))
Text("${state.latencyMs} ms", style = MaterialTheme.typography.labelSmall)
}
}
is ProbeState.Err -> {
Icon(
Icons.Default.ErrorOutline, state.message,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
}
private fun parseProbeResult(json: String?): ProbeState {
if (json.isNullOrBlank()) return ProbeState.Err("no response")
return try {
val obj = JSONObject(json)
if (obj.optBoolean("ok", false)) {
ProbeState.Ok(obj.optInt("latencyMs", -1))
} else {
ProbeState.Err(obj.optString("error", "failed"))
}
} catch (_: Throwable) {
ProbeState.Err("bad json")
}
}
// =========================================================================
// Advanced settings.
// =========================================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AdvancedSettings(
cfg: MhrvConfig,
onChange: (MhrvConfig) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
// verify_ssl
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Verify upstream TLS", style = MaterialTheme.typography.bodyMedium)
Text(
"Off disables cert checks for the Google edge. Only useful for debugging.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = cfg.verifySsl,
onCheckedChange = { onChange(cfg.copy(verifySsl = it)) },
)
}
// log_level dropdown
var expanded by remember { mutableStateOf(false) }
val levels = listOf("trace", "debug", "info", "warn", "error", "off")
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = cfg.logLevel,
onValueChange = {},
readOnly = true,
label = { Text("log_level") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
levels.forEach { lvl ->
DropdownMenuItem(
text = { Text(lvl) },
onClick = {
onChange(cfg.copy(logLevel = lvl))
expanded = false
},
)
}
}
}
// parallel_relay slider
Column {
Text(
"parallel_relay: ${cfg.parallelRelay}",
style = MaterialTheme.typography.bodyMedium,
)
Slider(
value = cfg.parallelRelay.toFloat(),
onValueChange = { onChange(cfg.copy(parallelRelay = it.toInt().coerceIn(1, 5))) },
valueRange = 1f..5f,
steps = 3, // yields 1,2,3,4,5 positions
)
Text(
"Fan-out per request. 1 is normal; bump to 2-3 on lossy links.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = cfg.upstreamSocks5,
onValueChange = { onChange(cfg.copy(upstreamSocks5 = it)) },
label = { Text("upstream_socks5 (optional)") },
placeholder = { Text("host:port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = {
Text("If set, route upstream via this SOCKS5. Leave blank for direct.")
},
)
}
}
// =========================================================================
// Live log pane — polls Native.drainLogs() on a 500ms tick.
// =========================================================================
@Composable
private fun LiveLogPane() {
val lines = remember { mutableStateListOf<String>() }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Pull from the ring buffer periodically. We pull even while the
// section is collapsed (cheap), so re-expanding shows fresh tail.
LaunchedEffect(Unit) {
while (true) {
val blob = withContext(Dispatchers.IO) {
runCatching { Native.drainLogs() }.getOrNull()
}
if (!blob.isNullOrEmpty()) {
blob.split("\n").forEach { if (it.isNotBlank()) lines.add(it) }
// Cap the visible list so we don't grow unboundedly.
while (lines.size > 500) lines.removeAt(0)
// Follow tail.
if (lines.isNotEmpty()) {
listState.scrollToItem(lines.size - 1)
}
}
delay(500)
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${lines.size} lines",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { lines.clear() }) { Text("Clear") }
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp, max = 320.dp),
) {
LazyColumn(
state = listState,
modifier = Modifier.padding(8.dp),
) {
items(lines) { line ->
Text(
line,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
}
}
}
}
}
// =========================================================================
// Small shared pieces.
// =========================================================================
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
)
}
/**
* Minimal disclosure widget. Compose has no stock "expandable card" in
* Material3 yet, so we build it from a clickable header + AnimatedVisibility
* wrapping the content.
*/
@Composable
private fun CollapsibleSection(
title: String,
initiallyExpanded: Boolean = false,
content: @Composable ColumnScope.() -> Unit,
) {
var expanded by rememberSaveable(title) { mutableStateOf(initiallyExpanded) }
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { expanded = !expanded }) {
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
)
}
}
AnimatedVisibility(visible = expanded) {
Column(
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
}
}
@Composable
private fun HowToUseCard(listenPort: Int) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("How to use", style = MaterialTheme.typography.titleMedium)
Text(
"1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n" +
"2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to " +
"Downloads/mhrv-ca.crt and Security settings opens. Navigate: Encryption & " +
"credentials → Install a certificate → \"CA certificate\" (NOT \"VPN & app user " +
"certificate\" or \"Wi-Fi\"). Pick mhrv-ca.crt from Downloads. You'll be asked to " +
"set a screen lock if you don't have one (Android requirement).\n" +
"3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If " +
"every entry times out, your google_ip is unreachable — replace it with one that " +
"resolves locally (e.g. `nslookup www.google.com` on any working device).\n" +
"4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the " +
"device through the proxy — no per-app setup needed.\n" +
"5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn't " +
"responding. Redeploy the script, grab the new /exec URL, and paste it above. " +
"Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer " +
"is failing.\n" +
"\n" +
"Known limitation — Cloudflare Turnstile (\"Verify you are human\") will loop " +
"endlessly on most CF-protected sites. Every Apps Script request uses a rotating " +
"Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a " +
"Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) " +
"tuple the challenge was solved against, so the NEXT request — from a different " +
"egress IP — gets re-challenged. Nothing in this app can fix that; it's inherent " +
"to Apps Script as a relay. Sites that only gate the initial page load (not every " +
"request) will work after one solve.",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@@ -0,0 +1,97 @@
package com.therealaleph.mhrv.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Visual theme tuned to match the desktop `mhrv-rs-ui` eframe UI pixel-for-pixel
* where Compose semantics allow. The canonical source lives in `src/bin/ui.rs`
* — these constants are the same `egui::Color32` values, re-expressed as
* `Color(0xAARRGGBB)`. If you change a value here and not there (or vice
* versa) the two builds will drift visibly.
*
* Deliberate choices:
* - ALWAYS dark. The desktop UI is always dark (`egui::Visuals::dark()`),
* so Android follows. Neither light mode nor Android 12+ dynamic color
* is respected — matching the desktop trumps blending with the user's
* wallpaper here.
* - Card corners 6.dp, button corners 4.dp, matching the eframe
* `.rounding(6.0)` / `.rounding(4.0)` pairs in the desktop code.
*/
// Exact palette from src/bin/ui.rs (line 508+).
// ACCENT / ACCENT_HOVER
val AccentBlue = Color(0xFF4678B4)
val AccentHover = Color(0xFF5A91CD)
// OK_GREEN / ERR_RED
val OkGreen = Color(0xFF50B464)
val ErrRed = Color(0xFFDC6E6E)
// Card fill and stroke used by section containers in the desktop UI.
val CardFill = Color(0xFF1C1E22)
val CardStroke = Color(0xFF32363C)
// Backdrop slightly darker than cards so containers pop off the page —
// egui's default dark background sits right around this value.
val BgDark = Color(0xFF111317)
// Text shades — `egui::Color32::from_gray(200)` etc.
val TextPrimary = Color(0xFFC8C8C8)
val TextSecondary = Color(0xFF8C8C8C)
val TextLabel = Color(0xFFB4B4B4)
private val MhrvDark = darkColorScheme(
primary = AccentBlue,
onPrimary = Color.White,
primaryContainer = AccentHover,
onPrimaryContainer = Color.White,
secondary = OkGreen,
onSecondary = Color.Black,
tertiary = OkGreen,
onTertiary = Color.Black,
error = ErrRed,
onError = Color.White,
background = BgDark,
onBackground = TextPrimary,
surface = CardFill,
onSurface = TextPrimary,
surfaceVariant = CardFill,
onSurfaceVariant = TextSecondary,
outline = CardStroke,
outlineVariant = CardStroke,
)
/**
* Material3 consumes Shapes through component defaults (Button uses
* `shapes.full`, Card uses `shapes.medium`, etc.). Mapping every size to
* tight rounded-rectangles keeps the whole app visually consistent with
* the desktop's squared-off controls instead of Material's default pills.
*/
private val MhrvShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(6.dp),
large = RoundedCornerShape(6.dp),
extraLarge = RoundedCornerShape(8.dp),
)
@Composable
fun MhrvTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = MhrvDark,
shapes = MhrvShapes,
content = content,
)
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Vector launcher icon. Simple monogram "M" in the mhrv-rs accent color.
Using a vector keeps the APK small and adapts to any density.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#4678B4"
android:pathData="M30,30 L30,78 L38,78 L38,46 L54,70 L70,46 L70,78 L78,78 L78,30 L70,30 L54,56 L38,30 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/black" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/black" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">mhrv-rs</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Compose owns the runtime theme. This is only the pre-Compose splash
state (while the activity is starting) — set to a plain dark
background so you don't see a flash of white before the first frame.
-->
<style name="Theme.Mhrv" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
We explicitly trust user-installed CAs for this app's own outbound
requests. This lets us talk to our OWN local MITM proxy (which signs
leaf certs with the CA we generate). Without this declaration, on
Android 7+ apps ignore user CAs — which is the whole pain point
users hit when trying to use mhrv-rs from the phone.
The system default (cleartextTrafficPermitted=false) stays. Plain
HTTP is not allowed except to localhost, which the VpnService loop
uses.
-->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
+6
View File
@@ -0,0 +1,6 @@
// Top-level build file. Versions are pinned here and inherited by :app.
plugins {
id("com.android.application") version "8.5.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
}
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
# Pinned below SDK 35 for now — Android 14 (API 34) is fine for an app
# this simple; bumping later is a one-line change once we know the
# minimum-device-tested set.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "mhrv-android"
include(":app")
+385
View File
@@ -0,0 +1,385 @@
//! JNI entry points for the Android app.
//!
//! The app (Kotlin) calls `Native.setDataDir()` once, then `Native.startProxy()`
//! with the full config.json payload and gets back a handle (u64). Later the
//! app calls `stopProxy(handle)` to stop, `statsJson(handle)` to poll, or
//! `exportCa(dest)` to copy the MITM CA cert to a path the app can hand to
//! Android's system "install certificate" dialog.
//!
//! The proxy runs on an internal tokio runtime that we own (1 worker thread
//! minimum) — we don't piggyback on the JVM thread that calls in.
//!
//! SAFETY: every `extern "system"` entry point catches panics so they never
//! unwind across the JNI boundary (UB otherwise).
#![cfg(target_os = "android")]
use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use jni::objects::{JClass, JString};
use jni::sys::{jboolean, jlong, jstring, JNI_FALSE, JNI_TRUE};
use jni::JNIEnv;
use tokio::runtime::Runtime;
use tokio::sync::{oneshot, Mutex as AsyncMutex};
use crate::config::Config;
use crate::mitm::{MitmCertManager, CA_CERT_FILE};
use crate::proxy_server::ProxyServer;
/// Running-proxy record. The JNI handle is the index into a slot map we
/// keep in a lazy-initialized global — we can't round-trip a Rust pointer
/// through `jlong` safely if the JVM compacts, but we can hand out an
/// integer key.
struct Running {
/// Dropping this sends the shutdown signal. Optional so we can `take()`
/// it in stop().
shutdown: Option<oneshot::Sender<()>>,
/// Own the runtime so it outlives the server. Dropped last.
rt: Option<Runtime>,
}
static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
fn slot_map() -> &'static Mutex<std::collections::HashMap<u64, Running>> {
static SLOTS: OnceLock<Mutex<std::collections::HashMap<u64, Running>>> = OnceLock::new();
SLOTS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
// ---------------------------------------------------------------------------
// Logging bridge.
//
// We fan each tracing event out two ways:
// 1. `__android_log_write` — lands in `adb logcat` under tag `mhrv_rs`.
// 2. An in-memory ring buffer the Kotlin UI drains via `Native.drainLogs()`.
// The first path was enough to get past "startProxy returned 0 — silent
// failure"; the second path gives the user a live log panel without making
// them attach a debugger.
// ---------------------------------------------------------------------------
extern "C" {
fn __android_log_write(prio: i32, tag: *const std::os::raw::c_char, text: *const std::os::raw::c_char) -> i32;
}
const ANDROID_LOG_INFO: i32 = 4;
const LOG_RING_CAP: usize = 500;
fn log_ring() -> &'static Mutex<VecDeque<String>> {
static RING: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
RING.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_RING_CAP)))
}
/// MakeWriter that forwards each write to `__android_log_write` AND to the
/// in-memory ring buffer. One line per write call; we trim the trailing
/// newline that tracing-subscriber appends so logcat doesn't show blank
/// rows between every event.
struct LogcatWriter;
impl std::io::Write for LogcatWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
// Skip empty writes — tracing occasionally flushes a bare "\n".
if buf.is_empty() { return Ok(0); }
let trimmed = if buf.ends_with(b"\n") { &buf[..buf.len() - 1] } else { buf };
// logcat side.
let mut cstr = Vec::with_capacity(trimmed.len() + 1);
cstr.extend_from_slice(trimmed);
cstr.push(0);
static TAG: &[u8] = b"mhrv_rs\0";
unsafe {
__android_log_write(
ANDROID_LOG_INFO,
TAG.as_ptr() as *const std::os::raw::c_char,
cstr.as_ptr() as *const std::os::raw::c_char,
);
}
// ring-buffer side. Best-effort UTF-8; if there are invalid bytes
// we'd rather show replacement chars than drop the line entirely.
if let Ok(mut g) = log_ring().lock() {
if g.len() >= LOG_RING_CAP {
g.pop_front();
}
let line = String::from_utf8_lossy(trimmed).into_owned();
g.push_back(line);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogcatWriter {
type Writer = LogcatWriter;
fn make_writer(&'a self) -> Self::Writer { LogcatWriter }
}
fn install_logging_once() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_ansi(false)
.with_writer(LogcatWriter)
.try_init();
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
/// Helper: JString -> String, defaulting to "" on any failure.
fn jstring_to_string(env: &mut JNIEnv, s: &JString) -> String {
env.get_string(s)
.map(|j| j.into())
.unwrap_or_else(|_| String::new())
}
fn safe<F: FnOnce() -> R + std::panic::UnwindSafe, R>(default: R, f: F) -> R {
std::panic::catch_unwind(f).unwrap_or(default)
}
/// Build a throwaway tokio runtime for one-shot blocking calls from JNI.
/// Small, single-worker — sufficient for probes and cert ops.
fn one_shot_runtime() -> Option<Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()
}
/// `Native.setDataDir(String)` — must be called once, before `startProxy`.
/// The Kotlin side passes `context.filesDir.absolutePath`.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_setDataDir(
mut env: JNIEnv,
_class: JClass,
path: JString,
) {
let _ = safe((), AssertUnwindSafe(|| {
install_logging_once();
let p = jstring_to_string(&mut env, &path);
if !p.is_empty() {
crate::data_dir::set_data_dir(PathBuf::from(p));
}
}));
}
/// `Native.startProxy(String configJson)` -> `long` handle (0 on failure).
/// The config is parsed and validated; on success the proxy server is
/// spawned on its own tokio runtime and a non-zero handle returned.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy(
mut env: JNIEnv,
_class: JClass,
config_json: JString,
) -> jlong {
safe(0i64, AssertUnwindSafe(|| {
install_logging_once();
let json = jstring_to_string(&mut env, &config_json);
let config: Config = match serde_json::from_str(&json) {
Ok(c) => c,
Err(e) => {
tracing::error!("android: invalid config json: {}", e);
return 0i64;
}
};
// Try to build the runtime first — if allocation fails we want to
// know before spinning up anything stateful.
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("mhrv-worker")
.build()
{
Ok(r) => r,
Err(e) => {
tracing::error!("android: tokio runtime build failed: {}", e);
return 0i64;
}
};
let base = crate::data_dir::data_dir();
let mitm = match MitmCertManager::new_in(&base) {
Ok(m) => m,
Err(e) => {
tracing::error!("android: MITM CA init failed: {}", e);
return 0i64;
}
};
let mitm = Arc::new(AsyncMutex::new(mitm));
let server = match ProxyServer::new(&config, mitm) {
Ok(s) => s,
Err(e) => {
tracing::error!("android: ProxyServer::new failed: {}", e);
return 0i64;
}
};
let (tx, rx) = oneshot::channel::<()>();
rt.spawn(async move {
if let Err(e) = server.run(rx).await {
tracing::error!("android: proxy server exited: {}", e);
}
});
let handle = HANDLE_COUNTER.fetch_add(1, Ordering::Relaxed);
slot_map().lock().unwrap().insert(
handle,
Running {
shutdown: Some(tx),
rt: Some(rt),
},
);
handle as jlong
}))
}
/// `Native.stopProxy(long handle)` -> boolean. Idempotent: calling on an
/// unknown handle returns false quietly.
///
/// Uses `Runtime::shutdown_timeout` instead of letting `drop(rt)` block
/// synchronously. `drop(rt)` waits forever for tokio tasks to finish, and
/// if ANY task is stuck (in-flight TLS handshake, retrying HTTP request,
/// blocked read) the whole thing deadlocks — which is exactly what caused
/// the reported "Stop doesn't disconnect; subsequent Start fails with
/// Address already in use" bug. 3s is enough for a cooperative server to
/// unwind; anything slower, we force-kill (the listener socket is released
/// as part of the forced shutdown).
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_stopProxy(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jboolean {
safe(JNI_FALSE, AssertUnwindSafe(|| {
let mut map = slot_map().lock().unwrap();
let Some(mut running) = map.remove(&(handle as u64)) else {
return JNI_FALSE;
};
if let Some(tx) = running.shutdown.take() {
let _ = tx.send(());
}
// Release the map lock BEFORE shutting the runtime down so concurrent
// JNI callers (stats queries, etc.) don't stall behind us.
drop(map);
if let Some(rt) = running.rt.take() {
tracing::info!("android: stopProxy handle={} — shutting runtime down", handle);
rt.shutdown_timeout(std::time::Duration::from_secs(5));
tracing::info!("android: stopProxy handle={} — runtime shutdown complete", handle);
}
JNI_TRUE
}))
}
/// `Native.exportCa(String destPath)` -> boolean. Writes the MITM CA's
/// public cert to the given path. Init-safe: creates the CA on first call
/// if it doesn't exist yet.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_exportCa(
mut env: JNIEnv,
_class: JClass,
dest: JString,
) -> jboolean {
safe(JNI_FALSE, AssertUnwindSafe(|| {
install_logging_once();
let dest_path = jstring_to_string(&mut env, &dest);
if dest_path.is_empty() {
return JNI_FALSE;
}
let base = crate::data_dir::data_dir();
if MitmCertManager::new_in(&base).is_err() {
return JNI_FALSE;
}
let src = base.join(CA_CERT_FILE);
match std::fs::copy(&src, &dest_path) {
Ok(_) => JNI_TRUE,
Err(e) => {
tracing::error!("android: CA export to {} failed: {}", dest_path, e);
JNI_FALSE
}
}
}))
}
/// `Native.version()` -> String. Trivial smoke test for the JNI linkage.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_version<'a>(
env: JNIEnv<'a>,
_class: JClass,
) -> jstring {
let v = env!("CARGO_PKG_VERSION");
env.new_string(v).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
}
/// `Native.drainLogs()` -> String. Returns the full ring buffer as a single
/// `\n`-joined blob, then clears it. We return one String rather than an
/// array because it's one JNI call vs. N — the Kotlin side splits on `\n`
/// for display. Empty string when there's nothing to read.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>(
env: JNIEnv<'a>,
_class: JClass,
) -> jstring {
let out = safe(String::new(), AssertUnwindSafe(|| {
let mut g = match log_ring().lock() {
Ok(g) => g,
Err(_) => return String::new(),
};
let lines: Vec<String> = g.drain(..).collect();
lines.join("\n")
}));
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
}
/// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob
/// like `{"ok":true,"latencyMs":123}` or `{"ok":false,"error":"..."}`.
/// Blocking call — Kotlin side should invoke on a background coroutine.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
google_ip: JString,
sni: JString,
) -> jstring {
let result_json = safe(r#"{"ok":false,"error":"panic"}"#.to_string(), AssertUnwindSafe(|| {
install_logging_once();
let ip = jstring_to_string(&mut env, &google_ip);
let s = jstring_to_string(&mut env, &sni);
if ip.is_empty() || s.is_empty() {
return r#"{"ok":false,"error":"empty google_ip or sni"}"#.to_string();
}
let Some(rt) = one_shot_runtime() else {
return r#"{"ok":false,"error":"tokio init failed"}"#.to_string();
};
let probe = rt.block_on(crate::scan_sni::probe_one(&ip, &s));
match (probe.latency_ms, probe.error) {
(Some(ms), _) => {
tracing::info!("sni_probe: {} via {} ok in {}ms", s, ip, ms);
format!(r#"{{"ok":true,"latencyMs":{}}}"#, ms)
}
(None, Some(e)) => {
// Surface the reason in logcat too — otherwise users see a
// red dot in the UI with no path to diagnose. Common causes:
// - "dns: ..." -> system resolver can't reach DNS
// - "connect: ..." -> TCP to google_ip:443 blocked
// - "handshake: ..." -> TLS fail (cert, ALPN, etc.)
tracing::warn!("sni_probe: {} via {} FAIL: {}", s, ip, e);
let cleaned = e.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"{{"ok":false,"error":"{}"}}"#, cleaned)
}
_ => r#"{"ok":false,"error":"unknown"}"#.to_string(),
}
}));
env.new_string(result_json).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
}
+20
View File
@@ -1,7 +1,21 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const APP_NAME: &str = "mhrv-rs";
/// Global override. On Android the app sets this to its private files dir
/// before any other mhrv-rs code runs — avoids `directories` crate returning
/// a questionable path inside `/data/data/...` that the app may not own.
/// On desktop platforms nobody sets this and the normal fallback applies.
static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
/// Set the data directory. Takes effect ONLY on the first call — later
/// calls are no-ops (OnceLock semantics). Intended for Android's JNI init
/// path; don't call from desktop builds.
pub fn set_data_dir(path: PathBuf) {
let _ = DATA_DIR_OVERRIDE.set(path);
}
/// Returns the platform-appropriate user-data directory for this app, creating
/// it if necessary. Falls back to the current directory if the dir can't be
/// determined (rare).
@@ -9,7 +23,13 @@ const APP_NAME: &str = "mhrv-rs";
/// - macOS: `~/Library/Application Support/mhrv-rs`
/// - Linux: `~/.config/mhrv-rs` (or `$XDG_CONFIG_HOME/mhrv-rs`)
/// - Windows: `%APPDATA%\mhrv-rs`
/// - Android: whatever the app passed to `set_data_dir()` (typically the
/// app's private `filesDir`).
pub fn data_dir() -> PathBuf {
if let Some(p) = DATA_DIR_OVERRIDE.get() {
let _ = std::fs::create_dir_all(p);
return p.clone();
}
let dir = directories::ProjectDirs::from("", "", APP_NAME)
.map(|d| d.config_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
+3
View File
@@ -12,3 +12,6 @@ pub mod scan_ips;
pub mod scan_sni;
pub mod test_cmd;
pub mod update_check;
#[cfg(target_os = "android")]
pub mod android_jni;
+97 -11
View File
@@ -8,7 +8,8 @@ use tokio_rustls::rustls::client::danger::{
};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use tokio_rustls::{TlsAcceptor, TlsConnector};
use tokio_rustls::rustls::server::Acceptor;
use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector};
use crate::config::Config;
use crate::domain_fronter::DomainFronter;
@@ -725,42 +726,85 @@ async fn run_mitm_then_relay(
mitm: Arc<Mutex<MitmCertManager>>,
fronter: &DomainFronter,
) {
tracing::info!("MITM TLS -> {}:{}", host, port);
// Peek the TLS ClientHello BEFORE minting the MITM cert. When the client
// resolves the hostname itself (DoH in Chrome/Firefox) and hands us a raw
// IP via SOCKS5, the only place the real hostname lives is the SNI. If we
// mint a cert for the IP, Chrome rejects with ERR_CERT_COMMON_NAME_INVALID
// — the IP isn't in the cert's SAN. Reading SNI up front and using it as
// both the cert subject and the upstream Host for the Apps Script relay
// is what unblocks Cloudflare-fronted sites and any browser on Android
// where DoH is the default.
let start = match LazyConfigAcceptor::new(Acceptor::default(), sock).await {
Ok(s) => s,
Err(e) => {
tracing::debug!("TLS ClientHello peek failed for {}: {}", host, e);
return;
}
};
let sni_hostname = start.client_hello().server_name().map(String::from);
// Effective host: SNI when present and looks like a hostname (anything
// other than a bare IPv4 literal — IP SNIs exist for weird setups but
// minting a cert for them still triggers ERR_CERT_COMMON_NAME_INVALID,
// so we fall through to the raw host in that case).
let effective_host: String = match sni_hostname.as_deref() {
Some(s) if !looks_like_ip(s) && !s.is_empty() => s.to_string(),
_ => host.to_string(),
};
tracing::info!(
"MITM TLS -> {}:{} (socks_host={}, sni={})",
effective_host,
port,
host,
sni_hostname.as_deref().unwrap_or("<none>"),
);
let server_config = {
let mut m = mitm.lock().await;
match m.get_server_config(host) {
match m.get_server_config(&effective_host) {
Ok(c) => c,
Err(e) => {
tracing::error!("cert gen failed for {}: {}", host, e);
tracing::error!("cert gen failed for {}: {}", effective_host, e);
return;
}
}
};
let acceptor = TlsAcceptor::from(server_config);
let mut tls = match acceptor.accept(sock).await {
let mut tls = match start.into_stream(server_config).await {
Ok(t) => t,
Err(e) => {
tracing::debug!("TLS accept failed for {}: {}", host, e);
tracing::debug!("TLS accept failed for {}: {}", effective_host, e);
return;
}
};
// Keep-alive loop: read HTTP requests from the decrypted stream.
// scheme=https because we MITM-terminated TLS.
// Keep-alive loop: read HTTP requests from the decrypted stream. Pass the
// SNI-derived hostname so the Apps Script relay fetches
// `https://<real hostname>/path` instead of `https://<raw IP>/path` — the
// latter would produce an IP-in-Host request that Cloudflare/etc. reject
// outright.
loop {
match handle_mitm_request(&mut tls, host, port, fronter, "https").await {
match handle_mitm_request(&mut tls, &effective_host, port, fronter, "https").await {
Ok(true) => continue,
Ok(false) => break,
Err(e) => {
tracing::debug!("MITM handler error for {}: {}", host, e);
tracing::debug!("MITM handler error for {}: {}", effective_host, e);
break;
}
}
}
}
/// True if `s` parses as an IPv4 or IPv6 literal. Used to decide whether
/// a string is a hostname we should mint a MITM leaf cert for — IP SANs
/// need their own cert extension and we don't bother emitting those,
/// so fall back to the SOCKS5-provided target in that case.
fn looks_like_ip(s: &str) -> bool {
s.parse::<std::net::IpAddr>().is_ok()
}
// ---------- Plain HTTP relay on a raw TCP stream (port 80 targets) ----------
async fn relay_http_stream_raw(
@@ -959,6 +1003,48 @@ where
format!("{}://{}:{}{}", scheme, host, port, path)
};
// Short-circuit CORS preflight at the MITM boundary.
//
// Apps Script's UrlFetchApp.fetch() only accepts methods {get, delete,
// patch, post, put} — OPTIONS triggers the Swedish-localized
// "Ett attribut med ogiltigt värde har angetts: method" error, which
// kills every XHR/fetch preflight and is the root cause of "JS doesn't
// load" on sites like Discord, Yahoo finance widgets, etc.
//
// Answering the preflight ourselves is safe: we already terminate the
// TLS for the browser (we minted the cert), so it's legitimate for us
// to own the wire-level conversation. CORS is a browser-side
// protection, not a network security one — responding 204 with
// permissive ACL headers just tells the browser the *subsequent* real
// request is allowed, and that real request still goes through the
// Apps Script relay where the origin server gets final say on content.
// The origin header is echoed (not "*") so Credentials-true responses
// stay spec-valid.
if method.eq_ignore_ascii_case("OPTIONS") {
tracing::info!("preflight 204 {} (short-circuit, no relay)", url);
let origin = header_value(&headers, "origin").unwrap_or("*");
let acrm = header_value(&headers, "access-control-request-method")
.unwrap_or("GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD");
let acrh = header_value(&headers, "access-control-request-headers").unwrap_or("*");
let resp = format!(
"HTTP/1.1 204 No Content\r\n\
Access-Control-Allow-Origin: {origin}\r\n\
Access-Control-Allow-Methods: {acrm}\r\n\
Access-Control-Allow-Headers: {acrh}\r\n\
Access-Control-Allow-Credentials: true\r\n\
Access-Control-Max-Age: 86400\r\n\
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers\r\n\
Content-Length: 0\r\n\
\r\n",
);
stream.write_all(resp.as_bytes()).await?;
stream.flush().await?;
let connection_close = headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close"));
return Ok(!connection_close);
}
tracing::info!("relay {} {}", method, url);
let response = fronter.relay(&method, &url, &headers, &body).await;