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/2] 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/2] 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 // =========================================================================