v1.0.2: stable release signature, idempotent Stop, top-level Settings for CA install (#33)

Three fixes + one behaviour change from v1.0.1 reports.

APK signature is now stable (release.jks committed)
----------------------------------------------------
v1.0.0 and v1.0.1 signed release APKs with Gradle's
auto-generated debug keystore, which is randomly generated per
machine and per CI runner. Result: every upgrade failed with
INSTALL_FAILED_UPDATE_INCOMPATIBLE and users had to uninstall
first. Unfixable without a stable key.

android/app/release.jks now holds that key, committed to the
repo with the password in plaintext in build.gradle.kts. This
is fine for a FOSS sideload project without a Play Store
identity — the trust model is "trust the source tree you
pulled from," not "trust the key we hold." Anyone forking and
shipping a rebranded build should generate their own key.

One-time cost: v1.0.1 → v1.0.2 STILL requires uninstall,
because we're switching signature keys. Every upgrade from
v1.0.2 onward is clean.

Stop no longer (sometimes) closes the app
-----------------------------------------
teardown() is reachable from three paths on two threads:
  1. ACTION_STOP onStartCommand branch  (mhrv-teardown worker)
  2. onDestroy after stopSelf            (main thread)
  3. VpnService revocation out-of-band   (main thread)
Running the full native cleanup sequence twice races the two
threads through Tun2proxy.stop() → fd.close() →
Native.stopProxy(handle) on state that's already been
nullified — SIGSEGV source, user-visible as "tap Stop, app
disappears."

New AtomicBoolean `tornDown` gates entry: first caller wins,
every subsequent caller logs "teardown: already done" and
returns. onDestroy also wraps the call in try/catch — crashing
out of onDestroy takes the whole process with it, which is
exactly the bug we're trying to fix. Smoke-tested on emulator:
teardown now logs

  teardown: begin caller=mhrv-teardown
  ... clean sequence ...
  teardown: done
  onDestroy entered
  teardown: already done, skipping (caller=main)
  onDestroy done

with PID unchanged throughout.

CA install now routes to the Settings search
--------------------------------------------
Old flow: `Settings.ACTION_SECURITY_SETTINGS` deep-link, then
walk "Encryption & credentials → Install a certificate →
CA certificate". That path varies wildly between OEMs (Samsung
buries it under "Biometrics and security → Other security
settings"; Xiaomi under "Passwords & Security → Privacy"; Pixel
splits it between "More security settings" and "Privacy
controls" depending on Android version). Users got lost.

New flow: open the top-level Settings app
(`Settings.ACTION_SETTINGS`) and instruct the user to use the
Settings search bar to find "CA certificate". Search is
consistent across OEMs and Android versions; the menu paths
are not. Dialog, snackbar, and `docs/android.md` copy all
updated to match.

Version bump: 1.0.1 → 1.0.2 (versionCode 101 → 102).
releases/mhrv-rs-android-universal-v1.0.1.apk replaced with
the v1.0.2 build.

🤖 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 04:19:52 +03:00
committed by GitHub
parent b734f41faa
commit 64409f6b41
10 changed files with 114 additions and 51 deletions
+28 -11
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34
versionCode = 101
versionName = "1.0.1"
versionCode = 102
versionName = "1.0.2"
// Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019
@@ -30,6 +30,31 @@ android {
}
}
signingConfigs {
create("release") {
// Committed keystore — fixed signature across machines and
// across CI runs. Using the auto-generated debug keystore
// (as v1.0.0 / v1.0.1 did) makes every release APK fail to
// install over the previous one with
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, because Android treats
// a signature change as "different app": the user has to
// uninstall first. That's awful UX.
//
// The password is in plaintext because this is an
// open-source project without Play Store identity. A
// forked/rebuilt APK signed with a different key is
// fundamentally a different install path anyway — the
// protection model here is "trust the source tree you
// pulled from," not "trust that we hold a key you can't
// see." If you're forking, generate your own key, commit
// it, and ship.
storeFile = file("release.jks")
storePassword = "mhrv-rs-release"
keyAlias = "mhrv-rs"
keyPassword = "mhrv-rs-release"
}
}
buildTypes {
release {
isMinifyEnabled = false
@@ -37,15 +62,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Sign release builds with the debug keystore so users can
// sideload the APK without us shipping a proper release key.
// The project has no Play Store presence, so signature
// identity per-build doesn't matter — installability does.
// Gradle auto-creates `~/.android/debug.keystore` on first use;
// CI runners inherit that behaviour. Anyone rebuilding from
// source gets their own signature, which is what we want for
// an open-source project: trust the source, not a key we hold.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
Binary file not shown.
@@ -147,19 +147,26 @@ object CaInstall {
}
/**
* 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.
* Intent that opens the TOP-LEVEL system Settings app. The Settings
* search bar is the most portable way to get users to the CA-install
* screen across OEMs — every Android vendor ships the CA install
* flow under a subtly different menu path (Encryption & credentials,
* Other security settings, Privacy → Credentials, etc.), but they
* all respond to a search for "CA certificate".
*
* 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.
* Earlier versions used `Settings.ACTION_SECURITY_SETTINGS` which
* landed on Security & privacy directly, but on some OEMs (Samsung,
* Xiaomi, newer Pixel builds) that screen doesn't have the cert
* install entry one tap away and users got stuck. Top-level Settings
* + "search for CA certificate" is the instruction that actually
* works everywhere.
*
* We DO NOT use KeyChain.createInstallIntent — on Android 11+ that
* intent opens a dialog that just says "Install CA certificates in
* Settings" with a Close button and no forward path. Google
* intentionally removed the inline install flow in that release.
*/
fun buildSettingsIntent(): Intent = Intent(Settings.ACTION_SECURITY_SETTINGS)
fun buildSettingsIntent(): Intent = Intent(Settings.ACTION_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/**
@@ -36,6 +36,18 @@ class MhrvVpnService : VpnService() {
private var tun2proxyThread: Thread? = null
private val tun2proxyRunning = AtomicBoolean(false)
// Idempotency guard. teardown() is reachable from three paths:
// 1. ACTION_STOP onStartCommand branch (background thread)
// 2. onDestroy() (main thread, fires whenever stopSelf resolves
// OR Android decides to kill the service)
// 3. Android revoking the VPN profile out-of-band (also onDestroy)
// Running the full native cleanup sequence twice races two threads
// through Tun2proxy.stop(), fd.close(), Native.stopProxy() on state
// that's already been nullified — the second pass was the
// SIGSEGV-or-zombie source. This flag makes the second call a
// no-op.
private val tornDown = 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) {
@@ -187,7 +199,21 @@ class MhrvVpnService : VpnService() {
* 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)")
// Idempotency guard. Without this, onDestroy racing the
// ACTION_STOP background thread has been observed to crash the
// process — two threads into Tun2proxy.stop() and
// Native.stopProxy(handle) where handle has already been zeroed
// is a SIGSEGV waiting to happen. First caller wins, subsequent
// callers return immediately.
if (!tornDown.compareAndSet(false, true)) {
Log.i(TAG, "teardown: already done, skipping (caller=${Thread.currentThread().name})")
return
}
Log.i(
TAG,
"teardown: begin caller=${Thread.currentThread().name} " +
"(tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)",
)
// 1. Cooperative stop signal.
if (tun2proxyRunning.get()) {
@@ -200,7 +226,9 @@ class MhrvVpnService : VpnService() {
// 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) {}
try { tun?.close() } catch (t: Throwable) {
Log.w(TAG, "tun.close: ${t.message}")
}
tun = null
// 3. Join the worker. 4s is enough in the happy case; if tun2proxy
@@ -219,19 +247,31 @@ class MhrvVpnService : VpnService() {
// 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) {
val handle = proxyHandle
proxyHandle = 0L
if (handle != 0L) {
Log.i(TAG, "teardown: stopping proxy handle=$handle")
try { Native.stopProxy(handle) } catch (t: Throwable) {
Log.e(TAG, "Native.stopProxy threw: ${t.message}", t)
}
proxyHandle = 0L
}
Log.i(TAG, "teardown: done")
}
override fun onDestroy() {
teardown()
Log.i(TAG, "onDestroy entered")
try {
teardown()
} catch (t: Throwable) {
// Belt-and-suspenders. Crashing out of onDestroy takes the
// whole process with it — user-visible as the app closing
// right when they tap Stop, which is exactly the symptom we
// are trying to fix. Anything that gets here is logged and
// swallowed.
Log.e(TAG, "onDestroy teardown threw: ${t.message}", t)
}
super.onDestroy()
Log.i(TAG, "onDestroy done")
}
private fun buildNotif(proxyPort: Int): Notification {
@@ -115,7 +115,7 @@ fun HomeScreen(
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.")
append("In Settings, search for \"CA certificate\" and install from there — NOT \"VPN & app user certificate\" or \"Wi-Fi\".")
} else {
append(" Tap Install again to retry.")
}
@@ -374,11 +374,13 @@ fun HomeScreen(
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."
"(2) open the Settings app.\n\n" +
"Inside Settings, tap the search bar and type \"CA certificate\". " +
"Open the result labelled \"CA certificate\" (NOT \"VPN & app user " +
"certificate\" or \"Wi-Fi certificate\"). Pick mhrv-ca.crt from " +
"Downloads when prompted. If you don't have a screen lock, Android " +
"will ask you to add one first — that's an OS requirement for " +
"installing any user CA."
)
if (fp != null) {
Text("Subject: ${cn ?: "(unknown)"}", style = MaterialTheme.typography.labelMedium)
@@ -911,10 +913,10 @@ private fun HowToUseCard(listenPort: Int) {
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" +
"Downloads/mhrv-ca.crt and the Settings app opens. Use Settings' search bar " +
"to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" " +
"or \"Wi-Fi\"), and 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" +