v1.0.1: auto-resolve google_ip, robust Stop, Check-for-updates, front_domain repair (#31)

Three reported issues from v1.0.0 — one real bug, two UX gaps.

google_ip auto-resolve (THE FIX)
--------------------------------
Google rotates the A record for www.google.com across their anycast
pool. A hardcoded default IP breaks new installs on any network that
isn't geo-homed to the same edge — symptom is "all SNIs time out"
even with a fresh deployment. On Start and via a new "Auto-detect"
button, we now do a JVM-side InetAddress lookup BEFORE establishing
the VPN (so the resolver uses the underlying network, not our own
Virtual-DNS TUN — avoids a loop), update the config, and continue.

The auto-resolve lives in the HomeScreen click handler (not
MainActivity) so it goes through the same `persist(cfg)` the text
fields use. Previous iteration did `ConfigStore.load → modify → save`
directly to disk, which left Compose's in-memory cfg stale and a
subsequent field edit would overwrite the fresh IP. One source of
truth now.

Also defensively repairs front_domain: if it's been corrupted into
an IP literal (bad paste, whatever) we restore "www.google.com" —
the TLS SNI on the outbound leg has to be a hostname or the
handshake lands on the wrong vhost.

Robust Stop
-----------
The Stop button now dispatches both ACTION_STOP (graceful: runs
teardown, stops tun2proxy, closes TUN fd, shuts down Rust runtime)
AND stopService() (defensive: covers force-closed-then-reopened
zombie state where Android auto-restarted our START_STICKY service
in a fresh process and the in-memory TUN reference is gone).

Check-for-updates
-----------------
Tapping the version badge in the top bar now runs the same
update_check that the desktop UI uses, via a new
`Native.checkUpdate()` JNI entry point. Returns a JSON blob the
Kotlin side parses into an "Up to date", "Update available: v→v
<url>", "Offline: ...", or "Check failed: ..." snackbar. Mirrors
the desktop's behavior so a user doesn't have to manually poll
GitHub for new builds.

Crash visibility
----------------
New MhrvApp.kt registers a process-wide uncaught exception handler.
Crashes are now stamped into logcat under the `mhrv-crash` tag with
the thread name before the default handler kills the process —
previously the JVM crash in coroutines / the log drain / the
tun2proxy worker was invisible unless you caught the dropoff in
real time.

Version bump: 1.0.0 → 1.0.1 (versionCode 100 → 101). Release APK
rebuilt and replaces the 1.0.0 copy in releases/; CI will regenerate
on the v1.0.1 tag push.

🤖 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 03:45:08 +03:00
committed by GitHub
parent 91015b0594
commit b734f41faa
12 changed files with 350 additions and 17 deletions
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34
versionCode = 100
versionName = "1.0.0"
versionCode = 101
versionName = "1.0.1"
// Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019
+1
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MhrvApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
@@ -91,6 +91,14 @@ class MainActivity : ComponentActivity() {
}
HomeScreen(
// MainActivity's onStart is intentionally dumb: it only
// launches the VpnService. The auto-resolve that used to
// live here ran load-modify-save directly on disk, which
// left HomeScreen's in-memory Compose `cfg` stale — a
// subsequent UI edit would then persist the stale cfg back
// over the fresh IP we just wrote. HomeScreen now owns the
// auto-resolve (it uses the same persist() flow the UI uses
// for text-field edits, so there's one source of truth).
onStart = {
val prepareIntent = VpnService.prepare(this)
if (prepareIntent == null) {
@@ -100,9 +108,30 @@ class MainActivity : ComponentActivity() {
}
},
onStop = {
val i = Intent(this, MhrvVpnService::class.java)
// Three-step teardown. Each step is defensive against a
// different failure mode we've actually hit in testing:
//
// 1. ACTION_STOP — graceful path. The service receives it,
// runs its teardown (stops tun2proxy, closes the TUN
// fd, shuts down the Rust runtime) and stopSelf()'s.
// This is what we want 99% of the time.
//
// 2. stopService() — covers the "force-closed then
// reopened" zombie case. Android may auto-restart our
// START_STICKY service in a fresh process after the
// user swipes us away from Recents, and the user's
// next Stop tap needs to actually unbind even if our
// in-memory TUN fd reference is gone. stopService is
// idempotent so it's safe to follow the graceful path.
//
// 3. We do NOT touch the VpnService permission — that's
// the OS-wide VPN grant and the user approved it
// deliberately. Revoking it would force a re-prompt
// on next Start, which is worse UX.
val stopAction = Intent(this, MhrvVpnService::class.java)
.setAction(MhrvVpnService.ACTION_STOP)
startService(i)
startService(stopAction)
stopService(Intent(this, MhrvVpnService::class.java))
},
onInstallCaConfirmed = {
// The flow is (1) export cert, (2) copy it to Downloads so
@@ -0,0 +1,40 @@
package com.therealaleph.mhrv
import android.app.Application
import android.util.Log
/**
* Application-level setup. The only job here right now is to catch
* uncaught JVM exceptions and route them through logcat under the
* `mhrv-crash` tag BEFORE the process dies. Without this the crashes
* appear as opaque "App closed unexpectedly" with no line number in
* `adb logcat` — we re-raise the exception afterwards so the default
* handler still prints its stack trace and Android still shows the
* dialog, but at least the chain-of-events is searchable.
*
* Registering the handler in `Application.onCreate()` (rather than
* `Activity.onCreate()`) catches crashes on ALL process threads,
* including the tun2proxy worker and the log-drain coroutine —
* important because those don't have an activity in scope.
*/
class MhrvApp : Application() {
override fun onCreate() {
super.onCreate()
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e(
CRASH_TAG,
"uncaught on thread=${thread.name} (id=${thread.id}): ${throwable.message}",
throwable,
)
// Let the default handler still terminate the process and
// show the system "app closed" dialog — we just wanted to
// get a log line out the door first.
previous?.uncaughtException(thread, throwable)
}
}
companion object {
private const val CRASH_TAG = "mhrv-crash"
}
}
@@ -65,4 +65,17 @@ object Native {
* BLOCKS (does a TLS handshake); call from a background dispatcher.
*/
external fun testSni(googleIp: String, sni: String): String
/**
* Ask GitHub's Releases API whether a newer version of mhrv-rs is
* out. Returns a JSON blob, one of:
* - `{"kind":"upToDate","current":"1.0.0","latest":"1.0.0"}`
* - `{"kind":"updateAvailable","current":"1.0.0","latest":"1.1.0","url":"https://..."}`
* - `{"kind":"offline","reason":"..."}`
* - `{"kind":"error","reason":"..."}`
*
* BLOCKS (HTTPS round-trip); call from a background dispatcher.
* Same check the desktop UI runs — same result format.
*/
external fun checkUpdate(): String
}
@@ -0,0 +1,44 @@
package com.therealaleph.mhrv
import java.net.Inet4Address
import java.net.InetAddress
/**
* Helpers for figuring out which IP to actually connect to when the user
* left the config on a stale `google_ip`.
*
* Google rotates the A record for `www.google.com` across their global
* anycast pool; an IP that answered a year ago often 100% packet-drops
* today from networks that are geo-homed somewhere else. Hardcoding any
* single value in the config breaks new installs on all but one region.
*
* At Start time we ask Android's resolver for the current A record and
* use that, falling back to whatever the user had configured only if the
* resolver itself fails (no connectivity, DNS blocked, etc.). We
* deliberately do this on the Kotlin side rather than inside the proxy:
* - It happens before we open the VPN TUN — so the resolver uses the
* underlying network, not our own VPN's Virtual DNS (which would
* loop).
* - The resolved IP gets persisted into `config.json`, so the next
* launch has a warm value even before auto-detection re-runs.
*/
object NetworkDetect {
/**
* Resolve `www.google.com` and return the first IPv4 A record as a
* dotted-quad string, or null if resolution failed. IPv6 is skipped —
* the outbound leg of our proxy is IPv4-only for now.
*
* BLOCKING — call from a background coroutine (Dispatchers.IO).
*/
fun resolveGoogleIp(hostname: String = "www.google.com"): String? {
return try {
InetAddress.getAllByName(hostname)
.filterIsInstance<Inet4Address>()
.firstOrNull()
?.hostAddress
} catch (_: Throwable) {
null
}
}
}
@@ -34,6 +34,7 @@ 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.NetworkDetect
import com.therealaleph.mhrv.ui.theme.OkGreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -130,11 +131,32 @@ fun HomeScreen(
TopAppBar(
title = { Text("mhrv-rs") },
actions = {
Text(
text = "v" + runCatching { Native.version() }.getOrDefault("?"),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(end = 12.dp),
)
// Tap the version label to check for updates. Keeps
// the top bar visually quiet (no explicit menu) but
// is discoverable because the cursor-style ripple
// makes it obvious it's interactive.
var checking by remember { mutableStateOf(false) }
TextButton(
onClick = {
if (checking) return@TextButton
checking = true
scope.launch {
val json = withContext(Dispatchers.IO) {
runCatching { Native.checkUpdate() }.getOrNull()
}
val msg = summarizeUpdateCheck(json)
snackbar.showSnackbar(msg, withDismissAction = true)
checking = false
}
},
modifier = Modifier.padding(end = 4.dp),
) {
Text(
text = if (checking) "checking…"
else "v" + runCatching { Native.version() }.getOrDefault("?"),
style = MaterialTheme.typography.labelMedium,
)
}
},
)
},
@@ -191,6 +213,43 @@ fun HomeScreen(
modifier = Modifier.weight(1f),
)
}
// "Auto-detect" forces a fresh DNS resolution now. Start also
// auto-resolves transparently, but exposing a button makes the
// "I'm getting connect timeouts, is my google_ip stale?" case
// a one-tap fix without needing to look up nslookup output.
TextButton(
onClick = {
scope.launch {
val fresh = withContext(Dispatchers.IO) {
NetworkDetect.resolveGoogleIp()
}
if (!fresh.isNullOrBlank()) {
var updated = cfg
if (fresh != updated.googleIp) {
updated = updated.copy(googleIp = fresh)
}
// Same repair logic as the Start button —
// if front_domain has been corrupted into an
// IP we can't use it for SNI, so put the
// default hostname back.
if (updated.frontDomain.isBlank() ||
updated.frontDomain.parseAsIpOrNull() != null
) {
updated = updated.copy(frontDomain = "www.google.com")
}
if (updated !== cfg) {
persist(updated)
snackbar.showSnackbar("google_ip updated to $fresh")
} else {
snackbar.showSnackbar("google_ip already current ($fresh)")
}
} else {
snackbar.showSnackbar("DNS lookup failed — check network")
}
}
},
modifier = Modifier.align(Alignment.End),
) { Text("Auto-detect google_ip") }
// SNI pool: collapsed by default. Users without a reason to
// touch it should leave Rust's auto-expansion to handle it.
@@ -217,8 +276,42 @@ fun HomeScreen(
) {
Button(
onClick = {
// Start flow: (1) auto-resolve google_ip so we
// don't hand the proxy a stale anycast target,
// (2) repair front_domain if it got corrupted into
// an IP (has to be a hostname — that's what goes
// into the TLS SNI on the outbound leg),
// (3) fire the VpnService. All three steps live
// here (rather than in MainActivity) so they go
// through the same persist() used for text edits
// — otherwise the Compose cfg would go stale and
// a subsequent field edit would overwrite our
// fresh values with the pre-resolve ones.
transitionCooldown = true
onStart()
scope.launch {
val fresh = withContext(Dispatchers.IO) {
NetworkDetect.resolveGoogleIp()
}
var updated = cfg
if (!fresh.isNullOrBlank() && fresh != updated.googleIp) {
updated = updated.copy(googleIp = fresh)
}
// Defensive front_domain repair. An IP literal
// here breaks the outbound leg: TLS SNI
// must be a hostname, and the Apps Script
// dispatcher uses front_domain as the SNI
// when rewriting www.google.com-bound TCP
// flows. If the field got corrupted (bad
// paste, previous bug, etc.) reset to the
// safe default.
if (updated.frontDomain.isBlank() ||
updated.frontDomain.parseAsIpOrNull() != null
) {
updated = updated.copy(frontDomain = "www.google.com")
}
if (updated !== cfg) persist(updated)
onStart()
}
},
enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
modifier = Modifier.weight(1f),
@@ -536,6 +629,57 @@ private fun ProbeBadge(state: ProbeState) {
}
}
/**
* Turn the JSON blob from `Native.checkUpdate()` into a one-line
* snackbar message. Parsing is lenient — if the shape is anything other
* than what we expect we fall back to "check failed" rather than
* spewing the raw JSON at the user.
*/
private fun summarizeUpdateCheck(json: String?): String {
if (json.isNullOrBlank()) return "Update check failed (no response)"
return try {
val obj = JSONObject(json)
when (obj.optString("kind")) {
"upToDate" -> "Up to date (running v${obj.optString("current")})"
"updateAvailable" -> {
val cur = obj.optString("current")
val latest = obj.optString("latest")
val url = obj.optString("url")
"Update available: v$cur → v$latest $url"
}
"offline" -> "Offline: ${obj.optString("reason", "no details")}"
"error" -> "Check failed: ${obj.optString("reason", "no details")}"
else -> "Check failed (unknown response)"
}
} catch (_: Throwable) {
"Check failed (bad json)"
}
}
/**
* Try to parse a string as an IPv4 or IPv6 literal. Returns null if it
* looks like a hostname (or bogus) — which is what we want for
* front_domain, where a hostname is required (goes into the TLS SNI on
* the outbound leg).
*
* Intentionally strict: must be a valid literal AND must not contain a
* letter anywhere. Plain `InetAddress.getByName(...)` would succeed for
* hostnames too (it'd do a DNS lookup and return an IP), which would
* false-positive every normal value like "www.google.com".
*/
private fun String.parseAsIpOrNull(): java.net.InetAddress? {
val s = trim()
if (s.isEmpty() || s.any { it.isLetter() }) return null
return try {
// Literal-only parse: rejects anything that would need DNS.
java.net.InetAddress.getByName(s).takeIf {
it.hostAddress?.let { addr -> addr == s || addr.contains(s) } == true
}
} catch (_: Throwable) {
null
}
}
private fun parseProbeResult(json: String?): ProbeState {
if (json.isNullOrBlank()) return ProbeState.Err("no response")
return try {