Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ android {
// that before each assembleDebug / assembleRelease.
sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs")

// assets/fronting-groups/curated.json is generated into build/ by
// the syncFrontingGroupsAssets task (defined further down). Keeping
// generated output under build/ rather than src/ means stale copies
// can't outlive the canonical file in the source tree, and the
// standard build/ gitignore covers it without a carve-out under
// src/main/assets/.
sourceSets["main"].assets.srcDir(
layout.buildDirectory.dir("generated/curatedAssets")
)

packaging {
resources.excludes += setOf(
"META-INF/AL2.0",
Expand Down Expand Up @@ -142,6 +152,15 @@ dependencies {

debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

// Local JVM unit tests (`gradlew :app:test`). JUnit 4 plus the real
// org.json:json classes — by default android.jar's stubbed
// JSONObject methods all return null in unit tests, which makes
// ConfigStore round-trip tests untestable. The org.json artifact
// overrides those stubs in the test classpath without affecting
// the device runtime.
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
}

// --------------------------------------------------------------------------
Expand Down Expand Up @@ -222,3 +241,34 @@ tasks.configureEach {
"mergeReleaseJniLibFolders" -> dependsOn("cargoBuildRelease")
}
}

// --------------------------------------------------------------------------
// Bundle assets/fronting-groups/curated.json into the APK so the Android
// UI's "Load curated fronting groups" button can read it without a network
// hop. The Rust crate is the single source of truth; we copy into a
// build/generated/ directory that is wired into sourceSets.main.assets
// above, so stale outputs can't survive the canonical file being deleted
// or renamed (a fresh `gradlew clean` wipes them) and we don't need a
// gitignore carve-out under src/main/assets/.
// --------------------------------------------------------------------------
val syncFrontingGroupsAssets =
tasks.register<Copy>("syncFrontingGroupsAssets") {
from(rustCrateDir.resolve("assets/fronting-groups"))
include("curated.json")
// Sub-folder so the asset opens at "fronting-groups/curated.json"
// (matches CuratedGroups.ASSET_PATH); without the sub-dir Android
// would expose it at the asset namespace root.
into(layout.buildDirectory.dir("generated/curatedAssets/fronting-groups"))
}

tasks.configureEach {
when (name) {
// Asset merge runs before resource processing — depending on
// mergeDebugAssets / mergeReleaseAssets is the most precise
// hook, but preBuild also covers the lint/compile paths that
// need the file present (lintDebug, etc.).
"preBuild" -> dependsOn(syncFrontingGroupsAssets)
"mergeDebugAssets",
"mergeReleaseAssets" -> dependsOn(syncFrontingGroupsAssets)
}
}
82 changes: 80 additions & 2 deletions android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ enum class UiLang { AUTO, FA, EN }
*/
enum class Mode { APPS_SCRIPT, DIRECT, FULL }

/**
* One multi-edge fronting group. Mirrors the Rust `FrontingGroup`
* struct in `src/config.rs` and the desktop UI's round-tripped form.
*
* `domains` matches case-insensitively, exact OR dot-anchored suffix
* (`vercel.com` covers `*.vercel.com`). First group whose member
* matches wins, so put more-specific groups earlier in the list.
*/
data class FrontingGroup(
val name: String,
val ip: String,
val sni: String,
val domains: List<String>,
)

data class MhrvConfig(
val mode: Mode = Mode.APPS_SCRIPT,

Expand Down Expand Up @@ -161,6 +176,16 @@ data class MhrvConfig(

/** UI language toggle. Non-Rust; honoured only by the Android wrapper. */
val uiLang: UiLang = UiLang.AUTO,

/**
* Multi-edge fronting groups (Vercel, Fastly, AWS CloudFront, …).
* Until v1.9.x the Android Save path silently dropped this field
* because it wasn't modelled here; round-tripping fixes that and
* unlocks the curated bundle loader. There's no in-app editor for
* the entries — users either load the curated bundle or import a
* config that contains them. See `assets/fronting-groups/curated.json`.
*/
val frontingGroups: List<FrontingGroup> = emptyList(),
) {
/**
* Extract just the deployment ID from either a full
Expand Down Expand Up @@ -279,6 +304,19 @@ data class MhrvConfig(
UiLang.FA -> "fa"
UiLang.EN -> "en"
})

if (frontingGroups.isNotEmpty()) {
put("fronting_groups", JSONArray().apply {
for (g in frontingGroups) {
put(JSONObject().apply {
put("name", g.name)
put("ip", g.ip)
put("sni", g.sni)
put("domains", JSONArray().apply { g.domains.forEach { put(it) } })
})
}
})
}
}
return obj.toString(2)
}
Expand Down Expand Up @@ -356,6 +394,18 @@ object ConfigStore {
if (cleanBypassDohHosts.isNotEmpty()) {
obj.put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } })
}
if (cfg.frontingGroups.isNotEmpty()) {
obj.put("fronting_groups", JSONArray().apply {
for (g in cfg.frontingGroups) {
put(JSONObject().apply {
put("name", g.name)
put("ip", g.ip)
put("sni", g.sni)
put("domains", JSONArray().apply { g.domains.forEach { put(it) } })
})
}
})
}

// Compress with DEFLATE then base64.
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
Expand Down Expand Up @@ -415,8 +465,13 @@ object ConfigStore {
return false
}

/** Parse config from a JSON object — shared by load() and decode(). */
private fun loadFromJson(obj: JSONObject): MhrvConfig {
/**
* Parse config from a JSON object — shared by [load] and [decode].
* `internal` rather than `private` so the JVM unit tests in
* `src/test/` can drive a JSON-only round-trip without going
* through the disk path.
*/
internal fun loadFromJson(obj: JSONObject): MhrvConfig {
val ids = obj.optJSONArray("script_ids")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
Expand Down Expand Up @@ -476,6 +531,29 @@ object ConfigStore {
"en" -> UiLang.EN
else -> UiLang.AUTO
},
frontingGroups = obj.optJSONArray("fronting_groups")?.let { arr ->
buildList {
for (i in 0 until arr.length()) {
val g = arr.optJSONObject(i) ?: continue
val name = g.optString("name").trim()
val ip = g.optString("ip").trim()
val sni = g.optString("sni").trim()
val domArr = g.optJSONArray("domains")
val domains = if (domArr != null) {
buildList {
for (j in 0 until domArr.length()) {
val d = domArr.optString(j).trim()
if (d.isNotEmpty()) add(d)
}
}
} else emptyList()
// Skip half-empty entries — same shape as the
// Rust validator in src/config.rs would reject.
if (name.isEmpty() || ip.isEmpty() || sni.isEmpty() || domains.isEmpty()) continue
add(FrontingGroup(name, ip, sni, domains))
}
}
}.orEmpty(),
)
}
}
Expand Down
101 changes: 101 additions & 0 deletions android/app/src/main/java/com/therealaleph/mhrv/CuratedGroups.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.therealaleph.mhrv

import android.content.Context
import android.util.Log
import java.io.IOException
import org.json.JSONException
import org.json.JSONObject

/**
* Loader + merger for the curated fronting-group bundle shipped at
* `assets/fronting-groups/curated.json` (synced from the Rust crate's
* canonical copy at repo-root `assets/fronting-groups/curated.json` by
* the `syncFrontingGroupsAssets` Gradle task).
*
* Same shape as `src/curated_groups.rs` on the Rust side: `mergeInto`
* appends groups whose `name` isn't already present, leaving the user's
* hand-edited entries alone. There's no in-app editor for the entries
* yet, so this is the no-typing path to install Vercel / Fastly /
* AWS-CloudFront / direct-GitHub coverage.
*
* Edge IPs rotate. If a group stops working, the remediation is the
* same as desktop: re-resolve `sni` (`nslookup <sni>`) and edit the IP
* by hand in `config.json`. There's no IP-refresh button in the UI yet.
*/
object CuratedGroups {
private const val TAG = "CuratedGroups"
private const val ASSET_PATH = "fronting-groups/curated.json"

/** Result of [mergeInto], surfaced to the UI for snackbar text. */
data class MergeReport(val added: Int, val skipped: Int)

/**
* Read the bundled curated.json from APK assets and parse the
* `fronting_groups` array. Returns null on a packaging or parse
* failure (UI surfaces a generic toast); both failure modes are
* also logged at warn so a user reporting "the button does
* nothing" can be debugged from logcat. Anything else propagates
* — we don't want to swallow `OutOfMemoryError` or a coding bug
* (NPE / IndexOutOfBounds) just because the call site is a
* button-tap.
*/
fun loadCurated(ctx: Context): List<FrontingGroup>? {
val json = try {
ctx.assets.open(ASSET_PATH).bufferedReader().use { it.readText() }
} catch (e: IOException) {
Log.w(TAG, "asset $ASSET_PATH unreadable", e)
return null
}

val arr = try {
JSONObject(json).optJSONArray("fronting_groups")
} catch (e: JSONException) {
Log.w(TAG, "asset $ASSET_PATH is not valid JSON", e)
return null
} ?: return null

return buildList {
for (i in 0 until arr.length()) {
val g = arr.optJSONObject(i) ?: continue
val name = g.optString("name").trim()
val ip = g.optString("ip").trim()
val sni = g.optString("sni").trim()
val domArr = g.optJSONArray("domains") ?: continue
val domains = buildList {
for (j in 0 until domArr.length()) {
val d = domArr.optString(j).trim()
if (d.isNotEmpty()) add(d)
}
}
if (name.isEmpty() || ip.isEmpty() || sni.isEmpty() || domains.isEmpty()) continue
add(FrontingGroup(name, ip, sni, domains))
}
}
}

/**
* Append every curated group whose `name` isn't already in
* [existing]. Names compare case-insensitively after trim — the
* way humans actually edit configs. Returns a new list (does not
* mutate [existing]) plus a report of how many were added vs.
* already-present.
*/
fun mergeInto(
existing: List<FrontingGroup>,
curated: List<FrontingGroup>,
): Pair<List<FrontingGroup>, MergeReport> {
val merged = existing.toMutableList()
var added = 0
var skipped = 0
for (g in curated) {
val present = merged.any { it.name.trim().equals(g.name.trim(), ignoreCase = true) }
if (present) {
skipped += 1
} else {
merged.add(g)
added += 1
}
}
return merged to MergeReport(added, skipped)
}
}
47 changes: 47 additions & 0 deletions android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.therealaleph.mhrv.CaInstall
import com.therealaleph.mhrv.ConfigStore
import com.therealaleph.mhrv.CuratedGroups
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
import com.therealaleph.mhrv.MhrvConfig
import com.therealaleph.mhrv.Mode
Expand Down Expand Up @@ -1369,6 +1370,52 @@ private fun AdvancedSettings(
Text(stringResource(R.string.adv_upstream_socks5_help))
},
)

// Curated fronting-group loader. The bundle ships at
// assets/fronting-groups/curated.json (synced from the Rust
// crate's canonical copy by Gradle's syncFrontingGroupsAssets
// task). Mirrors the desktop UI's Advanced-section button.
// No in-app editor for the entries — this is the no-typing
// path. Existing groups with the same `name` are preserved.
val ctx = LocalContext.current
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(R.string.adv_fronting_groups_count, cfg.frontingGroups.size),
style = MaterialTheme.typography.bodyMedium,
)
Text(
stringResource(R.string.adv_fronting_groups_help),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilledTonalButton(
onClick = {
val curated = CuratedGroups.loadCurated(ctx)
if (curated == null) {
Toast.makeText(
ctx,
ctx.getString(R.string.toast_curated_load_failed),
Toast.LENGTH_LONG,
).show()
} else {
val (merged, report) = CuratedGroups.mergeInto(cfg.frontingGroups, curated)
onChange(cfg.copy(frontingGroups = merged))
Toast.makeText(
ctx,
ctx.getString(
R.string.toast_curated_loaded,
report.added,
report.skipped,
),
Toast.LENGTH_LONG,
).show()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.btn_load_curated_groups))
}
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/res/values-fa/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@
<string name="adv_upstream_socks5">upstream_socks5 (اختیاری)</string>
<string name="adv_upstream_socks5_help">اگر تنظیم شود، ترافیک خروجی از این SOCKS5 رد می‌شود. خالی بگذارید برای اتصال مستقیم.</string>

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

<!-- Live logs -->
<string name="logs_lines_count">%1$d خط</string>

Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@
<string name="adv_upstream_socks5">upstream_socks5 (optional)</string>
<string name="adv_upstream_socks5_help">If set, route upstream via this SOCKS5. Leave blank for direct.</string>

<!-- Curated fronting groups -->
<string name="adv_fronting_groups_count">Fronting groups: %1$d</string>
<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>
<string name="btn_load_curated_groups">Load curated fronting groups</string>
<string name="toast_curated_loaded">Loaded curated groups: %1$d added, %2$d already present.</string>
<string name="toast_curated_load_failed">Could not read curated fronting groups asset.</string>

<!-- Live logs -->
<string name="logs_lines_count">%1$d lines</string>

Expand Down
Loading
Loading