Skip to content

Commit 0213993

Browse files
feat: multi-profile config storage (desktop + Android)
1 parent b259dd0 commit 0213993

12 files changed

Lines changed: 4094 additions & 26 deletions

File tree

android/app/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ dependencies {
142142

143143
debugImplementation("androidx.compose.ui:ui-tooling")
144144
debugImplementation("androidx.compose.ui:ui-test-manifest")
145+
146+
// Local JVM unit tests (Robolectric so we can use Android Context
147+
// without standing up an emulator). Used by ProfileStoreTest to
148+
// verify the storage invariants documented in ProfileStore.kt.
149+
testImplementation("junit:junit:4.13.2")
150+
testImplementation("org.robolectric:robolectric:4.13")
151+
testImplementation("androidx.test:core:1.6.1")
145152
}
146153

147154
// --------------------------------------------------------------------------

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

Lines changed: 160 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)