From d719c8ff44133daa2c8b3ed6e6dcd2269d6f8d2b Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:46:09 +0200 Subject: [PATCH 1/3] feat(android): config import/export via clipboard, QR code, deep link, and share sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clipboard paste: banner auto-detects mhrv:// or raw JSON in clipboard, one tap to import. Clipboard cleared after successful import. - Export dialog: QR code + compressed hash + copy button + Android share sheet (sends QR image + text together). - QR scanner: ZXing embedded scanner in portrait orientation. - Deep link: mhrv:// URIs auto-open the app and import the config. - Compact encoding: only non-default fields included, DEFLATE compressed before base64. Accepts both compressed and raw JSON on import. - ConfigStore.loadFromJson() deduplicated — shared by file load + import. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/build.gradle.kts | 4 + android/app/src/main/AndroidManifest.xml | 25 ++ .../java/com/therealaleph/mhrv/ConfigStore.kt | 200 ++++++++---- .../com/therealaleph/mhrv/MainActivity.kt | 17 + .../com/therealaleph/mhrv/ui/ConfigSharing.kt | 291 ++++++++++++++++++ .../com/therealaleph/mhrv/ui/HomeScreen.kt | 7 + android/app/src/main/res/values/strings.xml | 15 + android/app/src/main/res/xml/file_paths.xml | 4 + 8 files changed, 510 insertions(+), 53 deletions(-) create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt create mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6f64f46..4362ad1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -136,6 +136,10 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") + // QR code generation + scanning (self-contained, no ML Kit needed). + implementation("com.google.zxing:core:3.5.3") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dd2e94e..030bf7f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -53,8 +53,33 @@ + + + + + + + + + + + + + + + %1$d lines + + Paste config from clipboard + Export config + Show QR code + Scan QR code + Copy to clipboard + Config imported + Config copied to clipboard + Invalid config in clipboard + Export config + This includes your auth_key. Only share with people you trust. + Import config? + This will replace your current settings. + Camera permission needed to scan QR codes + google_ip updated to %1$s google_ip already current (%1$s) diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..1e63d10 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + From 8ed400eeb8f3f490c74538425a10533b8f26382a Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:54:07 +0200 Subject: [PATCH 2/3] fix: deep link requires confirmation, trust warning on import, mhrv-rs:// scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fix: deep link (mhrv-rs://) no longer auto-imports config. Stashes decoded config for UI confirmation dialog — same flow as clipboard paste and QR scan. Import confirmation dialog now shows: - Trust warning: "Importing routes your traffic through the deployment IDs in this config. Only import from trusted sources." - Mode and deployment ID count with first 3 IDs previewed - Explicit Import / Cancel buttons Also: - Renamed scheme from mhrv:// to mhrv-rs:// (less collision risk) - Deduplicated import dialog into shared ImportConfirmDialog composable Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/src/main/AndroidManifest.xml | 2 +- .../java/com/therealaleph/mhrv/ConfigStore.kt | 2 +- .../com/therealaleph/mhrv/MainActivity.kt | 15 +-- .../com/therealaleph/mhrv/ui/ConfigSharing.kt | 96 +++++++++++++++---- 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 030bf7f..4d74ca5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,7 +59,7 @@ - + diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 354999d..2666679 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -242,7 +242,7 @@ object ConfigStore { } /** Prefix for encoded config strings so we can detect them in clipboard. */ - private const val HASH_PREFIX = "mhrv://" + private const val HASH_PREFIX = "mhrv-rs://" /** Encode config as a shareable base64 string with prefix. * Only includes non-default fields to keep the hash short. */ diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt index 921fe74..6336274 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt @@ -81,7 +81,6 @@ class MainActivity : AppCompatActivity() { } } - // Handle mhrv:// deep link — auto-import config. handleDeepLink(intent) setContent { @@ -96,15 +95,17 @@ class MainActivity : AppCompatActivity() { handleDeepLink(intent) } + /** Stash decoded config from deep link for the UI to confirm — never + * auto-import. The composable reads this and shows a confirmation + * dialog with the deployment IDs and a trust warning. */ private fun handleDeepLink(intent: Intent?) { val data = intent?.data ?: return - if (data.scheme != "mhrv") return - val encoded = data.toString() - val cfg = ConfigStore.decode(encoded) ?: return - ConfigStore.save(this, cfg) - android.widget.Toast.makeText(this, "Config imported", android.widget.Toast.LENGTH_SHORT).show() + if (data.scheme != "mhrv-rs") return + val cfg = ConfigStore.decode(data.toString()) ?: return + pendingDeepLinkConfig.value = cfg } + @Composable private fun AppRoot() { // The system VpnService.prepare() returns an Intent if the user @@ -254,5 +255,7 @@ class MainActivity : AppCompatActivity() { companion object { private const val REQ_NOTIF = 42 + /** Deep link config waiting for user confirmation. Read by ConfigSharingBar. */ + val pendingDeepLinkConfig = mutableStateOf(null) } } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt index 834b6fc..50626c4 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt @@ -45,6 +45,20 @@ fun ConfigSharingBar( onImport: (MhrvConfig) -> Unit, onSnackbar: suspend (String) -> Unit, ) { + // Deep link import — requires confirmation before applying. + val deepLinkCfg by com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig + if (deepLinkCfg != null) { + ImportConfirmDialog( + cfg = deepLinkCfg!!, + onConfirm = { + onImport(deepLinkCfg!!) + com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig.value = null + }, + onDismiss = { + com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig.value = null + }, + ) + } val ctx = LocalContext.current val clipboard = LocalClipboardManager.current val scope = rememberCoroutineScope() @@ -241,34 +255,78 @@ fun ConfigSharingBar( } } - // --- Import confirmation dialog --- + // --- Import confirmation dialog (clipboard + QR scan) --- if (showImportConfirm && pendingImport != null) { - AlertDialog( - onDismissRequest = { + ImportConfirmDialog( + cfg = pendingImport!!, + onConfirm = { + onImport(pendingImport!!) + clipboard.setText(AnnotatedString("")) showImportConfirm = false pendingImport = null + scope.launch { onSnackbar(ctx.getString(R.string.snack_config_imported)) } }, - title = { Text(stringResource(R.string.dialog_import_title)) }, - text = { Text(stringResource(R.string.dialog_import_body)) }, - confirmButton = { - TextButton(onClick = { - pendingImport?.let { onImport(it) } - clipboard.setText(AnnotatedString("")) - showImportConfirm = false - pendingImport = null - scope.launch { onSnackbar(ctx.getString(R.string.snack_config_imported)) } - }) { Text("Import") } - }, - dismissButton = { - TextButton(onClick = { - showImportConfirm = false - pendingImport = null - }) { Text(stringResource(R.string.btn_cancel)) } + onDismiss = { + showImportConfirm = false + pendingImport = null }, ) } } +// ========================================================================= +// Import confirmation dialog — shared by clipboard, QR scan, and deep link. +// Shows deployment IDs, mode, and a trust warning before overwriting config. +// ========================================================================= + +@Composable +private fun ImportConfirmDialog( + cfg: MhrvConfig, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val ids = cfg.appsScriptUrls.mapNotNull { url -> + val marker = "/macros/s/" + val i = url.indexOf(marker) + val raw = if (i >= 0) url.substring(i + marker.length).substringBefore("/") else url + raw.trim().takeIf { it.isNotEmpty() } + } + val preview = ids.take(3).joinToString("\n") { " ${it.take(20)}…" } + val modeLabel = when (cfg.mode) { + com.therealaleph.mhrv.Mode.APPS_SCRIPT -> "apps_script" + com.therealaleph.mhrv.Mode.GOOGLE_ONLY -> "google_only" + com.therealaleph.mhrv.Mode.FULL -> "full" + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_import_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Importing routes your traffic through the deployment IDs in this config. Only import from trusted sources.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Text( + "Mode: $modeLabel\nDeployments: ${ids.size}\n$preview", + style = MaterialTheme.typography.bodySmall, + ) + Text( + stringResource(R.string.dialog_import_body), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text("Import") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + }, + ) +} + // ========================================================================= // QR code generation // ========================================================================= From 9659c76b0ca8bbf233b31c1e7c7975419b2b3a01 Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:19:03 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20enable=20udpgw=20via=20tun2proxy=20?= =?UTF-8?q?CLI=20API=20=E2=80=94=20no=20fork=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses tun2proxy_run_with_cli_args (the C API) via dlsym instead of modifying the JNI run() signature. The upstream tun2proxy maintainer recommended this path — the CLI API accepts --udpgw-server natively and offers more flexibility than the JNI entry point. Changes: - Cargo.toml: enable udpgw feature on tun2proxy, remove [patch.crates-io] - MhrvVpnService.kt: build CLI args string with --udpgw-server in full mode - Native.kt + android_jni.rs: Native.runTun2proxy() resolves tun2proxy_run_with_cli_args at runtime via dlsym from libtun2proxy.so - Tun2proxy.kt: reverted to upstream signature (no extra parameter) No fork, no patch, no submodule. Upstream tun2proxy 0.7 with features = ["udpgw"] is all that's needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 3 +- Cargo.toml | 6 --- .../com/github/shadowsocks/bg/Tun2proxy.kt | 1 - .../com/therealaleph/mhrv/MhrvVpnService.kt | 25 +++++----- .../main/java/com/therealaleph/mhrv/Native.kt | 10 ++++ src/android_jni.rs | 50 +++++++++++++++++++ 6 files changed, 74 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b21695..fc42871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3961,7 +3961,8 @@ dependencies = [ [[package]] name = "tun2proxy" version = "0.7.21" -source = "git+https://github.com/yyoyoian-pixel/tun2proxy?branch=feat%2Fudpgw-jni-param#dfc24ed12cdee69987bdd321ea55c6b940f2d0f0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336ad07beb04a9e219972fcdc54a71d2586cdfd35ac03551a629e4ca328db3c" dependencies = [ "android_logger", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 2ef3b1f..003d362 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,12 +100,6 @@ tun2proxy = { version = "0.7", default-features = false, features = ["udpgw"] } # Used in mitm tests to sanity-check the cert extensions we emit. x509-parser = "0.16" -# Temporary patch: adds udpgw_server parameter to the Android JNI run() -# function. Upstream PR: https://github.com/tun2proxy/tun2proxy/pull/247 -# Remove this section once tun2proxy >= 0.8 ships with the change. -[patch.crates-io] -tun2proxy = { git = "https://github.com/yyoyoian-pixel/tun2proxy", branch = "feat/udpgw-jni-param" } - [profile.release] panic = "abort" codegen-units = 1 diff --git a/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt b/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt index 4b1e3bf..03953be 100644 --- a/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt +++ b/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt @@ -59,7 +59,6 @@ object Tun2proxy { tunMtu: Char, verbosity: Int, dnsStrategy: Int, - udpgwServer: String, ): Int /** Signals the running `run()` to shut down. Idempotent. */ diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt index 69ceb74..58e3dbf 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt @@ -259,21 +259,20 @@ class MhrvVpnService : VpnService() { // the sole owner once it's running. val detachedFd = parcelFd.detachFd() tun2proxyRunning.set(true) - // In full mode, enable udpgw so UDP traffic (DNS, QUIC, …) is - // forwarded through the tunnel-node's native udpgw handler. - // 198.18.0.1:7300 is a magic address the tunnel-node intercepts. - val udpgwAddr = if (cfg.mode == Mode.FULL) "198.18.0.1:7300" else "" + // Use tun2proxy_run_with_cli_args C API via dlsym — gives full + // CLI flexibility including --udpgw-server, no fork needed. + val cliArgs = buildString { + append("tun2proxy") + append(" --proxy socks5://127.0.0.1:$socks5Port") + append(" --tun-fd $detachedFd") + append(" --dns virtual") + append(" --verbosity info") + append(" --close-fd-on-drop true") + if (cfg.mode == Mode.FULL) append(" --udpgw-server 198.18.0.1:7300") + } val worker = Thread({ try { - val rc = Tun2proxy.run( - "socks5://127.0.0.1:$socks5Port", - detachedFd, - /* closeFdOnDrop = */ true, - MTU.toChar(), - /* verbosity = info */ 3, - /* dnsStrategy = virtual */ 0, - udpgwAddr, - ) + val rc = Native.runTun2proxy(cliArgs, MTU) Log.i(TAG, "tun2proxy exited rc=$rc") } catch (t: Throwable) { Log.e(TAG, "tun2proxy crashed: ${t.message}", t) diff --git a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt index f016d87..517e46d 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt @@ -95,4 +95,14 @@ object Native { * Cheap — just reads atomics. Safe to poll on a second-scale timer. */ external fun statsJson(handle: Long): String + + /** + * Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`). + * Resolved at runtime via dlsym from libtun2proxy.so — no fork needed. + * + * @param cliArgs full CLI string, e.g. "tun2proxy --proxy socks5://... --tun-fd 42 --udpgw-server 198.18.0.1:7300" + * @param tunMtu TUN MTU (typically 1500) + * @return 0 on normal shutdown, negative on error. BLOCKS. + */ + external fun runTun2proxy(cliArgs: String, tunMtu: Int): Int } diff --git a/src/android_jni.rs b/src/android_jni.rs index 6f467be..6bb5a97 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -482,3 +482,53 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_statsJson<'a>( })); env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) } + +// --------------------------------------------------------------------------- +// tun2proxy CLI API wrapper (dlsym — no fork or patch needed) +// --------------------------------------------------------------------------- + +/// `Native.runTun2proxy(cliArgs, tunMtu)` -> int +/// +/// Calls `tun2proxy_run_with_cli_args` from libtun2proxy.so via dlsym. +/// This is the C API the tun2proxy maintainer recommends for callers that +/// need full CLI flexibility (e.g. --udpgw-server). BLOCKS until shutdown. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_runTun2proxy<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + cli_args: JString, + tun_mtu: jni::sys::jint, +) -> jni::sys::jint { + safe(-1, AssertUnwindSafe(|| { + let args_str = jstring_to_string(&mut env, &cli_args); + tracing::info!("runTun2proxy: cli={}", args_str); + + unsafe { + use std::ffi::{CStr, CString}; + + let lib = CString::new("libtun2proxy.so").unwrap(); + let handle = libc::dlopen(lib.as_ptr(), libc::RTLD_NOW); + if handle.is_null() { + let err = CStr::from_ptr(libc::dlerror()); + tracing::error!("dlopen libtun2proxy.so failed: {:?}", err); + return -10; + } + + let sym = CString::new("tun2proxy_run_with_cli_args").unwrap(); + let func = libc::dlsym(handle, sym.as_ptr()); + if func.is_null() { + let err = CStr::from_ptr(libc::dlerror()); + tracing::error!("dlsym tun2proxy_run_with_cli_args: {:?}", err); + libc::dlclose(handle); + return -11; + } + + type RunFn = unsafe extern "C" fn(*const std::ffi::c_char, u16, bool) -> i32; + let run: RunFn = std::mem::transmute(func); + let c_args = CString::new(args_str).unwrap(); + let rc = run(c_args.as_ptr(), tun_mtu as u16, false); + libc::dlclose(handle); + rc + } + })) +}