feat(android): config import/export — clipboard, QR, deep link, share (#266)

* feat(android): config import/export via clipboard, QR code, deep link, and share sheet

- 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) <noreply@anthropic.com>

* fix: deep link requires confirmation, trust warning on import, mhrv-rs:// scheme

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) <noreply@anthropic.com>

---------

Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yyoyoian-pixel
2026-04-26 19:22:14 +02:00
committed by GitHub
parent e9ce03e697
commit 1c9d288962
8 changed files with 571 additions and 53 deletions
+4
View File
@@ -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")
}
+25
View File
@@ -53,8 +53,33 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link: tapping mhrv://... in any app opens MainActivity
and auto-imports the encoded config. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mhrv-rs" />
</intent-filter>
</activity>
<!-- FileProvider for sharing QR code images via the share sheet. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Force ZXing scanner to portrait (matches app orientation). -->
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
tools:replace="android:screenOrientation" />
<!--
VpnService: Android captures all traffic at the IP layer and feeds
it to us via a TUN file descriptor. The android.net.VpnService action
@@ -230,59 +230,7 @@ object ConfigStore {
val f = File(ctx.filesDir, FILE)
if (!f.exists()) return MhrvConfig()
return try {
val obj = JSONObject(f.readText())
val ids = obj.optJSONArray("script_ids")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
// For display we turn each ID back into the full URL form —
// easier to paste-verify, and the Kotlin side doesn't depend
// on it (extractId re-parses on save).
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
listenHost = obj.optString("listen_host", "127.0.0.1"),
listenPort = obj.optInt("listen_port", 8080),
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
appsScriptUrls = urls,
authKey = obj.optString("auth_key", ""),
frontDomain = obj.optString("front_domain", "www.google.com"),
sniHosts = sni,
googleIp = obj.optString("google_ip", "142.251.36.68"),
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
"proxy_only" -> ConnectionMode.PROXY_ONLY
else -> ConnectionMode.VPN_TUN // default for unknown/missing
},
splitMode = when (obj.optString("split_mode", "all")) {
"only" -> SplitMode.ONLY
"except" -> SplitMode.EXCEPT
else -> SplitMode.ALL
},
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
uiLang = when (obj.optString("ui_lang", "auto")) {
"fa" -> UiLang.FA
"en" -> UiLang.EN
else -> UiLang.AUTO
},
)
loadFromJson(JSONObject(f.readText()))
} catch (_: Throwable) {
MhrvConfig()
}
@@ -292,6 +240,152 @@ object ConfigStore {
val f = File(ctx.filesDir, FILE)
f.writeText(cfg.toJson())
}
/** Prefix for encoded config strings so we can detect them in clipboard. */
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. */
fun encode(cfg: MhrvConfig): String {
val defaults = MhrvConfig()
val obj = JSONObject()
// Always include essential fields.
obj.put("mode", when (cfg.mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
Mode.FULL -> "full"
})
val ids = cfg.appsScriptUrls.mapNotNull { url ->
val marker = "/macros/s/"
val i = url.indexOf(marker)
if (i >= 0) {
var s = url.substring(i + marker.length)
val slash = s.indexOf('/'); if (slash >= 0) s = s.substring(0, slash)
s.trim().ifEmpty { null }
} else url.trim().ifEmpty { null }
}
if (ids.isNotEmpty()) obj.put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
if (cfg.authKey.isNotBlank()) obj.put("auth_key", cfg.authKey)
// Only include non-default values.
if (cfg.googleIp != defaults.googleIp) obj.put("google_ip", cfg.googleIp)
if (cfg.frontDomain != defaults.frontDomain) obj.put("front_domain", cfg.frontDomain)
if (cfg.sniHosts.isNotEmpty()) obj.put("sni_hosts", JSONArray().apply { cfg.sniHosts.forEach { put(it) } })
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
// Compress with DEFLATE then base64.
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
val compressed = java.io.ByteArrayOutputStream().also { bos ->
java.util.zip.DeflaterOutputStream(bos).use { it.write(jsonBytes) }
}.toByteArray()
val b64 = android.util.Base64.encodeToString(
compressed,
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
)
return "$HASH_PREFIX$b64"
}
/** Try DEFLATE inflate; fall back to treating bytes as raw UTF-8
* (for backward compat with uncompressed exports). */
private fun inflateOrRaw(raw: ByteArray): String {
return try {
java.util.zip.InflaterInputStream(raw.inputStream()).bufferedReader().readText()
} catch (_: Throwable) {
String(raw, Charsets.UTF_8)
}
}
/** Try to decode an encoded config string or raw JSON. Returns null on failure. */
fun decode(encoded: String): MhrvConfig? {
val trimmed = encoded.trim()
// Try raw JSON first.
if (trimmed.startsWith("{")) {
return try {
val obj = JSONObject(trimmed)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) null
else loadFromJson(obj)
} catch (_: Throwable) { null }
}
// Try mhrv:// base64 encoded (possibly DEFLATE-compressed).
val payload = if (trimmed.startsWith(HASH_PREFIX)) trimmed.removePrefix(HASH_PREFIX) else trimmed
return try {
val raw = android.util.Base64.decode(payload, android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE)
val text = inflateOrRaw(raw)
val obj = JSONObject(text)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) return null
loadFromJson(obj)
} catch (_: Throwable) {
null
}
}
/** Check if a string looks like an encoded mhrv config. */
fun looksLikeConfig(text: String): Boolean {
val t = text.trim()
if (t.startsWith(HASH_PREFIX)) return true
// Also accept raw JSON with a "mode" field.
if (t.startsWith("{")) {
return try { JSONObject(t).has("mode") } catch (_: Throwable) { false }
}
return false
}
/** Parse config from a JSON object — shared by load() and decode(). */
private 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()
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
return MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
listenHost = obj.optString("listen_host", "127.0.0.1"),
listenPort = obj.optInt("listen_port", 8080),
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
appsScriptUrls = urls,
authKey = obj.optString("auth_key", ""),
frontDomain = obj.optString("front_domain", "www.google.com"),
sniHosts = sni,
googleIp = obj.optString("google_ip", "142.251.36.68"),
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
"proxy_only" -> ConnectionMode.PROXY_ONLY
else -> ConnectionMode.VPN_TUN
},
splitMode = when (obj.optString("split_mode", "all")) {
"only" -> SplitMode.ONLY
"except" -> SplitMode.EXCEPT
else -> SplitMode.ALL
},
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
uiLang = when (obj.optString("ui_lang", "auto")) {
"fa" -> UiLang.FA
"en" -> UiLang.EN
else -> UiLang.AUTO
},
)
}
}
/**
@@ -81,6 +81,8 @@ class MainActivity : AppCompatActivity() {
}
}
handleDeepLink(intent)
setContent {
MhrvTheme {
AppRoot()
@@ -88,6 +90,22 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
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-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
@@ -237,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<MhrvConfig?>(null)
}
}
@@ -0,0 +1,349 @@
package com.therealaleph.mhrv.ui
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Color
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.therealaleph.mhrv.ConfigStore
import com.therealaleph.mhrv.MhrvConfig
import androidx.compose.foundation.text.selection.SelectionContainer
import com.therealaleph.mhrv.R
import kotlinx.coroutines.launch
// =========================================================================
// Import/Export bar — shown at the top of the config screen.
// =========================================================================
@Composable
fun ConfigSharingBar(
cfg: MhrvConfig,
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()
val clipText = clipboard.getText()?.text.orEmpty()
val hasConfigInClipboard = clipText.isNotEmpty() && ConfigStore.looksLikeConfig(clipText)
var showExportDialog by remember { mutableStateOf(false) }
var showImportConfirm by remember { mutableStateOf(false) }
var pendingImport by remember { mutableStateOf<MhrvConfig?>(null) }
var showQrDialog by remember { mutableStateOf(false) }
// QR scanner launcher — fires the ZXing embedded scanner activity.
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
val scanned = result.contents ?: return@rememberLauncherForActivityResult
val decoded = ConfigStore.decode(scanned)
if (decoded != null) {
pendingImport = decoded
showImportConfirm = true
} else {
scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) }
}
}
// --- Paste from clipboard banner ---
if (hasConfigInClipboard) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Config detected in clipboard",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f),
)
FilledTonalButton(
onClick = {
val decoded = ConfigStore.decode(clipText)
if (decoded != null) {
pendingImport = decoded
showImportConfirm = true
} else {
scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) }
}
},
) {
Icon(Icons.Default.ContentPaste, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.btn_import_clipboard))
}
}
}
}
// --- Export + Scan row ---
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { showExportDialog = true },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.Share, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.btn_export_config))
}
OutlinedButton(
onClick = {
val opts = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("Scan mhrv config QR code")
setBeepEnabled(false)
setOrientationLocked(true)
}
scanLauncher.launch(opts)
},
) {
Icon(Icons.Default.QrCodeScanner, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.btn_scan_qr))
}
}
// --- Export dialog (QR + hash + copy in one) ---
if (showExportDialog) {
val encoded = remember(cfg) { ConfigStore.encode(cfg) }
val qrBitmap = remember(encoded) { generateQr(encoded, 512) }
Dialog(onDismissRequest = { showExportDialog = false }) {
Card(modifier = Modifier.padding(16.dp)) {
Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.dialog_export_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
stringResource(R.string.dialog_export_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
// QR code
if (qrBitmap != null) {
Image(
bitmap = qrBitmap.asImageBitmap(),
contentDescription = "QR code",
modifier = Modifier.size(260.dp),
)
} else {
Text(
"Config too large for QR code",
style = MaterialTheme.typography.bodySmall,
)
}
// Hash with copy button
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
SelectionContainer(modifier = Modifier.weight(1f)) {
Text(
encoded,
style = MaterialTheme.typography.bodySmall,
maxLines = 3,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
IconButton(onClick = {
clipboard.setText(AnnotatedString(encoded))
scope.launch { onSnackbar(ctx.getString(R.string.snack_config_copied)) }
}) {
Icon(
Icons.Default.ContentPaste,
contentDescription = stringResource(R.string.btn_copy),
modifier = Modifier.size(20.dp),
)
}
}
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
OutlinedButton(onClick = {
// Save QR bitmap to cache dir and share both image + text.
val intent = if (qrBitmap != null) {
val file = java.io.File(ctx.cacheDir, "mhrv-config-qr.png")
file.outputStream().use { qrBitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri = androidx.core.content.FileProvider.getUriForFile(
ctx, "${ctx.packageName}.fileprovider", file
)
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
putExtra(android.content.Intent.EXTRA_TEXT, encoded)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} else {
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_TEXT, encoded)
}
}
ctx.startActivity(android.content.Intent.createChooser(intent, "Share config"))
}) {
Icon(Icons.Default.Share, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Share")
}
TextButton(onClick = { showExportDialog = false }) {
Text("Close")
}
}
}
}
}
}
// --- Import confirmation dialog (clipboard + QR scan) ---
if (showImportConfirm && pendingImport != null) {
ImportConfirmDialog(
cfg = pendingImport!!,
onConfirm = {
onImport(pendingImport!!)
clipboard.setText(AnnotatedString(""))
showImportConfirm = false
pendingImport = null
scope.launch { onSnackbar(ctx.getString(R.string.snack_config_imported)) }
},
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
// =========================================================================
private fun generateQr(content: String, size: Int): Bitmap? {
return try {
val writer = QRCodeWriter()
val matrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
for (x in 0 until size) {
for (y in 0 until size) {
bitmap.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
bitmap
} catch (_: Throwable) {
null // Config too large for QR
}
}
@@ -250,6 +250,13 @@ fun HomeScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Config import/export bar — paste from clipboard + export + QR.
ConfigSharingBar(
cfg = cfg,
onImport = { persist(it) },
onSnackbar = { snackbar.showSnackbar(it) },
)
SectionHeader("Mode")
ModeDropdown(
mode = cfg.mode,
@@ -76,6 +76,21 @@
<!-- Live logs -->
<string name="logs_lines_count">%1$d lines</string>
<!-- Config import/export -->
<string name="btn_import_clipboard">Paste config from clipboard</string>
<string name="btn_export_config">Export config</string>
<string name="btn_export_qr">Show QR code</string>
<string name="btn_scan_qr">Scan QR code</string>
<string name="btn_copy_hash">Copy to clipboard</string>
<string name="snack_config_imported">Config imported</string>
<string name="snack_config_copied">Config copied to clipboard</string>
<string name="snack_invalid_config">Invalid config in clipboard</string>
<string name="dialog_export_title">Export config</string>
<string name="dialog_export_warning">This includes your auth_key. Only share with people you trust.</string>
<string name="dialog_import_title">Import config?</string>
<string name="dialog_import_body">This will replace your current settings.</string>
<string name="label_camera_permission">Camera permission needed to scan QR codes</string>
<!-- Snackbar -->
<string name="snack_google_ip_updated">google_ip updated to %1$s</string>
<string name="snack_google_ip_current">google_ip already current (%1$s)</string>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="shared" path="." />
</paths>