@@ -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/* *
0 commit comments