Skip to content

Commit d719c8f

Browse files
feat(android): config import/export via clipboard, QR code, deep link, and share sheet
- Clipboard paste: banner auto-detects mhrv:// or raw JSON in clipboard, one tap to import. Clipboard cleared after successful import. - Export dialog: QR code + compressed hash + copy button + Android share sheet (sends QR image + text together). - QR scanner: ZXing embedded scanner in portrait orientation. - Deep link: mhrv:// URIs auto-open the app and import the config. - Compact encoding: only non-default fields included, DEFLATE compressed before base64. Accepts both compressed and raw JSON on import. - ConfigStore.loadFromJson() deduplicated — shared by file load + import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6469e1f commit d719c8f

8 files changed

Lines changed: 510 additions & 53 deletions

File tree

android/app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ dependencies {
136136
implementation("androidx.compose.material3:material3")
137137
implementation("androidx.compose.material:material-icons-extended")
138138

139+
// QR code generation + scanning (self-contained, no ML Kit needed).
140+
implementation("com.google.zxing:core:3.5.3")
141+
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
142+
139143
debugImplementation("androidx.compose.ui:ui-tooling")
140144
debugImplementation("androidx.compose.ui:ui-test-manifest")
141145
}

android/app/src/main/AndroidManifest.xml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,33 @@
5353
<action android:name="android.intent.action.MAIN" />
5454
<category android:name="android.intent.category.LAUNCHER" />
5555
</intent-filter>
56+
<!-- Deep link: tapping mhrv://... in any app opens MainActivity
57+
and auto-imports the encoded config. -->
58+
<intent-filter>
59+
<action android:name="android.intent.action.VIEW" />
60+
<category android:name="android.intent.category.DEFAULT" />
61+
<category android:name="android.intent.category.BROWSABLE" />
62+
<data android:scheme="mhrv" />
63+
</intent-filter>
5664
</activity>
5765

66+
<!-- FileProvider for sharing QR code images via the share sheet. -->
67+
<provider
68+
android:name="androidx.core.content.FileProvider"
69+
android:authorities="${applicationId}.fileprovider"
70+
android:exported="false"
71+
android:grantUriPermissions="true">
72+
<meta-data
73+
android:name="android.support.FILE_PROVIDER_PATHS"
74+
android:resource="@xml/file_paths" />
75+
</provider>
76+
77+
<!-- Force ZXing scanner to portrait (matches app orientation). -->
78+
<activity
79+
android:name="com.journeyapps.barcodescanner.CaptureActivity"
80+
android:screenOrientation="portrait"
81+
tools:replace="android:screenOrientation" />
82+
5883
<!--
5984
VpnService: Android captures all traffic at the IP layer and feeds
6085
it to us via a TUN file descriptor. The android.net.VpnService action

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 147 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -230,59 +230,7 @@ object ConfigStore {
230230
val f = File(ctx.filesDir, FILE)
231231
if (!f.exists()) return MhrvConfig()
232232
return try {
233-
val obj = JSONObject(f.readText())
234-
235-
val ids = obj.optJSONArray("script_ids")?.let { arr ->
236-
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
237-
}?.filter { it.isNotBlank() }.orEmpty()
238-
// For display we turn each ID back into the full URL form —
239-
// easier to paste-verify, and the Kotlin side doesn't depend
240-
// on it (extractId re-parses on save).
241-
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
242-
243-
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
244-
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
245-
}?.filter { it.isNotBlank() }.orEmpty()
246-
247-
MhrvConfig(
248-
mode = when (obj.optString("mode", "apps_script")) {
249-
"google_only" -> Mode.GOOGLE_ONLY
250-
"full" -> Mode.FULL
251-
else -> Mode.APPS_SCRIPT
252-
},
253-
listenHost = obj.optString("listen_host", "127.0.0.1"),
254-
listenPort = obj.optInt("listen_port", 8080),
255-
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
256-
appsScriptUrls = urls,
257-
authKey = obj.optString("auth_key", ""),
258-
frontDomain = obj.optString("front_domain", "www.google.com"),
259-
sniHosts = sni,
260-
googleIp = obj.optString("google_ip", "142.251.36.68"),
261-
verifySsl = obj.optBoolean("verify_ssl", true),
262-
logLevel = obj.optString("log_level", "info"),
263-
parallelRelay = obj.optInt("parallel_relay", 1),
264-
upstreamSocks5 = obj.optString("upstream_socks5", ""),
265-
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
266-
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
267-
}?.filter { it.isNotBlank() }.orEmpty(),
268-
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
269-
"proxy_only" -> ConnectionMode.PROXY_ONLY
270-
else -> ConnectionMode.VPN_TUN // default for unknown/missing
271-
},
272-
splitMode = when (obj.optString("split_mode", "all")) {
273-
"only" -> SplitMode.ONLY
274-
"except" -> SplitMode.EXCEPT
275-
else -> SplitMode.ALL
276-
},
277-
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
278-
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
279-
}?.filter { it.isNotBlank() }.orEmpty(),
280-
uiLang = when (obj.optString("ui_lang", "auto")) {
281-
"fa" -> UiLang.FA
282-
"en" -> UiLang.EN
283-
else -> UiLang.AUTO
284-
},
285-
)
233+
loadFromJson(JSONObject(f.readText()))
286234
} catch (_: Throwable) {
287235
MhrvConfig()
288236
}
@@ -292,6 +240,152 @@ object ConfigStore {
292240
val f = File(ctx.filesDir, FILE)
293241
f.writeText(cfg.toJson())
294242
}
243+
244+
/** Prefix for encoded config strings so we can detect them in clipboard. */
245+
private const val HASH_PREFIX = "mhrv://"
246+
247+
/** Encode config as a shareable base64 string with prefix.
248+
* Only includes non-default fields to keep the hash short. */
249+
fun encode(cfg: MhrvConfig): String {
250+
val defaults = MhrvConfig()
251+
val obj = JSONObject()
252+
253+
// Always include essential fields.
254+
obj.put("mode", when (cfg.mode) {
255+
Mode.APPS_SCRIPT -> "apps_script"
256+
Mode.GOOGLE_ONLY -> "google_only"
257+
Mode.FULL -> "full"
258+
})
259+
val ids = cfg.appsScriptUrls.mapNotNull { url ->
260+
val marker = "/macros/s/"
261+
val i = url.indexOf(marker)
262+
if (i >= 0) {
263+
var s = url.substring(i + marker.length)
264+
val slash = s.indexOf('/'); if (slash >= 0) s = s.substring(0, slash)
265+
s.trim().ifEmpty { null }
266+
} else url.trim().ifEmpty { null }
267+
}
268+
if (ids.isNotEmpty()) obj.put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
269+
if (cfg.authKey.isNotBlank()) obj.put("auth_key", cfg.authKey)
270+
271+
// Only include non-default values.
272+
if (cfg.googleIp != defaults.googleIp) obj.put("google_ip", cfg.googleIp)
273+
if (cfg.frontDomain != defaults.frontDomain) obj.put("front_domain", cfg.frontDomain)
274+
if (cfg.sniHosts.isNotEmpty()) obj.put("sni_hosts", JSONArray().apply { cfg.sniHosts.forEach { put(it) } })
275+
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
276+
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
277+
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
278+
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
279+
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
280+
281+
// Compress with DEFLATE then base64.
282+
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
283+
val compressed = java.io.ByteArrayOutputStream().also { bos ->
284+
java.util.zip.DeflaterOutputStream(bos).use { it.write(jsonBytes) }
285+
}.toByteArray()
286+
287+
val b64 = android.util.Base64.encodeToString(
288+
compressed,
289+
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
290+
)
291+
return "$HASH_PREFIX$b64"
292+
}
293+
294+
/** Try DEFLATE inflate; fall back to treating bytes as raw UTF-8
295+
* (for backward compat with uncompressed exports). */
296+
private fun inflateOrRaw(raw: ByteArray): String {
297+
return try {
298+
java.util.zip.InflaterInputStream(raw.inputStream()).bufferedReader().readText()
299+
} catch (_: Throwable) {
300+
String(raw, Charsets.UTF_8)
301+
}
302+
}
303+
304+
/** Try to decode an encoded config string or raw JSON. Returns null on failure. */
305+
fun decode(encoded: String): MhrvConfig? {
306+
val trimmed = encoded.trim()
307+
// Try raw JSON first.
308+
if (trimmed.startsWith("{")) {
309+
return try {
310+
val obj = JSONObject(trimmed)
311+
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) null
312+
else loadFromJson(obj)
313+
} catch (_: Throwable) { null }
314+
}
315+
// Try mhrv:// base64 encoded (possibly DEFLATE-compressed).
316+
val payload = if (trimmed.startsWith(HASH_PREFIX)) trimmed.removePrefix(HASH_PREFIX) else trimmed
317+
return try {
318+
val raw = android.util.Base64.decode(payload, android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE)
319+
val text = inflateOrRaw(raw)
320+
val obj = JSONObject(text)
321+
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) return null
322+
loadFromJson(obj)
323+
} catch (_: Throwable) {
324+
null
325+
}
326+
}
327+
328+
/** Check if a string looks like an encoded mhrv config. */
329+
fun looksLikeConfig(text: String): Boolean {
330+
val t = text.trim()
331+
if (t.startsWith(HASH_PREFIX)) return true
332+
// Also accept raw JSON with a "mode" field.
333+
if (t.startsWith("{")) {
334+
return try { JSONObject(t).has("mode") } catch (_: Throwable) { false }
335+
}
336+
return false
337+
}
338+
339+
/** Parse config from a JSON object — shared by load() and decode(). */
340+
private fun loadFromJson(obj: JSONObject): MhrvConfig {
341+
val ids = obj.optJSONArray("script_ids")?.let { arr ->
342+
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
343+
}?.filter { it.isNotBlank() }.orEmpty()
344+
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
345+
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
346+
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
347+
}?.filter { it.isNotBlank() }.orEmpty()
348+
349+
return MhrvConfig(
350+
mode = when (obj.optString("mode", "apps_script")) {
351+
"google_only" -> Mode.GOOGLE_ONLY
352+
"full" -> Mode.FULL
353+
else -> Mode.APPS_SCRIPT
354+
},
355+
listenHost = obj.optString("listen_host", "127.0.0.1"),
356+
listenPort = obj.optInt("listen_port", 8080),
357+
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
358+
appsScriptUrls = urls,
359+
authKey = obj.optString("auth_key", ""),
360+
frontDomain = obj.optString("front_domain", "www.google.com"),
361+
sniHosts = sni,
362+
googleIp = obj.optString("google_ip", "142.251.36.68"),
363+
verifySsl = obj.optBoolean("verify_ssl", true),
364+
logLevel = obj.optString("log_level", "info"),
365+
parallelRelay = obj.optInt("parallel_relay", 1),
366+
upstreamSocks5 = obj.optString("upstream_socks5", ""),
367+
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
368+
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
369+
}?.filter { it.isNotBlank() }.orEmpty(),
370+
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
371+
"proxy_only" -> ConnectionMode.PROXY_ONLY
372+
else -> ConnectionMode.VPN_TUN
373+
},
374+
splitMode = when (obj.optString("split_mode", "all")) {
375+
"only" -> SplitMode.ONLY
376+
"except" -> SplitMode.EXCEPT
377+
else -> SplitMode.ALL
378+
},
379+
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
380+
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
381+
}?.filter { it.isNotBlank() }.orEmpty(),
382+
uiLang = when (obj.optString("ui_lang", "auto")) {
383+
"fa" -> UiLang.FA
384+
"en" -> UiLang.EN
385+
else -> UiLang.AUTO
386+
},
387+
)
388+
}
295389
}
296390

297391
/**

android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,30 @@ class MainActivity : AppCompatActivity() {
8181
}
8282
}
8383

84+
// Handle mhrv:// deep link — auto-import config.
85+
handleDeepLink(intent)
86+
8487
setContent {
8588
MhrvTheme {
8689
AppRoot()
8790
}
8891
}
8992
}
9093

94+
override fun onNewIntent(intent: Intent) {
95+
super.onNewIntent(intent)
96+
handleDeepLink(intent)
97+
}
98+
99+
private fun handleDeepLink(intent: Intent?) {
100+
val data = intent?.data ?: return
101+
if (data.scheme != "mhrv") return
102+
val encoded = data.toString()
103+
val cfg = ConfigStore.decode(encoded) ?: return
104+
ConfigStore.save(this, cfg)
105+
android.widget.Toast.makeText(this, "Config imported", android.widget.Toast.LENGTH_SHORT).show()
106+
}
107+
91108
@Composable
92109
private fun AppRoot() {
93110
// The system VpnService.prepare() returns an Intent if the user

0 commit comments

Comments
 (0)