+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 00000000..b7c76730
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..f0c6ad08
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 00000000..c61ea334
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 00000000..848eaa5e
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..e7562257
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..f648cd12
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 00000000..16660f1d
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 535f2a1f..00f6e5cf 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -4,6 +4,8 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
+ id("com.google.devtools.ksp")
+ id("com.google.dagger.hilt.android")
}
android {
@@ -129,6 +131,10 @@ dependencies {
// from MhrvApp.onCreate without touching every composable.
implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.dagger:hilt-android:2.57.1")
+ implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
+ ksp("com.google.dagger:hilt-android-compiler:2.57.1")
+
// Compose UI.
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index f60d868a..0f2c66f0 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -2,4 +2,4 @@
# 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 { *; }
+-keep class com.therealaleph.mhrv.data.MhrvVpnService { *; }
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index cce8e611..01a3f50e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -46,7 +46,7 @@
tools:targetApi="34">
@@ -88,7 +88,7 @@
and route through us.
-->
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt
index 1e721ec4..f7467a5e 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt
@@ -4,6 +4,9 @@ import android.app.Application
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
+import com.therealaleph.mhrv.data.ConfigStore
+import com.therealaleph.mhrv.data.UiLang
+import dagger.hilt.android.HiltAndroidApp
/**
* Application-level setup. The only job here right now is to catch
@@ -19,6 +22,7 @@ import androidx.core.os.LocaleListCompat
* including the tun2proxy worker and the log-drain coroutine —
* important because those don't have an activity in scope.
*/
+@HiltAndroidApp
class MhrvApp : Application() {
override fun onCreate() {
super.onCreate()
@@ -53,7 +57,8 @@ class MhrvApp : Application() {
"uncaught on thread=${thread.name} (id=${thread.id}): ${throwable.message}",
throwable,
)
- } catch (_: Throwable) { }
+ } catch (_: 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.
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt
index 40482af9..cfb11143 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt
@@ -126,4 +126,4 @@ object Native {
* @return 0 on normal shutdown, negative on error. BLOCKS.
*/
external fun runTun2proxy(cliArgs: String, tunMtu: Int): Int
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/data/CaCertInfo.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/CaCertInfo.kt
new file mode 100644
index 00000000..0ea84585
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/CaCertInfo.kt
@@ -0,0 +1,7 @@
+package com.therealaleph.mhrv.data
+
+data class CaCertInfo(
+ val fingerprint: ByteArray,
+ val fingerprintHex: String,
+ val subjectCn: String?
+)
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/CaInstall.kt
similarity index 98%
rename from android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/data/CaInstall.kt
index 6daa4018..886737d2 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/CaInstall.kt
@@ -1,4 +1,4 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.data
import android.content.ContentValues
import android.content.Context
@@ -9,10 +9,12 @@ import android.provider.MediaStore
import android.provider.Settings
import android.security.KeyChain
import android.util.Base64
+import com.therealaleph.mhrv.Native
import java.io.File
import java.security.KeyStore
import java.security.MessageDigest
import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
/**
* Helpers for the MITM-CA install UX.
@@ -195,7 +197,7 @@ object CaInstall {
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 cert = cf.generateCertificate(der.inputStream()) as X509Certificate
val dn = cert.subjectX500Principal.name // RFC 2253, CN=foo,O=bar
Regex("""CN=([^,]+)""").find(dn)?.groupValues?.get(1)
} catch (_: Throwable) { null }
@@ -230,4 +232,4 @@ object CaInstall {
if (body.isEmpty()) return null
return try { Base64.decode(body, Base64.DEFAULT) } catch (_: Throwable) { null }
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/data/CaRepository.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/CaRepository.kt
new file mode 100644
index 00000000..8142d0ba
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/CaRepository.kt
@@ -0,0 +1,7 @@
+package com.therealaleph.mhrv.data
+
+interface CaRepository {
+ suspend fun prepareCaDialogData(): CaCertInfo?
+ suspend fun saveToDownloads(): String?
+ suspend fun checkInstalled(fingerprint: ByteArray): Boolean
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/data/CaRepositoryImpl.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/CaRepositoryImpl.kt
new file mode 100644
index 00000000..ec7b2e23
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/CaRepositoryImpl.kt
@@ -0,0 +1,36 @@
+package com.therealaleph.mhrv.data
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class CaRepositoryImpl @Inject constructor(@ApplicationContext private val ctx: Context) :
+ CaRepository {
+
+ override suspend fun prepareCaDialogData(): CaCertInfo? {
+ return withContext(Dispatchers.IO) {
+ val exported = CaInstall.export(ctx)
+ if (!exported) return@withContext null
+ val fingerprint = CaInstall.fingerprint(ctx) ?: return@withContext null
+ val hex = CaInstall.fingerprintHex(fingerprint)
+ val subjectCn = CaInstall.subjectCn(ctx)
+ return@withContext CaCertInfo(
+ fingerprint, hex, subjectCn
+ )
+ }
+ }
+
+ override suspend fun saveToDownloads(): String? {
+ return withContext(Dispatchers.IO) {
+ return@withContext CaInstall.saveToDownloads(ctx)
+ }
+ }
+
+ override suspend fun checkInstalled(fingerprint: ByteArray): Boolean {
+ return withContext(Dispatchers.IO) {
+ return@withContext CaInstall.isInstalled(fingerprint)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepository.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepository.kt
new file mode 100644
index 00000000..9eda383b
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepository.kt
@@ -0,0 +1,12 @@
+package com.therealaleph.mhrv.data
+
+import android.content.Context
+import kotlinx.coroutines.flow.StateFlow
+
+interface ConfigRepository {
+ val config: StateFlow
+ suspend fun saveConfig(cfg: MhrvConfig)
+ suspend fun loadConfig(): MhrvConfig
+ fun encodeConfig(cfg: MhrvConfig): String
+ fun decodeConfig(string: String): MhrvConfig?
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepositoryImpl.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepositoryImpl.kt
new file mode 100644
index 00000000..ae14c87c
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepositoryImpl.kt
@@ -0,0 +1,34 @@
+package com.therealaleph.mhrv.data
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+class ConfigRepositoryImpl @Inject constructor(@ApplicationContext private val ctx: Context) :
+ ConfigRepository {
+
+ private val _config = MutableStateFlow(MhrvConfig())
+
+ override val config: StateFlow = _config
+
+ override suspend fun saveConfig(cfg: MhrvConfig) {
+ _config.value = cfg
+ ConfigStore.save(ctx, cfg)
+ }
+
+ override suspend fun loadConfig(): MhrvConfig {
+ _config.value = ConfigStore.load(ctx)
+ return _config.value
+ }
+
+ override fun encodeConfig(cfg: MhrvConfig): String {
+ return ConfigStore.encode(cfg)
+ }
+
+ override fun decodeConfig(string: String): MhrvConfig? {
+ return ConfigStore.decode(string)
+ }
+
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigStore.kt
similarity index 97%
rename from android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/data/ConfigStore.kt
index d00d59e4..b6eadb0c 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/ConfigStore.kt
@@ -1,9 +1,13 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.data
import android.content.Context
+import android.util.Base64
import org.json.JSONArray
import org.json.JSONObject
+import java.io.ByteArrayOutputStream
import java.io.File
+import java.util.zip.DeflaterOutputStream
+import java.util.zip.InflaterInputStream
/**
* Config I/O. The source of truth is a JSON file in the app's files dir —
@@ -363,13 +367,13 @@ object ConfigStore {
// Compress with DEFLATE then base64.
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
- val compressed = java.io.ByteArrayOutputStream().also { bos ->
- java.util.zip.DeflaterOutputStream(bos).use { it.write(jsonBytes) }
+ val compressed = ByteArrayOutputStream().also { bos ->
+ DeflaterOutputStream(bos).use { it.write(jsonBytes) }
}.toByteArray()
- val b64 = android.util.Base64.encodeToString(
+ val b64 = Base64.encodeToString(
compressed,
- android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
+ Base64.NO_WRAP or Base64.URL_SAFE,
)
return "$HASH_PREFIX$b64"
}
@@ -378,7 +382,7 @@ object ConfigStore {
* (for backward compat with uncompressed exports). */
private fun inflateOrRaw(raw: ByteArray): String {
return try {
- java.util.zip.InflaterInputStream(raw.inputStream()).bufferedReader().readText()
+ InflaterInputStream(raw.inputStream()).bufferedReader().readText()
} catch (_: Throwable) {
String(raw, Charsets.UTF_8)
}
@@ -398,7 +402,7 @@ object ConfigStore {
// Try mhrv:// base64 encoded (possibly DEFLATE-compressed).
val payload = if (trimmed.startsWith(HASH_PREFIX)) trimmed.removePrefix(HASH_PREFIX) else trimmed
return try {
- val raw = android.util.Base64.decode(payload, android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE)
+ val raw = Base64.decode(payload, Base64.NO_WRAP or Base64.URL_SAFE)
val text = inflateOrRaw(raw)
val obj = JSONObject(text)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) return null
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/MhrvVpnService.kt
similarity index 91%
rename from android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/data/MhrvVpnService.kt
index 59aebb79..ecb611f1 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/MhrvVpnService.kt
@@ -1,5 +1,6 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.data
+import android.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -8,9 +9,12 @@ import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
+import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import com.github.shadowsocks.bg.Tun2proxy
+import com.therealaleph.mhrv.Native
+import com.therealaleph.mhrv.ui.MainActivity
import java.util.concurrent.atomic.AtomicBoolean
/**
@@ -59,7 +63,9 @@ class MhrvVpnService : VpnService() {
// (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) {
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (t: Throwable) {
Log.w(TAG, "stopForeground: ${t.message}")
}
// Teardown can block on native shutdown (rt.shutdown_timeout
@@ -72,6 +78,7 @@ class MhrvVpnService : VpnService() {
}, "mhrv-teardown").start()
START_NOT_STICKY
}
+
else -> {
startEverything()
START_STICKY
@@ -111,7 +118,10 @@ class MhrvVpnService : VpnService() {
val needsCreds = cfg.mode != Mode.DIRECT
if (needsCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) {
Log.e(TAG, "Config is incomplete — deployment ID + auth key required for ${cfg.mode}")
- try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {}
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (_: Throwable) {
+ }
stopSelf()
return
}
@@ -124,14 +134,20 @@ class MhrvVpnService : VpnService() {
// 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) {}
+ 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")
- try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {}
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (_: Throwable) {
+ }
stopSelf()
return
}
@@ -196,12 +212,17 @@ class MhrvVpnService : VpnService() {
Log.w(TAG, "addDisallowedApplication(self) failed: ${e.message}")
}
}
+
SplitMode.ONLY -> {
if (cfg.splitApps.isEmpty()) {
- Log.w(TAG, "ONLY mode with empty splitApps list — no app would get the VPN; falling back to ALL")
+ Log.w(
+ TAG,
+ "ONLY mode with empty splitApps list — no app would get the VPN; falling back to ALL"
+ )
try {
builder.addDisallowedApplication(packageName)
- } catch (_: Throwable) {}
+ } catch (_: Throwable) {
+ }
} else {
var allowed = 0
for (pkg in cfg.splitApps) {
@@ -217,10 +238,12 @@ class MhrvVpnService : VpnService() {
Log.w(TAG, "ONLY mode had no usable apps — falling back to ALL")
try {
builder.addDisallowedApplication(packageName)
- } catch (_: Throwable) {}
+ } catch (_: Throwable) {
+ }
}
}
}
+
SplitMode.EXCEPT -> {
try {
builder.addDisallowedApplication(packageName)
@@ -229,7 +252,9 @@ class MhrvVpnService : VpnService() {
}
for (pkg in cfg.splitApps) {
if (pkg == packageName) continue // already self-excluded above
- try { builder.addDisallowedApplication(pkg) } catch (e: Throwable) {
+ try {
+ builder.addDisallowedApplication(pkg)
+ } catch (e: Throwable) {
Log.w(TAG, "addDisallowedApplication($pkg) failed: ${e.message}")
}
}
@@ -247,7 +272,10 @@ class MhrvVpnService : VpnService() {
Log.e(TAG, "establish() returned null — is VPN permission granted?")
Native.stopProxy(proxyHandle)
proxyHandle = 0L
- try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {}
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (_: Throwable) {
+ }
stopSelf()
return
}
@@ -300,7 +328,10 @@ class MhrvVpnService : VpnService() {
}
Native.stopProxy(proxyHandle)
proxyHandle = 0L
- try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {}
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (_: Throwable) {
+ }
stopSelf()
return
}
@@ -321,7 +352,7 @@ class MhrvVpnService : VpnService() {
private fun showDebugOverlay() {
if (debugOverlay != null) return
- if (!android.provider.Settings.canDrawOverlays(this)) {
+ if (!Settings.canDrawOverlays(this)) {
Log.w(TAG, "overlay permission not granted — skipping debug overlay")
return
}
@@ -390,7 +421,7 @@ class MhrvVpnService : VpnService() {
Log.i(
TAG,
"teardown: begin caller=${Thread.currentThread().name} " +
- "(tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)",
+ "(tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)",
)
// 1. Stop the Rust proxy FIRST. Closing the SOCKS5 listener is
@@ -401,7 +432,9 @@ class MhrvVpnService : VpnService() {
proxyHandle = 0L
if (handle != 0L) {
Log.i(TAG, "teardown: stopping proxy handle=$handle")
- try { Native.stopProxy(handle) } catch (t: Throwable) {
+ try {
+ Native.stopProxy(handle)
+ } catch (t: Throwable) {
Log.e(TAG, "Native.stopProxy threw: ${t.message}", t)
}
}
@@ -413,11 +446,16 @@ class MhrvVpnService : VpnService() {
// Bounded so a hung JNI call can't stall teardown.
if (tun2proxyRunning.get()) {
val stopper = Thread({
- try { Tun2proxy.stop() } catch (t: Throwable) {
+ try {
+ Tun2proxy.stop()
+ } catch (t: Throwable) {
Log.w(TAG, "Tun2proxy.stop: ${t.message}")
}
}, "mhrv-tun2proxy-stop").apply { start() }
- try { stopper.join(2_000) } catch (_: InterruptedException) {}
+ try {
+ stopper.join(2_000)
+ } catch (_: InterruptedException) {
+ }
if (stopper.isAlive) {
Log.w(TAG, "Tun2proxy.stop did not return within 2s — proceeding")
}
@@ -429,7 +467,9 @@ class MhrvVpnService : VpnService() {
// run(). The call is kept only to null the field cleanly on
// paths that never reached detachFd (PROXY_ONLY, or an
// establish() that failed mid-builder).
- try { tun?.close() } catch (t: Throwable) {
+ try {
+ tun?.close()
+ } catch (t: Throwable) {
Log.w(TAG, "tun.close: ${t.message}")
}
tun = null
@@ -439,7 +479,8 @@ class MhrvVpnService : VpnService() {
// headroom for tun2proxy's internal close path to drain.
try {
tun2proxyThread?.join(4_000)
- } catch (_: InterruptedException) {}
+ } catch (_: InterruptedException) {
+ }
val stillAlive = tun2proxyThread?.isAlive == true
tun2proxyThread = null
if (stillAlive) {
@@ -474,7 +515,7 @@ class MhrvVpnService : VpnService() {
}
private fun buildNotif(httpPort: Int, socks5Port: Int): Notification {
- val mgr = getSystemService(NotificationManager::class.java)
+ val mgr = this.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(
CHANNEL_ID,
@@ -501,9 +542,9 @@ class MhrvVpnService : VpnService() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("mhrv-rs VPN is active")
.setContentText("HTTP 127.0.0.1:$httpPort · SOCKS5 127.0.0.1:$socks5Port")
- .setSmallIcon(android.R.drawable.presence_online)
+ .setSmallIcon(R.drawable.presence_online)
.setContentIntent(openIntent)
- .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
+ .addAction(R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
@@ -525,4 +566,4 @@ class MhrvVpnService : VpnService() {
// also accept the legacy 198.18.0.1:7300 for one deprecation cycle.
private const val UDPGW_MAGIC_DEST = "192.0.2.1:7300"
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/NetworkDetect.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/NetworkDetect.kt
similarity index 97%
rename from android/app/src/main/java/com/therealaleph/mhrv/NetworkDetect.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/data/NetworkDetect.kt
index 50cd59d7..1c362052 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/NetworkDetect.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/NetworkDetect.kt
@@ -1,4 +1,4 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.data
import java.net.Inet4Address
import java.net.InetAddress
@@ -41,4 +41,4 @@ object NetworkDetect {
null
}
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/PipelineDebugOverlay.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/PipelineDebugOverlay.kt
similarity index 98%
rename from android/app/src/main/java/com/therealaleph/mhrv/PipelineDebugOverlay.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/data/PipelineDebugOverlay.kt
index c53ac4ec..fde4c581 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/PipelineDebugOverlay.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/PipelineDebugOverlay.kt
@@ -1,4 +1,4 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.data
import android.content.Context
import android.graphics.Color
@@ -12,6 +12,7 @@ import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
+import com.therealaleph.mhrv.Native
import org.json.JSONObject
/**
@@ -171,4 +172,4 @@ class PipelineDebugOverlay(private val context: Context) {
}
} catch (_: Throwable) {}
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/VpnState.kt b/android/app/src/main/java/com/therealaleph/mhrv/data/VpnState.kt
similarity index 98%
rename from android/app/src/main/java/com/therealaleph/mhrv/VpnState.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/data/VpnState.kt
index e556f3b6..a6f30b4c 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/VpnState.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/data/VpnState.kt
@@ -1,4 +1,4 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -44,4 +44,4 @@ object VpnState {
fun setProxyHandle(handle: Long) {
_proxyHandle.value = handle
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/di/AppModule.kt b/android/app/src/main/java/com/therealaleph/mhrv/di/AppModule.kt
new file mode 100644
index 00000000..88a22fd6
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/di/AppModule.kt
@@ -0,0 +1,29 @@
+package com.therealaleph.mhrv.di
+
+import com.therealaleph.mhrv.data.CaRepository
+import com.therealaleph.mhrv.data.CaRepositoryImpl
+import com.therealaleph.mhrv.data.ConfigRepository
+import com.therealaleph.mhrv.data.ConfigRepositoryImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class AppModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindConfigRepository(
+ configRepositoryImpl: ConfigRepositoryImpl
+ ): ConfigRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindCaRepository(
+ caRepositoryImpl: CaRepositoryImpl
+ ): CaRepository
+
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt
index 966030ca..c36e8dc8 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt
@@ -1,17 +1,14 @@
package com.therealaleph.mhrv.ui
-import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Color
import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPaste
-import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
@@ -29,10 +26,11 @@ import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
-import com.therealaleph.mhrv.ConfigStore
-import com.therealaleph.mhrv.MhrvConfig
+import com.therealaleph.mhrv.data.ConfigStore
+import com.therealaleph.mhrv.data.MhrvConfig
import androidx.compose.foundation.text.selection.SelectionContainer
import com.therealaleph.mhrv.R
+import com.therealaleph.mhrv.data.Mode
import kotlinx.coroutines.launch
// =========================================================================
@@ -46,16 +44,16 @@ fun ConfigSharingBar(
onSnackbar: suspend (String) -> Unit,
) {
// Deep link import — requires confirmation before applying.
- val deepLinkCfg by com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig
+ val deepLinkCfg by MainActivity.pendingDeepLinkConfig
if (deepLinkCfg != null) {
ImportConfirmDialog(
cfg = deepLinkCfg!!,
onConfirm = {
onImport(deepLinkCfg!!)
- com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig.value = null
+ MainActivity.pendingDeepLinkConfig.value = null
},
onDismiss = {
- com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig.value = null
+ MainActivity.pendingDeepLinkConfig.value = null
},
)
}
@@ -263,9 +261,9 @@ private fun ImportConfirmDialog(
}
val preview = ids.take(3).joinToString("\n") { " ${it.take(20)}…" }
val modeLabel = when (cfg.mode) {
- com.therealaleph.mhrv.Mode.APPS_SCRIPT -> "apps_script"
- com.therealaleph.mhrv.Mode.DIRECT -> "direct"
- com.therealaleph.mhrv.Mode.FULL -> "full"
+ Mode.APPS_SCRIPT -> "apps_script"
+ Mode.DIRECT -> "direct"
+ Mode.FULL -> "full"
}
AlertDialog(
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt
index b06bc5cf..7a20397f 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt
@@ -1,5 +1,6 @@
package com.therealaleph.mhrv.ui
+import com.therealaleph.mhrv.R
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
@@ -17,8 +18,6 @@ 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
@@ -33,19 +32,16 @@ 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.Mode
-import com.therealaleph.mhrv.Native
-import com.therealaleph.mhrv.ConnectionMode
-import com.therealaleph.mhrv.NetworkDetect
-import com.therealaleph.mhrv.R
-import com.therealaleph.mhrv.SplitMode
-import com.therealaleph.mhrv.UiLang
-import com.therealaleph.mhrv.VpnState
import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.therealaleph.mhrv.Native
+import com.therealaleph.mhrv.data.ConnectionMode
+import com.therealaleph.mhrv.data.DEFAULT_SNI_POOL
+import com.therealaleph.mhrv.data.MhrvConfig
+import com.therealaleph.mhrv.data.Mode
+import com.therealaleph.mhrv.data.SplitMode
+import com.therealaleph.mhrv.data.UiLang
+import com.therealaleph.mhrv.data.VpnState
import com.therealaleph.mhrv.ui.theme.ErrRed
import com.therealaleph.mhrv.ui.theme.OkGreen
import kotlinx.coroutines.Dispatchers
@@ -63,6 +59,7 @@ import org.json.JSONObject
*/
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
@@ -86,10 +83,11 @@ sealed class CaInstallOutcome {
fun HomeScreen(
onStart: () -> Unit,
onStop: () -> Unit,
- onInstallCaConfirmed: () -> Unit,
+ onInstallCaConfirmed: (fingerprint: ByteArray) -> Unit,
caOutcome: CaInstallOutcome?,
onCaOutcomeConsumed: () -> Unit,
onLangChange: (UiLang) -> Unit = {},
+ viewModel: HomeViewModel = hiltViewModel()
) {
val ctx = LocalContext.current
val scope = rememberCoroutineScope()
@@ -97,10 +95,32 @@ fun HomeScreen(
// 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)) }
+ val cfg by viewModel.config.collectAsState()
fun persist(new: MhrvConfig) {
- cfg = new
- ConfigStore.save(ctx, new)
+ viewModel.saveConfig(new)
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.googleIpResult.collect { result ->
+ when (result) {
+ is GoogleIpResult.Success -> {
+ snackbar.showSnackbar(
+ ctx.getString(R.string.snack_google_ip_updated, result.googleIp),
+ )
+ }
+
+ is GoogleIpResult.Error -> {
+ snackbar.showSnackbar(ctx.getString(R.string.snack_dns_lookup_failed))
+ }
+
+ is GoogleIpResult.NoChange -> {
+ snackbar.showSnackbar(
+ ctx.getString(R.string.snack_google_ip_current, cfg.googleIp),
+ )
+ }
+
+ }
+ }
}
// CA install dialog visibility.
@@ -123,7 +143,7 @@ fun HomeScreen(
if (obj?.optString("kind") == "updateAvailable") {
snackbar.showSnackbar(
"Update available: v${obj.optString("current")} → " +
- "v${obj.optString("latest")} ${obj.optString("url")}",
+ "v${obj.optString("latest")} ${obj.optString("url")}",
withDismissAction = true,
)
}
@@ -165,6 +185,7 @@ fun HomeScreen(
val msg = when (o) {
is CaInstallOutcome.Installed ->
"Certificate installed ✓"
+
is CaInstallOutcome.NotInstalled -> buildString {
append("Certificate not yet installed.")
if (!o.downloadPath.isNullOrBlank()) {
@@ -174,6 +195,7 @@ fun HomeScreen(
append(" Tap Install again to retry.")
}
}
+
is CaInstallOutcome.Failed -> o.message
}
snackbar.showSnackbar(msg, withDismissAction = true)
@@ -232,8 +254,8 @@ fun HomeScreen(
) {
Text(
text = if (checking) stringResource(R.string.tb_check_update_checking)
- else stringResource(R.string.tb_version_prefix) +
- runCatching { Native.version() }.getOrDefault("?"),
+ else stringResource(R.string.tb_version_prefix) +
+ runCatching { Native.version() }.getOrDefault("?"),
style = MaterialTheme.typography.labelMedium,
)
}
@@ -296,28 +318,14 @@ fun HomeScreen(
// just because the two values differ. They
// can still force a re-resolve via the
// explicit "Auto-detect" button above.
- var updated = cfg
- if (updated.googleIp.isBlank()) {
- val fresh = withContext(Dispatchers.IO) {
- NetworkDetect.resolveGoogleIp()
- }
- if (!fresh.isNullOrBlank()) {
- updated = updated.copy(googleIp = fresh)
- }
- }
- if (updated.frontDomain.isBlank() ||
- updated.frontDomain.parseAsIpOrNull() != null
- ) {
- updated = updated.copy(frontDomain = "www.google.com")
- }
- if (updated !== cfg) persist(updated)
+ viewModel.prepareConfigForStart()
onStart()
}
}
},
enabled = (isVpnRunning ||
- cfg.mode == Mode.DIRECT ||
- (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
+ cfg.mode == Mode.DIRECT ||
+ (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
colors = ButtonDefaults.buttonColors(
containerColor = if (isVpnRunning) ErrRed else OkGreen,
contentColor = androidx.compose.ui.graphics.Color.White,
@@ -347,7 +355,7 @@ fun HomeScreen(
CollapsibleSection(
title = stringResource(R.string.sec_apps_script_relay),
initiallyExpanded = appsScriptEnabled &&
- (cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank()),
+ (cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank()),
) {
DeploymentIdsField(
urls = cfg.appsScriptUrls,
@@ -406,41 +414,7 @@ fun HomeScreen(
// 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")
- }
- // Captured up-front so the lambda has access
- // to the format-string resources via context
- // before running on the IO dispatcher.
- if (updated !== cfg) {
- persist(updated)
- snackbar.showSnackbar(
- ctx.getString(R.string.snack_google_ip_updated, fresh),
- )
- } else {
- snackbar.showSnackbar(
- ctx.getString(R.string.snack_google_ip_current, fresh),
- )
- }
- } else {
- snackbar.showSnackbar(ctx.getString(R.string.snack_dns_lookup_failed))
- }
- }
+ viewModel.autoDetectGoogleIp()
},
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.btn_auto_detect_google_ip)) }
@@ -493,7 +467,10 @@ fun HomeScreen(
UsageTodayCard()
PipelineDebugCard()
- CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
+ CollapsibleSection(
+ title = stringResource(R.string.sec_live_logs),
+ initiallyExpanded = false
+ ) {
LiveLogPane()
}
@@ -511,61 +488,83 @@ fun HomeScreen(
}
}
+ LaunchedEffect(showInstallDialog) {
+ if (showInstallDialog) {
+ viewModel.prepareCaDialogData()
+ }
+ }
+
// ---- 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 }
+
+ val caState by viewModel.caCertState.collectAsState()
AlertDialog(
onDismissRequest = { showInstallDialog = false },
title = { Text(stringResource(R.string.dialog_install_mitm_title)) },
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 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)
- Text(
- text = "SHA-256: ${CaInstall.fingerprintHex(fp)}",
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- )
- } else {
+ when (val state = caState) {
+ is CaCertState.Ready -> {
+ 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 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."
+ )
+ Text(
+ "Subject: ${state.info.subjectCn ?: "(unknown)"}",
+ style = MaterialTheme.typography.labelMedium
+ )
+ Text(
+ text = "SHA-256: ${state.info.fingerprintHex}",
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ }
+ CaCertState.NotFound -> {
Text(
"Could not read the CA cert yet. Tap Start once so the " +
- "proxy generates it, then come back.",
+ "proxy generates it, then come back.",
color = MaterialTheme.colorScheme.error,
)
}
+ else -> {
+
+ }
}
+
},
confirmButton = {
- TextButton(
- onClick = {
- showInstallDialog = false
- if (fp != null) onInstallCaConfirmed()
- },
- enabled = fp != null,
- ) { Text("Install") }
+ when (val state = caState) {
+ is CaCertState.Ready -> {
+ TextButton(
+ onClick = {
+ showInstallDialog = false
+ onInstallCaConfirmed(state.info.fingerprint)
+ },
+ ) { Text("Install") }
+ }
+
+ else -> {
+ TextButton(onClick = {}, enabled = false) { Text("Install") }
+ }
+ }
},
dismissButton = {
TextButton(onClick = { showInstallDialog = false }) { Text("Cancel") }
@@ -689,7 +688,9 @@ private fun ConnectionModeDropdown(
readOnly = true,
label = { Text(stringResource(R.string.field_connection_mode)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier.fillMaxWidth().menuAnchor(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
@@ -719,6 +720,7 @@ private fun ConnectionModeDropdown(
val help = when (mode) {
ConnectionMode.VPN_TUN ->
stringResource(R.string.help_mode_vpn_tun)
+
ConnectionMode.PROXY_ONLY ->
stringResource(R.string.help_mode_proxy_only, httpPort, socks5Port)
}
@@ -868,7 +870,9 @@ private fun ModeDropdown(
readOnly = true,
label = { Text("Mode") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier.fillMaxWidth().menuAnchor(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
@@ -892,8 +896,10 @@ private fun ModeDropdown(
val help = when (mode) {
Mode.APPS_SCRIPT ->
"Full DPI bypass through your deployed Apps Script relay."
+
Mode.DIRECT ->
"SNI-rewrite tunnel only — no relay. Reach *.google.com (and any configured fronting_groups) directly. Useful as a bootstrap to open script.google.com and deploy Code.gs."
+
Mode.FULL ->
"All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed."
}
@@ -971,7 +977,8 @@ private fun SniPoolEditor(
val next = if (nowEnabled) {
(cfg.sniHosts.takeIf { it.isNotEmpty() } ?: emptyList()) + sni
} else {
- val current = if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts else enabledSet.toList()
+ val current =
+ if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts else enabledSet.toList()
current.filter { it != sni }
}
onChange(cfg.copy(sniHosts = next.distinct()))
@@ -1079,6 +1086,7 @@ private fun ProbeBadge(state: ProbeState) {
strokeWidth = 2.dp,
)
}
+
is ProbeState.Ok -> {
Row(verticalAlignment = Alignment.CenterVertically) {
// Same green the desktop UI uses for OK status (OK_GREEN
@@ -1092,6 +1100,7 @@ private fun ProbeBadge(state: ProbeState) {
Text("${state.latencyMs} ms", style = MaterialTheme.typography.labelSmall)
}
}
+
is ProbeState.Err -> {
Icon(
Icons.Default.ErrorOutline, state.message,
@@ -1120,6 +1129,7 @@ private fun summarizeUpdateCheck(json: String?): String {
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)"
@@ -1184,7 +1194,10 @@ private fun AdvancedSettings(
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
- Text(stringResource(R.string.adv_verify_tls), style = MaterialTheme.typography.bodyMedium)
+ Text(
+ stringResource(R.string.adv_verify_tls),
+ style = MaterialTheme.typography.bodyMedium
+ )
Text(
stringResource(R.string.adv_verify_tls_help),
style = MaterialTheme.typography.labelSmall,
@@ -1203,7 +1216,10 @@ private fun AdvancedSettings(
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
- Text(stringResource(R.string.adv_youtube_via_relay), style = MaterialTheme.typography.bodyMedium)
+ Text(
+ stringResource(R.string.adv_youtube_via_relay),
+ style = MaterialTheme.typography.bodyMedium
+ )
Text(
stringResource(R.string.adv_youtube_via_relay_help),
style = MaterialTheme.typography.labelSmall,
@@ -1229,7 +1245,9 @@ private fun AdvancedSettings(
readOnly = true,
label = { Text(stringResource(R.string.adv_log_level)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier.fillMaxWidth().menuAnchor(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
@@ -1363,7 +1381,13 @@ private fun AdvancedSettings(
)
Slider(
value = cfg.coalesceStepMs.toFloat(),
- onValueChange = { onChange(cfg.copy(coalesceStepMs = it.toInt().coerceIn(10, 500))) },
+ onValueChange = {
+ onChange(
+ cfg.copy(
+ coalesceStepMs = it.toInt().coerceIn(10, 500)
+ )
+ )
+ },
valueRange = 10f..500f,
)
}
@@ -1376,7 +1400,13 @@ private fun AdvancedSettings(
)
Slider(
value = cfg.coalesceMaxMs.toFloat(),
- onValueChange = { onChange(cfg.copy(coalesceMaxMs = it.toInt().coerceIn(100, 2000))) },
+ onValueChange = {
+ onChange(
+ cfg.copy(
+ coalesceMaxMs = it.toInt().coerceIn(100, 2000)
+ )
+ )
+ },
valueRange = 100f..2000f,
)
}
@@ -1451,7 +1481,9 @@ private fun LiveLogPane() {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp),
- modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp, max = 320.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 160.dp, max = 320.dp),
) {
// SelectionContainer makes log lines selectable for manual
// copy of partial ranges. Cross-line selection works within the
@@ -1793,4 +1825,4 @@ private fun HowToUseBody(listenPort: Int) {
style = MaterialTheme.typography.bodyMedium,
)
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeViewModel.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeViewModel.kt
new file mode 100644
index 00000000..9710cee5
--- /dev/null
+++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeViewModel.kt
@@ -0,0 +1,153 @@
+package com.therealaleph.mhrv.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.therealaleph.mhrv.data.CaCertInfo
+import com.therealaleph.mhrv.data.CaRepository
+import com.therealaleph.mhrv.data.ConfigRepository
+import com.therealaleph.mhrv.data.MhrvConfig
+import com.therealaleph.mhrv.data.NetworkDetect
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val configRepository: ConfigRepository,
+ private val caRepository: CaRepository
+) : ViewModel() {
+
+ val config: StateFlow = configRepository.config
+
+ private val _caCertState = MutableStateFlow(CaCertState.Idle)
+ val caCertState = _caCertState.asStateFlow()
+
+ private val _googleIpResult = MutableSharedFlow()
+ val googleIpResult = _googleIpResult.asSharedFlow()
+
+ init {
+ load()
+ }
+
+ fun load() {
+ viewModelScope.launch {
+ configRepository.loadConfig()
+ }
+ }
+
+ fun saveConfig(cfg: MhrvConfig) {
+ viewModelScope.launch {
+ configRepository.saveConfig(cfg)
+ }
+ }
+
+ fun autoDetectGoogleIp() {
+
+ viewModelScope.launch {
+ var updated = config.value
+
+ val fresh = withContext(Dispatchers.IO) {
+ NetworkDetect.resolveGoogleIp()
+ }
+ if (!fresh.isNullOrBlank()) {
+ updated = updated.copy(googleIp = fresh)
+ }
+ if (updated.frontDomain.isBlank() ||
+ updated.frontDomain.parseAsIpOrNull() != null
+ ) {
+ updated = updated.copy(frontDomain = "www.google.com")
+ }
+
+ if (fresh.isNullOrBlank()) {
+ _googleIpResult.emit(GoogleIpResult.Error)
+ return@launch
+ }
+
+ if (updated != config.value) {
+ configRepository.saveConfig(updated)
+ _googleIpResult.emit(GoogleIpResult.Success(fresh))
+ } else {
+ _googleIpResult.emit(GoogleIpResult.NoChange)
+ }
+
+ }
+ }
+
+ suspend fun prepareConfigForStart() {
+ var updated = config.value
+ if (config.value.googleIp.isBlank()) {
+ val fresh = withContext(Dispatchers.IO) {
+ NetworkDetect.resolveGoogleIp()
+ }
+ if (!fresh.isNullOrBlank()) {
+ updated = updated.copy(googleIp = fresh)
+ }
+
+ }
+
+ if (updated.frontDomain.isBlank() ||
+ updated.frontDomain.parseAsIpOrNull() != null
+ ) {
+ updated = updated.copy(frontDomain = "www.google.com")
+ }
+
+ if (updated != config.value) {
+ configRepository.saveConfig(updated)
+ }
+
+ }
+
+ fun prepareCaDialogData() {
+ viewModelScope.launch {
+ _caCertState.value = CaCertState.Loading
+ val data = caRepository.prepareCaDialogData()
+ if (data != null) {
+ _caCertState.value = CaCertState.Ready(data)
+ } else {
+ _caCertState.value = CaCertState.NotFound
+ }
+ }
+ }
+
+ suspend fun saveToDownloads(): String? {
+ return caRepository.saveToDownloads()
+ }
+
+ suspend fun checkCaInstall(fingerprint: ByteArray): Boolean {
+ return caRepository.checkInstalled(fingerprint)
+ }
+
+}
+
+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
+ }
+}
+
+sealed class GoogleIpResult {
+ data class Success(val googleIp: String) : GoogleIpResult()
+ object Error : GoogleIpResult()
+ object NoChange : GoogleIpResult()
+}
+
+sealed class CaCertState {
+ data class Ready(val info: CaCertInfo) : CaCertState()
+ object Idle : CaCertState()
+ object Loading : CaCertState()
+ object NotFound : CaCertState()
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/MainActivity.kt
similarity index 77%
rename from android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt
rename to android/app/src/main/java/com/therealaleph/mhrv/ui/MainActivity.kt
index 4fb12312..2cdd0a83 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/MainActivity.kt
@@ -1,40 +1,51 @@
-package com.therealaleph.mhrv
+package com.therealaleph.mhrv.ui
import android.Manifest
import android.app.Activity
+import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
+import android.content.res.Configuration
import android.net.VpnService
import android.os.Build
import android.os.Bundle
-import android.content.Context
-import android.content.res.Configuration
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
-import androidx.appcompat.app.AppCompatActivity
-import java.util.Locale
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
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.data.CaInstall
+import com.therealaleph.mhrv.data.MhrvVpnService
+import com.therealaleph.mhrv.Native
+import com.therealaleph.mhrv.data.ConfigStore
+import com.therealaleph.mhrv.data.ConnectionMode
+import com.therealaleph.mhrv.data.MhrvConfig
+import com.therealaleph.mhrv.data.UiLang
import com.therealaleph.mhrv.ui.theme.MhrvTheme
-
-// UiLang is in the outer package namespace already.
-
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import java.util.Locale
+import kotlin.getValue
// AppCompatActivity (not plain ComponentActivity) because it's what picks
// up AppCompatDelegate.setApplicationLocales() and swaps per-activity
// Configuration + LayoutDirection on recreate(). Compose works fine on
// top — setContent / rememberLauncherForActivityResult live on
// ComponentActivity and AppCompatActivity inherits from it.
+@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
+ private val viewModel: HomeViewModel by viewModels()
+
override fun attachBaseContext(newBase: Context) {
// Force the persisted ui_lang into the Activity's Configuration
// before it's constructed. AppCompatDelegate.setApplicationLocales
@@ -119,6 +130,10 @@ class MainActivity : AppCompatActivity() {
}
}
+ val cfg by viewModel.config.collectAsState()
+
+ val scope = rememberCoroutineScope()
+
// 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
@@ -135,14 +150,19 @@ class MainActivity : AppCompatActivity() {
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)
+ scope.launch {
+ val fp = pendingFingerprint
+ caOutcome = if (fp == null) {
+ CaInstallOutcome.Failed("Internal error: no fingerprint")
+ } else {
+ when (viewModel.checkCaInstall(fp)) {
+ true -> CaInstallOutcome.Installed
+ false -> CaInstallOutcome.NotInstalled(pendingDownloadPath)
+ }
+ }
+ pendingFingerprint = null
+ pendingDownloadPath = null
}
- pendingFingerprint = null
- pendingDownloadPath = null
}
HomeScreen(
@@ -160,7 +180,6 @@ class MainActivity : AppCompatActivity() {
// VpnService.prepare — firing the consent dialog there
// would be wrong (user said "no VPN") and MhrvVpnService
// wouldn't call establish() anyway.
- val cfg = ConfigStore.load(this)
if (cfg.connectionMode == ConnectionMode.VPN_TUN) {
val prepareIntent = VpnService.prepare(this)
if (prepareIntent == null) {
@@ -200,11 +219,11 @@ class MainActivity : AppCompatActivity() {
// 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)
+ val stopAction =
+ Intent(this, MhrvVpnService::class.java).setAction(MhrvVpnService.ACTION_STOP)
startService(stopAction)
},
- onInstallCaConfirmed = {
+ onInstallCaConfirmed = { fingerprint ->
// 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
@@ -214,43 +233,38 @@ class MainActivity : AppCompatActivity() {
// 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
+ scope.launch {
+ val downloadPath = viewModel.saveToDownloads()
+ pendingFingerprint = fingerprint
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 },
- onLangChange = { lang ->
- // Re-apply the new locale to the running process. AppCompatDelegate
- // picks it up from MhrvApp.onCreate on process restart, so we
- // recreate() the activity to take effect immediately — otherwise
- // the user would have to swipe the app away and reopen it for
- // RTL/LTR to swap.
- val tag = when (lang) {
- UiLang.FA -> "fa"
- UiLang.EN -> "en"
- UiLang.AUTO -> ""
- }
- androidx.appcompat.app.AppCompatDelegate.setApplicationLocales(
- if (tag.isEmpty())
- androidx.core.os.LocaleListCompat.getEmptyLocaleList()
- else
- androidx.core.os.LocaleListCompat.forLanguageTags(tag),
- )
- // AppCompatDelegate triggers recreate internally on API 33+
- // via the per-app language OS setting, but on older API
- // levels it doesn't — call it explicitly for consistent
- // behaviour across the minSdk=24 range.
- recreate()
- },
+ onCaOutcomeConsumed =
+ { caOutcome = null },
+ onLangChange =
+ { lang ->
+ // Re-apply the new locale to the running process. AppCompatDelegate
+ // picks it up from MhrvApp.onCreate on process restart, so we
+ // recreate() the activity to take effect immediately — otherwise
+ // the user would have to swipe the app away and reopen it for
+ // RTL/LTR to swap.
+ val tag = when (lang) {
+ UiLang.FA -> "fa"
+ UiLang.EN -> "en"
+ UiLang.AUTO -> ""
+ }
+ androidx.appcompat.app.AppCompatDelegate.setApplicationLocales(
+ if (tag.isEmpty()) androidx.core.os.LocaleListCompat.getEmptyLocaleList()
+ else androidx.core.os.LocaleListCompat.forLanguageTags(tag),
+ )
+ // AppCompatDelegate triggers recreate internally on API 33+
+ // via the per-app language OS setting, but on older API
+ // levels it doesn't — call it explicitly for consistent
+ // behaviour across the minSdk=24 range.
+ recreate()
+ },
)
}
@@ -261,7 +275,8 @@ class MainActivity : AppCompatActivity() {
companion object {
private const val REQ_NOTIF = 42
+
/** Deep link config waiting for user confirmation. Read by ConfigSharingBar. */
val pendingDeepLinkConfig = mutableStateOf(null)
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6a7688e7..95289afb 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -27,6 +27,8 @@
CopyInstallCancel
+ Select all
+ Unselect allDeployment URL(s) or script ID(s)
@@ -46,6 +48,7 @@
Only selected appsAll except selectedPick apps…
+ Show system appsInstall MITM certificate?
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index c308190f..d2f8616e 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -3,4 +3,6 @@ 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
+ id("com.google.dagger.hilt.android") version "2.57.1" apply false
+ id("com.google.devtools.ksp") version "2.0.0-1.0.22" apply false
}