mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user