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
Generated
+1 -1
View File
@@ -1960,7 +1960,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.0.0" version = "1.0.1"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv" applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices. minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34 targetSdk = 34
versionCode = 100 versionCode = 101
versionName = "1.0.0" versionName = "1.0.1"
// Ship all four mainstream Android ABIs: // Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019 // - 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" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".MhrvApp"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false" android:fullBackupContent="false"
@@ -91,6 +91,14 @@ class MainActivity : ComponentActivity() {
} }
HomeScreen( 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 = { onStart = {
val prepareIntent = VpnService.prepare(this) val prepareIntent = VpnService.prepare(this)
if (prepareIntent == null) { if (prepareIntent == null) {
@@ -100,9 +108,30 @@ class MainActivity : ComponentActivity() {
} }
}, },
onStop = { 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) .setAction(MhrvVpnService.ACTION_STOP)
startService(i) startService(stopAction)
stopService(Intent(this, MhrvVpnService::class.java))
}, },
onInstallCaConfirmed = { onInstallCaConfirmed = {
// The flow is (1) export cert, (2) copy it to Downloads so // 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. * BLOCKS (does a TLS handshake); call from a background dispatcher.
*/ */
external fun testSni(googleIp: String, sni: String): String 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.DEFAULT_SNI_POOL
import com.therealaleph.mhrv.MhrvConfig import com.therealaleph.mhrv.MhrvConfig
import com.therealaleph.mhrv.Native import com.therealaleph.mhrv.Native
import com.therealaleph.mhrv.NetworkDetect
import com.therealaleph.mhrv.ui.theme.OkGreen import com.therealaleph.mhrv.ui.theme.OkGreen
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -130,11 +131,32 @@ fun HomeScreen(
TopAppBar( TopAppBar(
title = { Text("mhrv-rs") }, title = { Text("mhrv-rs") },
actions = { actions = {
Text( // Tap the version label to check for updates. Keeps
text = "v" + runCatching { Native.version() }.getOrDefault("?"), // the top bar visually quiet (no explicit menu) but
style = MaterialTheme.typography.labelMedium, // is discoverable because the cursor-style ripple
modifier = Modifier.padding(end = 12.dp), // 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), 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 // SNI pool: collapsed by default. Users without a reason to
// touch it should leave Rust's auto-expansion to handle it. // touch it should leave Rust's auto-expansion to handle it.
@@ -217,8 +276,42 @@ fun HomeScreen(
) { ) {
Button( Button(
onClick = { 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 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, enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
modifier = Modifier.weight(1f), 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 { private fun parseProbeResult(json: String?): ProbeState {
if (json.isNullOrBlank()) return ProbeState.Err("no response") if (json.isNullOrBlank()) return ProbeState.Err("no response")
return try { return try {
+5 -5
View File
@@ -2,11 +2,11 @@
This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page. This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page.
Current version: **v1.0.0** Current version: **v1.0.1**
| File | Platform | Contents | | File | Platform | Contents |
|---|---|---| |---|---|---|
| `mhrv-rs-android-universal-v1.0.0.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file | | `mhrv-rs-android-universal-v1.0.1.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file |
| `mhrv-rs-linux-amd64.tar.gz` | Linux x86_64 | `mhrv-rs`, `mhrv-rs-ui`, `run.sh` | | `mhrv-rs-linux-amd64.tar.gz` | Linux x86_64 | `mhrv-rs`, `mhrv-rs-ui`, `run.sh` |
| `mhrv-rs-linux-arm64.tar.gz` | Linux aarch64 | `mhrv-rs`, `run.sh` (CLI only) | | `mhrv-rs-linux-arm64.tar.gz` | Linux aarch64 | `mhrv-rs`, `run.sh` (CLI only) |
| `mhrv-rs-raspbian-armhf.tar.gz` | Raspberry Pi / ARMv7 hardfloat | `mhrv-rs`, `run.sh` (CLI only) | | `mhrv-rs-raspbian-armhf.tar.gz` | Raspberry Pi / ARMv7 hardfloat | `mhrv-rs`, `run.sh` (CLI only) |
@@ -45,7 +45,7 @@ Extract `mhrv-rs-windows-amd64.zip`, then double-click `run.bat` inside the extr
### Android ### Android
Copy `mhrv-rs-android-universal-v1.0.0.apk` to your phone, tap it from the Files app, and allow "Install unknown apps" for whichever app is opening the APK (Files, Chrome, etc.). See [the Android guide](../docs/android.md) for the full walk-through of the first-run steps (Apps Script deployment, MITM CA install, VPN permission, SNI tester). Copy `mhrv-rs-android-universal-v1.0.1.apk` to your phone, tap it from the Files app, and allow "Install unknown apps" for whichever app is opening the APK (Files, Chrome, etc.). See [the Android guide](../docs/android.md) for the full walk-through of the first-run steps (Apps Script deployment, MITM CA install, VPN permission, SNI tester).
See the [main README](../README.md) for desktop setup (Apps Script deployment, config, browser proxy settings). See the [main README](../README.md) for desktop setup (Apps Script deployment, config, browser proxy settings).
@@ -55,7 +55,7 @@ See the [main README](../README.md) for desktop setup (Apps Script deployment, c
این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند. این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند.
نسخهٔ فعلی: **v1.0.0** نسخهٔ فعلی: **v1.0.1**
### دانلود از طریق ZIP ### دانلود از طریق ZIP
@@ -73,6 +73,6 @@ cd mhrv-rs-macos-arm64
**ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود). **ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود).
**اندروید:** فایل `mhrv-rs-android-universal-v1.0.0.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست. **اندروید:** فایل `mhrv-rs-android-universal-v1.0.1.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست.
برای راه‌اندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید. برای راه‌اندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید.
+62
View File
@@ -342,6 +342,68 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>(
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
} }
/// `Native.checkUpdate()` -> String. Runs the same `update_check::check`
/// the desktop UI uses, serializes the outcome as JSON so Kotlin can
/// pattern-match without needing its own GitHub client.
///
/// Returned shape, 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":"..."}
///
/// Blocking — hit from a background dispatcher.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>(
env: JNIEnv<'a>,
_class: JClass,
) -> jstring {
let result_json = safe(
r#"{"kind":"error","reason":"panic"}"#.to_string(),
AssertUnwindSafe(|| {
install_logging_once();
let Some(rt) = one_shot_runtime() else {
return r#"{"kind":"error","reason":"tokio init failed"}"#.to_string();
};
let outcome = rt.block_on(crate::update_check::check(
crate::update_check::Route::Direct,
));
update_check_to_json(&outcome)
}),
);
env.new_string(result_json)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
fn update_check_to_json(u: &crate::update_check::UpdateCheck) -> String {
// Hand-serialized to keep the JNI side free of serde derive noise on
// the inner enum (which would need `#[derive(Serialize)]`). Short
// enough that the hand-rolled version is simpler than pulling
// serde_json in here for one call.
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
match u {
crate::update_check::UpdateCheck::UpToDate { current, latest } => format!(
r#"{{"kind":"upToDate","current":"{}","latest":"{}"}}"#,
esc(current), esc(latest),
),
crate::update_check::UpdateCheck::UpdateAvailable { current, latest, release_url, .. } => format!(
r#"{{"kind":"updateAvailable","current":"{}","latest":"{}","url":"{}"}}"#,
esc(current), esc(latest), esc(release_url),
),
crate::update_check::UpdateCheck::Offline(reason) => format!(
r#"{{"kind":"offline","reason":"{}"}}"#,
esc(reason),
),
crate::update_check::UpdateCheck::Error(reason) => format!(
r#"{{"kind":"error","reason":"{}"}}"#,
esc(reason),
),
}
}
/// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob /// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob
/// like `{"ok":true,"latencyMs":123}` or `{"ok":false,"error":"..."}`. /// like `{"ok":true,"latencyMs":123}` or `{"ok":false,"error":"..."}`.
/// Blocking call — Kotlin side should invoke on a background coroutine. /// Blocking call — Kotlin side should invoke on a background coroutine.