From 5536183440c8dfa6dbf1eceb066c9db97f55dae3 Mon Sep 17 00:00:00 2001 From: Kian Mahmoudi Date: Wed, 17 Jun 2026 21:35:09 +0330 Subject: [PATCH] refactor: move config, CA install, and Google IP logic into ViewModel and repositories --- .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/MasterHttpRelayVPN-RUST.iml | 9 + .idea/caches/deviceStreaming.xml | 1870 +++++++++++++++++ .idea/deploymentTargetSelector.xml | 10 + .idea/deviceManager.xml | 13 + .idea/gradle.xml | 19 + .idea/inspectionProfiles/Project_Default.xml | 50 + .idea/markdown.xml | 8 + .idea/migrations.xml | 10 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/runConfigurations.xml | 17 + .idea/vcs.xml | 6 + android/app/build.gradle.kts | 6 + android/app/proguard-rules.pro | 2 +- android/app/src/main/AndroidManifest.xml | 4 +- .../java/com/therealaleph/mhrv/MhrvApp.kt | 7 +- .../main/java/com/therealaleph/mhrv/Native.kt | 2 +- .../com/therealaleph/mhrv/data/CaCertInfo.kt | 7 + .../therealaleph/mhrv/{ => data}/CaInstall.kt | 8 +- .../therealaleph/mhrv/data/CaRepository.kt | 7 + .../mhrv/data/CaRepositoryImpl.kt | 36 + .../mhrv/data/ConfigRepository.kt | 12 + .../mhrv/data/ConfigRepositoryImpl.kt | 34 + .../mhrv/{ => data}/ConfigStore.kt | 18 +- .../mhrv/{ => data}/MhrvVpnService.kt | 85 +- .../mhrv/{ => data}/NetworkDetect.kt | 4 +- .../mhrv/{ => data}/PipelineDebugOverlay.kt | 5 +- .../therealaleph/mhrv/{ => data}/VpnState.kt | 4 +- .../com/therealaleph/mhrv/di/AppModule.kt | 29 + .../com/therealaleph/mhrv/ui/ConfigSharing.kt | 20 +- .../com/therealaleph/mhrv/ui/HomeScreen.kt | 276 +-- .../com/therealaleph/mhrv/ui/HomeViewModel.kt | 153 ++ .../mhrv/{ => ui}/MainActivity.kt | 123 +- android/app/src/main/res/values/strings.xml | 3 + android/build.gradle.kts | 2 + 37 files changed, 2652 insertions(+), 230 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/MasterHttpRelayVPN-RUST.iml create mode 100644 .idea/caches/deviceStreaming.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/markdown.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/data/CaCertInfo.kt rename android/app/src/main/java/com/therealaleph/mhrv/{ => data}/CaInstall.kt (98%) create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/data/CaRepository.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/data/CaRepositoryImpl.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepository.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/data/ConfigRepositoryImpl.kt rename android/app/src/main/java/com/therealaleph/mhrv/{ => data}/ConfigStore.kt (97%) rename android/app/src/main/java/com/therealaleph/mhrv/{ => data}/MhrvVpnService.kt (91%) rename android/app/src/main/java/com/therealaleph/mhrv/{ => data}/NetworkDetect.kt (97%) rename android/app/src/main/java/com/therealaleph/mhrv/{ => data}/PipelineDebugOverlay.kt (98%) rename android/app/src/main/java/com/therealaleph/mhrv/{ => data}/VpnState.kt (98%) create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/di/AppModule.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ui/HomeViewModel.kt rename android/app/src/main/java/com/therealaleph/mhrv/{ => ui}/MainActivity.kt (77%) diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/MasterHttpRelayVPN-RUST.iml b/.idea/MasterHttpRelayVPN-RUST.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/MasterHttpRelayVPN-RUST.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 00000000..76d03c29 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1870 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..5a3302e1 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ 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 @@ Copy Install Cancel + Select all + Unselect all Deployment URL(s) or script ID(s) @@ -46,6 +48,7 @@ Only selected apps All except selected Pick apps… + Show system apps Install 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 }