Skip to content

Commit 19b28ac

Browse files
feat(fronting-groups): bundle curated edges + UI loaders
1 parent 24534f7 commit 19b28ac

14 files changed

Lines changed: 941 additions & 45 deletions

File tree

android/app/build.gradle.kts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ android {
107107
// that before each assembleDebug / assembleRelease.
108108
sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs")
109109

110+
// assets/fronting-groups/curated.json is generated into build/ by
111+
// the syncFrontingGroupsAssets task (defined further down). Keeping
112+
// generated output under build/ rather than src/ means stale copies
113+
// can't outlive the canonical file in the source tree, and the
114+
// standard build/ gitignore covers it without a carve-out under
115+
// src/main/assets/.
116+
sourceSets["main"].assets.srcDir(
117+
layout.buildDirectory.dir("generated/curatedAssets")
118+
)
119+
110120
packaging {
111121
resources.excludes += setOf(
112122
"META-INF/AL2.0",
@@ -142,6 +152,15 @@ dependencies {
142152

143153
debugImplementation("androidx.compose.ui:ui-tooling")
144154
debugImplementation("androidx.compose.ui:ui-test-manifest")
155+
156+
// Local JVM unit tests (`gradlew :app:test`). JUnit 4 plus the real
157+
// org.json:json classes — by default android.jar's stubbed
158+
// JSONObject methods all return null in unit tests, which makes
159+
// ConfigStore round-trip tests untestable. The org.json artifact
160+
// overrides those stubs in the test classpath without affecting
161+
// the device runtime.
162+
testImplementation("junit:junit:4.13.2")
163+
testImplementation("org.json:json:20240303")
145164
}
146165

147166
// --------------------------------------------------------------------------
@@ -222,3 +241,34 @@ tasks.configureEach {
222241
"mergeReleaseJniLibFolders" -> dependsOn("cargoBuildRelease")
223242
}
224243
}
244+
245+
// --------------------------------------------------------------------------
246+
// Bundle assets/fronting-groups/curated.json into the APK so the Android
247+
// UI's "Load curated fronting groups" button can read it without a network
248+
// hop. The Rust crate is the single source of truth; we copy into a
249+
// build/generated/ directory that is wired into sourceSets.main.assets
250+
// above, so stale outputs can't survive the canonical file being deleted
251+
// or renamed (a fresh `gradlew clean` wipes them) and we don't need a
252+
// gitignore carve-out under src/main/assets/.
253+
// --------------------------------------------------------------------------
254+
val syncFrontingGroupsAssets =
255+
tasks.register<Copy>("syncFrontingGroupsAssets") {
256+
from(rustCrateDir.resolve("assets/fronting-groups"))
257+
include("curated.json")
258+
// Sub-folder so the asset opens at "fronting-groups/curated.json"
259+
// (matches CuratedGroups.ASSET_PATH); without the sub-dir Android
260+
// would expose it at the asset namespace root.
261+
into(layout.buildDirectory.dir("generated/curatedAssets/fronting-groups"))
262+
}
263+
264+
tasks.configureEach {
265+
when (name) {
266+
// Asset merge runs before resource processing — depending on
267+
// mergeDebugAssets / mergeReleaseAssets is the most precise
268+
// hook, but preBuild also covers the lint/compile paths that
269+
// need the file present (lintDebug, etc.).
270+
"preBuild" -> dependsOn(syncFrontingGroupsAssets)
271+
"mergeDebugAssets",
272+
"mergeReleaseAssets" -> dependsOn(syncFrontingGroupsAssets)
273+
}
274+
}

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

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ enum class UiLang { AUTO, FA, EN }
7777
*/
7878
enum class Mode { APPS_SCRIPT, DIRECT, FULL }
7979

80+
/**
81+
* One multi-edge fronting group. Mirrors the Rust `FrontingGroup`
82+
* struct in `src/config.rs` and the desktop UI's round-tripped form.
83+
*
84+
* `domains` matches case-insensitively, exact OR dot-anchored suffix
85+
* (`vercel.com` covers `*.vercel.com`). First group whose member
86+
* matches wins, so put more-specific groups earlier in the list.
87+
*/
88+
data class FrontingGroup(
89+
val name: String,
90+
val ip: String,
91+
val sni: String,
92+
val domains: List<String>,
93+
)
94+
8095
data class MhrvConfig(
8196
val mode: Mode = Mode.APPS_SCRIPT,
8297

@@ -161,6 +176,16 @@ data class MhrvConfig(
161176

162177
/** UI language toggle. Non-Rust; honoured only by the Android wrapper. */
163178
val uiLang: UiLang = UiLang.AUTO,
179+
180+
/**
181+
* Multi-edge fronting groups (Vercel, Fastly, AWS CloudFront, …).
182+
* Until v1.9.x the Android Save path silently dropped this field
183+
* because it wasn't modelled here; round-tripping fixes that and
184+
* unlocks the curated bundle loader. There's no in-app editor for
185+
* the entries — users either load the curated bundle or import a
186+
* config that contains them. See `assets/fronting-groups/curated.json`.
187+
*/
188+
val frontingGroups: List<FrontingGroup> = emptyList(),
164189
) {
165190
/**
166191
* Extract just the deployment ID from either a full
@@ -279,6 +304,19 @@ data class MhrvConfig(
279304
UiLang.FA -> "fa"
280305
UiLang.EN -> "en"
281306
})
307+
308+
if (frontingGroups.isNotEmpty()) {
309+
put("fronting_groups", JSONArray().apply {
310+
for (g in frontingGroups) {
311+
put(JSONObject().apply {
312+
put("name", g.name)
313+
put("ip", g.ip)
314+
put("sni", g.sni)
315+
put("domains", JSONArray().apply { g.domains.forEach { put(it) } })
316+
})
317+
}
318+
})
319+
}
282320
}
283321
return obj.toString(2)
284322
}
@@ -356,6 +394,18 @@ object ConfigStore {
356394
if (cleanBypassDohHosts.isNotEmpty()) {
357395
obj.put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } })
358396
}
397+
if (cfg.frontingGroups.isNotEmpty()) {
398+
obj.put("fronting_groups", JSONArray().apply {
399+
for (g in cfg.frontingGroups) {
400+
put(JSONObject().apply {
401+
put("name", g.name)
402+
put("ip", g.ip)
403+
put("sni", g.sni)
404+
put("domains", JSONArray().apply { g.domains.forEach { put(it) } })
405+
})
406+
}
407+
})
408+
}
359409

360410
// Compress with DEFLATE then base64.
361411
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
@@ -415,8 +465,13 @@ object ConfigStore {
415465
return false
416466
}
417467

418-
/** Parse config from a JSON object — shared by load() and decode(). */
419-
private fun loadFromJson(obj: JSONObject): MhrvConfig {
468+
/**
469+
* Parse config from a JSON object — shared by [load] and [decode].
470+
* `internal` rather than `private` so the JVM unit tests in
471+
* `src/test/` can drive a JSON-only round-trip without going
472+
* through the disk path.
473+
*/
474+
internal fun loadFromJson(obj: JSONObject): MhrvConfig {
420475
val ids = obj.optJSONArray("script_ids")?.let { arr ->
421476
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
422477
}?.filter { it.isNotBlank() }.orEmpty()
@@ -476,6 +531,29 @@ object ConfigStore {
476531
"en" -> UiLang.EN
477532
else -> UiLang.AUTO
478533
},
534+
frontingGroups = obj.optJSONArray("fronting_groups")?.let { arr ->
535+
buildList {
536+
for (i in 0 until arr.length()) {
537+
val g = arr.optJSONObject(i) ?: continue
538+
val name = g.optString("name").trim()
539+
val ip = g.optString("ip").trim()
540+
val sni = g.optString("sni").trim()
541+
val domArr = g.optJSONArray("domains")
542+
val domains = if (domArr != null) {
543+
buildList {
544+
for (j in 0 until domArr.length()) {
545+
val d = domArr.optString(j).trim()
546+
if (d.isNotEmpty()) add(d)
547+
}
548+
}
549+
} else emptyList()
550+
// Skip half-empty entries — same shape as the
551+
// Rust validator in src/config.rs would reject.
552+
if (name.isEmpty() || ip.isEmpty() || sni.isEmpty() || domains.isEmpty()) continue
553+
add(FrontingGroup(name, ip, sni, domains))
554+
}
555+
}
556+
}.orEmpty(),
479557
)
480558
}
481559
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.therealaleph.mhrv
2+
3+
import android.content.Context
4+
import android.util.Log
5+
import java.io.IOException
6+
import org.json.JSONException
7+
import org.json.JSONObject
8+
9+
/**
10+
* Loader + merger for the curated fronting-group bundle shipped at
11+
* `assets/fronting-groups/curated.json` (synced from the Rust crate's
12+
* canonical copy at repo-root `assets/fronting-groups/curated.json` by
13+
* the `syncFrontingGroupsAssets` Gradle task).
14+
*
15+
* Same shape as `src/curated_groups.rs` on the Rust side: `mergeInto`
16+
* appends groups whose `name` isn't already present, leaving the user's
17+
* hand-edited entries alone. There's no in-app editor for the entries
18+
* yet, so this is the no-typing path to install Vercel / Fastly /
19+
* AWS-CloudFront / direct-GitHub coverage.
20+
*
21+
* Edge IPs rotate. If a group stops working, the remediation is the
22+
* same as desktop: re-resolve `sni` (`nslookup <sni>`) and edit the IP
23+
* by hand in `config.json`. There's no IP-refresh button in the UI yet.
24+
*/
25+
object CuratedGroups {
26+
private const val TAG = "CuratedGroups"
27+
private const val ASSET_PATH = "fronting-groups/curated.json"
28+
29+
/** Result of [mergeInto], surfaced to the UI for snackbar text. */
30+
data class MergeReport(val added: Int, val skipped: Int)
31+
32+
/**
33+
* Read the bundled curated.json from APK assets and parse the
34+
* `fronting_groups` array. Returns null on a packaging or parse
35+
* failure (UI surfaces a generic toast); both failure modes are
36+
* also logged at warn so a user reporting "the button does
37+
* nothing" can be debugged from logcat. Anything else propagates
38+
* — we don't want to swallow `OutOfMemoryError` or a coding bug
39+
* (NPE / IndexOutOfBounds) just because the call site is a
40+
* button-tap.
41+
*/
42+
fun loadCurated(ctx: Context): List<FrontingGroup>? {
43+
val json = try {
44+
ctx.assets.open(ASSET_PATH).bufferedReader().use { it.readText() }
45+
} catch (e: IOException) {
46+
Log.w(TAG, "asset $ASSET_PATH unreadable", e)
47+
return null
48+
}
49+
50+
val arr = try {
51+
JSONObject(json).optJSONArray("fronting_groups")
52+
} catch (e: JSONException) {
53+
Log.w(TAG, "asset $ASSET_PATH is not valid JSON", e)
54+
return null
55+
} ?: return null
56+
57+
return buildList {
58+
for (i in 0 until arr.length()) {
59+
val g = arr.optJSONObject(i) ?: continue
60+
val name = g.optString("name").trim()
61+
val ip = g.optString("ip").trim()
62+
val sni = g.optString("sni").trim()
63+
val domArr = g.optJSONArray("domains") ?: continue
64+
val domains = buildList {
65+
for (j in 0 until domArr.length()) {
66+
val d = domArr.optString(j).trim()
67+
if (d.isNotEmpty()) add(d)
68+
}
69+
}
70+
if (name.isEmpty() || ip.isEmpty() || sni.isEmpty() || domains.isEmpty()) continue
71+
add(FrontingGroup(name, ip, sni, domains))
72+
}
73+
}
74+
}
75+
76+
/**
77+
* Append every curated group whose `name` isn't already in
78+
* [existing]. Names compare case-insensitively after trim — the
79+
* way humans actually edit configs. Returns a new list (does not
80+
* mutate [existing]) plus a report of how many were added vs.
81+
* already-present.
82+
*/
83+
fun mergeInto(
84+
existing: List<FrontingGroup>,
85+
curated: List<FrontingGroup>,
86+
): Pair<List<FrontingGroup>, MergeReport> {
87+
val merged = existing.toMutableList()
88+
var added = 0
89+
var skipped = 0
90+
for (g in curated) {
91+
val present = merged.any { it.name.trim().equals(g.name.trim(), ignoreCase = true) }
92+
if (present) {
93+
skipped += 1
94+
} else {
95+
merged.add(g)
96+
added += 1
97+
}
98+
}
99+
return merged to MergeReport(added, skipped)
100+
}
101+
}

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
3535
import androidx.compose.ui.unit.sp
3636
import com.therealaleph.mhrv.CaInstall
3737
import com.therealaleph.mhrv.ConfigStore
38+
import com.therealaleph.mhrv.CuratedGroups
3839
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
3940
import com.therealaleph.mhrv.MhrvConfig
4041
import com.therealaleph.mhrv.Mode
@@ -1369,6 +1370,52 @@ private fun AdvancedSettings(
13691370
Text(stringResource(R.string.adv_upstream_socks5_help))
13701371
},
13711372
)
1373+
1374+
// Curated fronting-group loader. The bundle ships at
1375+
// assets/fronting-groups/curated.json (synced from the Rust
1376+
// crate's canonical copy by Gradle's syncFrontingGroupsAssets
1377+
// task). Mirrors the desktop UI's Advanced-section button.
1378+
// No in-app editor for the entries — this is the no-typing
1379+
// path. Existing groups with the same `name` are preserved.
1380+
val ctx = LocalContext.current
1381+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
1382+
Text(
1383+
stringResource(R.string.adv_fronting_groups_count, cfg.frontingGroups.size),
1384+
style = MaterialTheme.typography.bodyMedium,
1385+
)
1386+
Text(
1387+
stringResource(R.string.adv_fronting_groups_help),
1388+
style = MaterialTheme.typography.labelSmall,
1389+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1390+
)
1391+
FilledTonalButton(
1392+
onClick = {
1393+
val curated = CuratedGroups.loadCurated(ctx)
1394+
if (curated == null) {
1395+
Toast.makeText(
1396+
ctx,
1397+
ctx.getString(R.string.toast_curated_load_failed),
1398+
Toast.LENGTH_LONG,
1399+
).show()
1400+
} else {
1401+
val (merged, report) = CuratedGroups.mergeInto(cfg.frontingGroups, curated)
1402+
onChange(cfg.copy(frontingGroups = merged))
1403+
Toast.makeText(
1404+
ctx,
1405+
ctx.getString(
1406+
R.string.toast_curated_loaded,
1407+
report.added,
1408+
report.skipped,
1409+
),
1410+
Toast.LENGTH_LONG,
1411+
).show()
1412+
}
1413+
},
1414+
modifier = Modifier.fillMaxWidth(),
1415+
) {
1416+
Text(stringResource(R.string.btn_load_curated_groups))
1417+
}
1418+
}
13721419
}
13731420
}
13741421

android/app/src/main/res/values-fa/strings.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@
7575
<string name="adv_upstream_socks5">upstream_socks5 (اختیاری)</string>
7676
<string name="adv_upstream_socks5_help">اگر تنظیم شود، ترافیک خروجی از این SOCKS5 رد می‌شود. خالی بگذارید برای اتصال مستقیم.</string>
7777

78+
<!-- Curated fronting groups -->
79+
<string name="adv_fronting_groups_count">گروه‌های فرانتینگ: %1$d</string>
80+
<string name="adv_fronting_groups_help">بستهٔ آماده شامل Vercel، Fastly (reddit/cnn/python)، AWS CloudFront (netlify) و مسیرهای مستقیم به GitHub است. اگر یک گروه از کار افتاد، آی‌پی را در config.json ویرایش کنید.</string>
81+
<string name="btn_load_curated_groups">بارگذاری گروه‌های فرانتینگ آماده</string>
82+
<string name="toast_curated_loaded">گروه‌های آماده بارگذاری شد: %1$d مورد افزوده شد، %2$d مورد از قبل وجود داشت.</string>
83+
<string name="toast_curated_load_failed">خواندن فایل گروه‌های فرانتینگ آماده ممکن نشد.</string>
84+
7885
<!-- Live logs -->
7986
<string name="logs_lines_count">%1$d خط</string>
8087

android/app/src/main/res/values/strings.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@
7575
<string name="adv_upstream_socks5">upstream_socks5 (optional)</string>
7676
<string name="adv_upstream_socks5_help">If set, route upstream via this SOCKS5. Leave blank for direct.</string>
7777

78+
<!-- Curated fronting groups -->
79+
<string name="adv_fronting_groups_count">Fronting groups: %1$d</string>
80+
<string name="adv_fronting_groups_help">Curated bundle covers vercel, fastly (reddit/cnn/python), AWS CloudFront (netlify), and direct GitHub paths. Edit IPs in config.json if a group stops working.</string>
81+
<string name="btn_load_curated_groups">Load curated fronting groups</string>
82+
<string name="toast_curated_loaded">Loaded curated groups: %1$d added, %2$d already present.</string>
83+
<string name="toast_curated_load_failed">Could not read curated fronting groups asset.</string>
84+
7885
<!-- Live logs -->
7986
<string name="logs_lines_count">%1$d lines</string>
8087

0 commit comments

Comments
 (0)