Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")

// QR code generation + scanning (self-contained, no ML Kit needed).
implementation("com.google.zxing:core:3.5.3")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")

debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Expand Down
25 changes: 25 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,33 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link: tapping mhrv://... in any app opens MainActivity
and auto-imports the encoded config. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mhrv" />
</intent-filter>
</activity>

<!-- FileProvider for sharing QR code images via the share sheet. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

<!-- Force ZXing scanner to portrait (matches app orientation). -->
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
tools:replace="android:screenOrientation" />

<!--
VpnService: Android captures all traffic at the IP layer and feeds
it to us via a TUN file descriptor. The android.net.VpnService action
Expand Down
200 changes: 147 additions & 53 deletions android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -230,59 +230,7 @@ object ConfigStore {
val f = File(ctx.filesDir, FILE)
if (!f.exists()) return MhrvConfig()
return try {
val obj = JSONObject(f.readText())

val ids = obj.optJSONArray("script_ids")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
// For display we turn each ID back into the full URL form —
// easier to paste-verify, and the Kotlin side doesn't depend
// on it (extractId re-parses on save).
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }

val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()

MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
listenHost = obj.optString("listen_host", "127.0.0.1"),
listenPort = obj.optInt("listen_port", 8080),
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
appsScriptUrls = urls,
authKey = obj.optString("auth_key", ""),
frontDomain = obj.optString("front_domain", "www.google.com"),
sniHosts = sni,
googleIp = obj.optString("google_ip", "142.251.36.68"),
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
"proxy_only" -> ConnectionMode.PROXY_ONLY
else -> ConnectionMode.VPN_TUN // default for unknown/missing
},
splitMode = when (obj.optString("split_mode", "all")) {
"only" -> SplitMode.ONLY
"except" -> SplitMode.EXCEPT
else -> SplitMode.ALL
},
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
uiLang = when (obj.optString("ui_lang", "auto")) {
"fa" -> UiLang.FA
"en" -> UiLang.EN
else -> UiLang.AUTO
},
)
loadFromJson(JSONObject(f.readText()))
} catch (_: Throwable) {
MhrvConfig()
}
Expand All @@ -292,6 +240,152 @@ object ConfigStore {
val f = File(ctx.filesDir, FILE)
f.writeText(cfg.toJson())
}

/** Prefix for encoded config strings so we can detect them in clipboard. */
private const val HASH_PREFIX = "mhrv://"

/** Encode config as a shareable base64 string with prefix.
* Only includes non-default fields to keep the hash short. */
fun encode(cfg: MhrvConfig): String {
val defaults = MhrvConfig()
val obj = JSONObject()

// Always include essential fields.
obj.put("mode", when (cfg.mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
Mode.FULL -> "full"
})
val ids = cfg.appsScriptUrls.mapNotNull { url ->
val marker = "/macros/s/"
val i = url.indexOf(marker)
if (i >= 0) {
var s = url.substring(i + marker.length)
val slash = s.indexOf('/'); if (slash >= 0) s = s.substring(0, slash)
s.trim().ifEmpty { null }
} else url.trim().ifEmpty { null }
}
if (ids.isNotEmpty()) obj.put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
if (cfg.authKey.isNotBlank()) obj.put("auth_key", cfg.authKey)

// Only include non-default values.
if (cfg.googleIp != defaults.googleIp) obj.put("google_ip", cfg.googleIp)
if (cfg.frontDomain != defaults.frontDomain) obj.put("front_domain", cfg.frontDomain)
if (cfg.sniHosts.isNotEmpty()) obj.put("sni_hosts", JSONArray().apply { cfg.sniHosts.forEach { put(it) } })
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })

// 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) }
}.toByteArray()

val b64 = android.util.Base64.encodeToString(
compressed,
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
)
return "$HASH_PREFIX$b64"
}

/** Try DEFLATE inflate; fall back to treating bytes as raw UTF-8
* (for backward compat with uncompressed exports). */
private fun inflateOrRaw(raw: ByteArray): String {
return try {
java.util.zip.InflaterInputStream(raw.inputStream()).bufferedReader().readText()
} catch (_: Throwable) {
String(raw, Charsets.UTF_8)
}
}

/** Try to decode an encoded config string or raw JSON. Returns null on failure. */
fun decode(encoded: String): MhrvConfig? {
val trimmed = encoded.trim()
// Try raw JSON first.
if (trimmed.startsWith("{")) {
return try {
val obj = JSONObject(trimmed)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) null
else loadFromJson(obj)
} catch (_: Throwable) { null }
}
// 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 text = inflateOrRaw(raw)
val obj = JSONObject(text)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) return null
loadFromJson(obj)
} catch (_: Throwable) {
null
}
}

/** Check if a string looks like an encoded mhrv config. */
fun looksLikeConfig(text: String): Boolean {
val t = text.trim()
if (t.startsWith(HASH_PREFIX)) return true
// Also accept raw JSON with a "mode" field.
if (t.startsWith("{")) {
return try { JSONObject(t).has("mode") } catch (_: Throwable) { false }
}
return false
}

/** Parse config from a JSON object — shared by load() and decode(). */
private fun loadFromJson(obj: JSONObject): MhrvConfig {
val ids = obj.optJSONArray("script_ids")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()

return MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
listenHost = obj.optString("listen_host", "127.0.0.1"),
listenPort = obj.optInt("listen_port", 8080),
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
appsScriptUrls = urls,
authKey = obj.optString("auth_key", ""),
frontDomain = obj.optString("front_domain", "www.google.com"),
sniHosts = sni,
googleIp = obj.optString("google_ip", "142.251.36.68"),
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
"proxy_only" -> ConnectionMode.PROXY_ONLY
else -> ConnectionMode.VPN_TUN
},
splitMode = when (obj.optString("split_mode", "all")) {
"only" -> SplitMode.ONLY
"except" -> SplitMode.EXCEPT
else -> SplitMode.ALL
},
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
uiLang = when (obj.optString("ui_lang", "auto")) {
"fa" -> UiLang.FA
"en" -> UiLang.EN
else -> UiLang.AUTO
},
)
}
}

/**
Expand Down
17 changes: 17 additions & 0 deletions android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,30 @@ class MainActivity : AppCompatActivity() {
}
}

// Handle mhrv:// deep link — auto-import config.
handleDeepLink(intent)

setContent {
MhrvTheme {
AppRoot()
}
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
}

private fun handleDeepLink(intent: Intent?) {
val data = intent?.data ?: return
if (data.scheme != "mhrv") return
val encoded = data.toString()
val cfg = ConfigStore.decode(encoded) ?: return
ConfigStore.save(this, cfg)
android.widget.Toast.makeText(this, "Config imported", android.widget.Toast.LENGTH_SHORT).show()
}

@Composable
private fun AppRoot() {
// The system VpnService.prepare() returns an Intent if the user
Expand Down
Loading
Loading