mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-19 08:04:39 +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.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
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-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,33 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</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
|
VpnService: Android captures all traffic at the IP layer and feeds
|
||||||
it to us via a TUN file descriptor. The android.net.VpnService action
|
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)
|
val f = File(ctx.filesDir, FILE)
|
||||||
if (!f.exists()) return MhrvConfig()
|
if (!f.exists()) return MhrvConfig()
|
||||||
return try {
|
return try {
|
||||||
val obj = JSONObject(f.readText())
|
loadFromJson(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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
MhrvConfig()
|
MhrvConfig()
|
||||||
}
|
}
|
||||||
@@ -292,6 +240,152 @@ object ConfigStore {
|
|||||||
val f = File(ctx.filesDir, FILE)
|
val f = File(ctx.filesDir, FILE)
|
||||||
f.writeText(cfg.toJson())
|
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 {
|
setContent {
|
||||||
MhrvTheme {
|
MhrvTheme {
|
||||||
AppRoot()
|
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
|
@Composable
|
||||||
private fun AppRoot() {
|
private fun AppRoot() {
|
||||||
// The system VpnService.prepare() returns an Intent if the user
|
// The system VpnService.prepare() returns an Intent if the user
|
||||||
@@ -237,5 +255,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val REQ_NOTIF = 42
|
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),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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")
|
SectionHeader("Mode")
|
||||||
ModeDropdown(
|
ModeDropdown(
|
||||||
mode = cfg.mode,
|
mode = cfg.mode,
|
||||||
|
|||||||
@@ -76,6 +76,21 @@
|
|||||||
<!-- Live logs -->
|
<!-- Live logs -->
|
||||||
<string name="logs_lines_count">%1$d lines</string>
|
<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 -->
|
<!-- Snackbar -->
|
||||||
<string name="snack_google_ip_updated">google_ip updated to %1$s</string>
|
<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>
|
<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