@@ -161,6 +161,29 @@ data class MhrvConfig(
161161
162162 /* * UI language toggle. Non-Rust; honoured only by the Android wrapper. */
163163 val uiLang : UiLang = UiLang .AUTO ,
164+
165+ /* *
166+ * Verbatim JSON for any config.json key this build doesn't model
167+ * (e.g. desktop-only `fronting_groups`, `exit_node`,
168+ * `request_timeout_secs`, `disable_padding`, `auto_blacklist_*`,
169+ * `hosts`, `normalize_x_graphql`, and any future Rust-side field
170+ * added before Android catches up).
171+ *
172+ * Captured by [ConfigStore.loadFromJson] and re-emitted by
173+ * [toJson] so a Rust-shaped or future-shaped config survives a
174+ * round-trip through the Android UI **without losing fields the
175+ * native runtime still needs**. The whole point of the Profile
176+ * "raw snapshot preservation" invariant is that the Rust side
177+ * sees those fields — and the Rust side reads `config.json`,
178+ * which is what we write here.
179+ *
180+ * Stored as a JSON object string (not Map) so we can splice it
181+ * back in via [JSONObject.put] without retyping every key.
182+ * Default empty = no passthrough fields.
183+ *
184+ * Excluded from [toJson]'s output when blank.
185+ */
186+ val extrasJson : String = " " ,
164187) {
165188 /* *
166189 * Extract just the deployment ID from either a full
@@ -279,6 +302,27 @@ data class MhrvConfig(
279302 UiLang .FA -> " fa"
280303 UiLang .EN -> " en"
281304 })
305+
306+ // Splice back any keys this build doesn't model (so they
307+ // survive a load → edit → save round-trip and reach the
308+ // native runtime, which IS the source of truth for them).
309+ // We deliberately don't overwrite our modelled keys — if a
310+ // future build models a field that's currently in extras,
311+ // the new modelled value wins on the next save.
312+ if (extrasJson.isNotBlank()) {
313+ try {
314+ val ex = JSONObject (extrasJson)
315+ val it = ex.keys()
316+ while (it.hasNext()) {
317+ val k = it.next()
318+ if (! has(k)) put(k, ex.get(k))
319+ }
320+ } catch (_: Throwable ) {
321+ // Malformed extras — drop. Captured-at-parse-time
322+ // extras should never be malformed; this guard is
323+ // for the synthetic-cfg path (decode()).
324+ }
325+ }
282326 }
283327 return obj.toString(2 )
284328 }
@@ -301,9 +345,24 @@ object ConfigStore {
301345 }
302346 }
303347
304- fun save (ctx : Context , cfg : MhrvConfig ) {
348+ /* *
349+ * Persist [cfg] to `config.json`. Returns true on success.
350+ *
351+ * Atomicity: writes to `config.json.tmp` and replaces via the same
352+ * NIO/backup pattern as [ProfileStore.save] — never deletes the
353+ * existing file without a backup. On failure the previous
354+ * `config.json` is preserved untouched (or restored from `.bak`).
355+ */
356+ fun save (ctx : Context , cfg : MhrvConfig ): Boolean {
305357 val f = File (ctx.filesDir, FILE )
306- f.writeText(cfg.toJson())
358+ val tmp = File (ctx.filesDir, " $FILE .tmp" )
359+ return try {
360+ tmp.writeText(cfg.toJson())
361+ ProfileStore .atomicReplacePublic(tmp, f)
362+ } catch (_: Throwable ) {
363+ tmp.delete()
364+ false
365+ }
307366 }
308367
309368 /* * Prefix for encoded config strings so we can detect them in clipboard. */
@@ -387,8 +446,7 @@ object ConfigStore {
387446 if (trimmed.startsWith(" {" )) {
388447 return try {
389448 val obj = JSONObject (trimmed)
390- if (! obj.has(" mode" ) && ! obj.has(" script_ids" ) && ! obj.has(" auth_key" )) null
391- else loadFromJson(obj)
449+ if (! hasConfigShape(obj)) null else loadFromJson(obj)
392450 } catch (_: Throwable ) { null }
393451 }
394452 // Try mhrv:// base64 encoded (possibly DEFLATE-compressed).
@@ -397,7 +455,7 @@ object ConfigStore {
397455 val raw = android.util.Base64 .decode(payload, android.util.Base64 .NO_WRAP or android.util.Base64 .URL_SAFE )
398456 val text = inflateOrRaw(raw)
399457 val obj = JSONObject (text)
400- if (! obj.has( " mode " ) && ! obj.has( " script_ids " ) && ! obj.has( " auth_key " )) return null
458+ if (! hasConfigShape( obj)) return null
401459 loadFromJson(obj)
402460 } catch (_: Throwable ) {
403461 null
@@ -408,23 +466,89 @@ object ConfigStore {
408466 fun looksLikeConfig (text : String ): Boolean {
409467 val t = text.trim()
410468 if (t.startsWith(HASH_PREFIX )) return true
411- // Also accept raw JSON with a "mode" field.
412469 if (t.startsWith(" {" )) {
413- return try { JSONObject (t).has( " mode " ) } catch (_: Throwable ) { false }
470+ return try { hasConfigShape( JSONObject (t)) } catch (_: Throwable ) { false }
414471 }
415472 return false
416473 }
417474
475+ /* *
476+ * Acceptance gate for "is this JSON shaped like an mhrv config?".
477+ * Accepts any of `mode`, `auth_key`, `script_id` (Rust output),
478+ * or `script_ids` (legacy Android output). `script_id` was added
479+ * after the parser was taught to read both shapes — without it,
480+ * a Rust-shaped config with only `script_id` would be rejected
481+ * here even though [loadFromJson] could read it fine.
482+ */
483+ private fun hasConfigShape (obj : JSONObject ): Boolean =
484+ obj.has(" mode" ) ||
485+ obj.has(" auth_key" ) ||
486+ obj.has(" script_id" ) ||
487+ obj.has(" script_ids" )
488+
489+ /* *
490+ * Keys this build models. Anything outside this set is captured
491+ * into [MhrvConfig.extrasJson] at parse time and re-emitted by
492+ * [MhrvConfig.toJson] so the native runtime keeps seeing
493+ * desktop-only / future Rust-side fields (`fronting_groups`,
494+ * `exit_node`, `request_timeout_secs`, `disable_padding`,
495+ * `auto_blacklist_*`, `hosts`, `normalize_x_graphql`,
496+ * `google_ip_validation`, `scan_batch_size`, etc.).
497+ *
498+ * Updating this set is a deliberate act — add a key here only
499+ * when [MhrvConfig] gains a real field for it.
500+ */
501+ private val MODELLED_KEYS : Set <String > = setOf (
502+ " mode" ,
503+ " listen_host" , " listen_port" , " socks5_port" ,
504+ // Both script_id (Rust output) and script_ids (legacy Android
505+ // output) are read by us, so both belong in the "modelled" set
506+ // — otherwise a Rust-shaped config would have its IDs end up
507+ // in extras AND in the parsed appsScriptUrls, getting written
508+ // out twice (once as the unmodelled passthrough, once as
509+ // script_ids).
510+ " script_id" , " script_ids" ,
511+ " auth_key" ,
512+ " front_domain" , " sni_hosts" , " google_ip" ,
513+ " verify_ssl" , " log_level" , " parallel_relay" ,
514+ " force_http1" ,
515+ " coalesce_step_ms" , " coalesce_max_ms" ,
516+ " block_quic" , " upstream_socks5" ,
517+ " passthrough_hosts" ,
518+ " tunnel_doh" , " bypass_doh_hosts" , " block_doh" ,
519+ " youtube_via_relay" ,
520+ " connection_mode" , " split_mode" , " split_apps" , " ui_lang" ,
521+ // Phone-scoped scan defaults toJson() emits. Modelled so they
522+ // don't round-trip into extras then collide with toJson's
523+ // explicit puts.
524+ " fetch_ips_from_api" , " max_ips_to_scan" ,
525+ )
526+
418527 /* * Parse config from a JSON object — shared by load() and decode(). */
419528 private fun loadFromJson (obj : JSONObject ): MhrvConfig {
420- val ids = obj.optJSONArray(" script_ids" )?.let { arr ->
421- buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
422- }?.filter { it.isNotBlank() }.orEmpty()
529+ // Read deployment IDs from both `script_id` (Rust output) and
530+ // `script_ids` (legacy Android output). Each can be a scalar
531+ // string OR an array of strings.
532+ val ids = buildList<String > {
533+ addAll(readScriptIdList(obj, " script_id" ))
534+ addAll(readScriptIdList(obj, " script_ids" ))
535+ }.filter { it.isNotBlank() }.distinct()
423536 val urls = ids.map { " https://script.google.com/macros/s/$it /exec" }
424537 val sni = obj.optJSONArray(" sni_hosts" )?.let { arr ->
425538 buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
426539 }?.filter { it.isNotBlank() }.orEmpty()
427540
541+ // Capture anything we don't model into extras for passthrough
542+ // (raw-snapshot preservation invariant — the native runtime
543+ // reads config.json directly and needs every field).
544+ val extras = JSONObject ()
545+ val keys = obj.keys()
546+ while (keys.hasNext()) {
547+ val k = keys.next()
548+ if (k !in MODELLED_KEYS ) extras.put(k, obj.get(k))
549+ }
550+ val extrasStr = if (extras.length() > 0 ) extras.toString() else " "
551+
428552 return MhrvConfig (
429553 mode = when (obj.optString(" mode" , " apps_script" )) {
430554 " direct" -> Mode .DIRECT
@@ -476,8 +600,34 @@ object ConfigStore {
476600 " en" -> UiLang .EN
477601 else -> UiLang .AUTO
478602 },
603+ extrasJson = extrasStr,
479604 )
480605 }
606+
607+ /* *
608+ * Read a list of deployment IDs from `key`. Accepts:
609+ * - a JSON string scalar ("abc")
610+ * - a JSON array of strings (["abc","def"])
611+ *
612+ * Mirrors the Rust [ScriptId] enum's `untagged` deserialize so both
613+ * shapes interop with desktop. Returns an empty list if the key
614+ * is absent or shaped wrong.
615+ */
616+ private fun readScriptIdList (obj : JSONObject , key : String ): List <String > {
617+ if (! obj.has(key)) return emptyList()
618+ // Array form first.
619+ obj.optJSONArray(key)?.let { arr ->
620+ return buildList {
621+ for (i in 0 until arr.length()) {
622+ val s = arr.optString(i, " " )
623+ if (s.isNotBlank()) add(s)
624+ }
625+ }
626+ }
627+ // Scalar form.
628+ val s = obj.optString(key, " " )
629+ return if (s.isNotBlank()) listOf (s) else emptyList()
630+ }
481631}
482632
483633/* *
0 commit comments