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
@@ -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 {