Skip to content

Commit 89e4c5f

Browse files
feat: better sharing
1 parent e99918a commit 89e4c5f

6 files changed

Lines changed: 250 additions & 16 deletions

File tree

android/app/src/main/AndroidManifest.xml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,26 @@
5353
<action android:name="android.intent.action.MAIN" />
5454
<category android:name="android.intent.category.LAUNCHER" />
5555
</intent-filter>
56-
<!-- Deep link: tapping mhrv://... in any app opens MainActivity
57-
and auto-imports the encoded config. -->
56+
<!-- Deep link: tapping mhrv-rs://... in any app opens
57+
MainActivity and auto-imports the encoded config. -->
5858
<intent-filter>
5959
<action android:name="android.intent.action.VIEW" />
6060
<category android:name="android.intent.category.DEFAULT" />
6161
<category android:name="android.intent.category.BROWSABLE" />
6262
<data android:scheme="mhrv-rs" />
6363
</intent-filter>
64+
<!-- Drive setup deep link: tapping mhrv-rs-setup://... in
65+
WhatsApp / Telegram / SMS opens the app and offers to
66+
import the bundled credentials + refresh token. Distinct
67+
scheme so the recipient is asked to confirm a different
68+
(credential-bearing) payload than the regular config
69+
share. -->
70+
<intent-filter>
71+
<action android:name="android.intent.action.VIEW" />
72+
<category android:name="android.intent.category.DEFAULT" />
73+
<category android:name="android.intent.category.BROWSABLE" />
74+
<data android:scheme="mhrv-rs-setup" />
75+
</intent-filter>
6476
</activity>
6577

6678
<!-- FileProvider for sharing QR code images via the share sheet. -->

android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,22 @@ class MainActivity : AppCompatActivity() {
9595
handleDeepLink(intent)
9696
}
9797

98-
/** Stash decoded config from deep link for the UI to confirm — never
99-
* auto-import. The composable reads this and shows a confirmation
100-
* dialog with the deployment IDs and a trust warning. */
98+
/** Stash decoded config / setup from deep link for the UI to
99+
* confirm — never auto-import. The composable reads these state
100+
* holders and shows a confirmation dialog before any disk write. */
101101
private fun handleDeepLink(intent: Intent?) {
102102
val data = intent?.data ?: return
103-
if (data.scheme != "mhrv-rs") return
104-
val cfg = ConfigStore.decode(data.toString()) ?: return
105-
pendingDeepLinkConfig.value = cfg
103+
when (data.scheme) {
104+
"mhrv-rs" -> {
105+
val cfg = ConfigStore.decode(data.toString()) ?: return
106+
pendingDeepLinkConfig.value = cfg
107+
}
108+
"mhrv-rs-setup" -> {
109+
val setup = ConfigStore.decodeDriveSetup(data.toString()) ?: return
110+
pendingDeepLinkSetup.value = setup
111+
}
112+
else -> {}
113+
}
106114
}
107115

108116

@@ -257,5 +265,9 @@ class MainActivity : AppCompatActivity() {
257265
private const val REQ_NOTIF = 42
258266
/** Deep link config waiting for user confirmation. Read by ConfigSharingBar. */
259267
val pendingDeepLinkConfig = mutableStateOf<MhrvConfig?>(null)
268+
/** Deep link Drive-setup payload waiting for user confirmation.
269+
* Read by ConfigSharingBar; consumed in HomeScreen which knows
270+
* how to wire it through ConfigStore.applyDriveSetup. */
271+
val pendingDeepLinkSetup = mutableStateOf<ConfigStore.DriveSetup?>(null)
260272
}
261273
}

android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,13 @@ import kotlinx.coroutines.launch
4343
fun ConfigSharingBar(
4444
cfg: MhrvConfig,
4545
onImport: (MhrvConfig) -> Unit,
46+
onImportDriveSetup: (ConfigStore.DriveSetup) -> Unit,
4647
onSnackbar: suspend (String) -> Unit,
4748
) {
48-
// Deep link import — requires confirmation before applying.
49+
// Deep link imports — both regular config and Drive-setup deep
50+
// links land here and get a confirmation dialog before any
51+
// mutation, identical to clipboard / QR-scan paths. Trust prompt
52+
// is the same for all three input vectors.
4953
val deepLinkCfg by com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig
5054
if (deepLinkCfg != null) {
5155
ImportConfirmDialog(
@@ -59,32 +63,96 @@ fun ConfigSharingBar(
5963
},
6064
)
6165
}
66+
val deepLinkSetup by com.therealaleph.mhrv.MainActivity.pendingDeepLinkSetup
67+
if (deepLinkSetup != null) {
68+
DriveSetupConfirmDialog(
69+
setup = deepLinkSetup!!,
70+
onConfirm = {
71+
onImportDriveSetup(deepLinkSetup!!)
72+
com.therealaleph.mhrv.MainActivity.pendingDeepLinkSetup.value = null
73+
},
74+
onDismiss = {
75+
com.therealaleph.mhrv.MainActivity.pendingDeepLinkSetup.value = null
76+
},
77+
)
78+
}
6279
val ctx = LocalContext.current
6380
val clipboard = LocalClipboardManager.current
6481
val scope = rememberCoroutineScope()
6582

6683
val clipText = clipboard.getText()?.text.orEmpty()
6784
val hasConfigInClipboard = clipText.isNotEmpty() && ConfigStore.looksLikeConfig(clipText)
85+
val hasDriveSetupInClipboard = clipText.isNotEmpty() && ConfigStore.looksLikeDriveSetup(clipText)
6886

6987
var showExportDialog by remember { mutableStateOf(false) }
7088
var showImportConfirm by remember { mutableStateOf(false) }
7189
var pendingImport by remember { mutableStateOf<MhrvConfig?>(null) }
90+
var pendingDriveSetup by remember { mutableStateOf<ConfigStore.DriveSetup?>(null) }
7291
var showQrDialog by remember { mutableStateOf(false) }
7392

7493
// QR scanner launcher — fires the ZXing embedded scanner activity.
94+
// Dispatches based on payload prefix: regular config vs Drive setup.
7595
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
7696
val scanned = result.contents ?: return@rememberLauncherForActivityResult
77-
val decoded = ConfigStore.decode(scanned)
78-
if (decoded != null) {
79-
pendingImport = decoded
80-
showImportConfirm = true
97+
if (ConfigStore.looksLikeDriveSetup(scanned)) {
98+
val setup = ConfigStore.decodeDriveSetup(scanned)
99+
if (setup != null) {
100+
pendingDriveSetup = setup
101+
} else {
102+
scope.launch { onSnackbar(ctx.getString(R.string.snack_drive_setup_invalid)) }
103+
}
81104
} else {
82-
scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) }
105+
val decoded = ConfigStore.decode(scanned)
106+
if (decoded != null) {
107+
pendingImport = decoded
108+
showImportConfirm = true
109+
} else {
110+
scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) }
111+
}
83112
}
84113
}
85114

86-
// --- Paste from clipboard banner ---
87-
if (hasConfigInClipboard) {
115+
// --- Paste from clipboard banner (regular config OR Drive setup) ---
116+
// Drive-setup blob takes precedence — it's a more specific format
117+
// and we want to surface it immediately when a fresh recipient
118+
// pastes a `mhrv-rs-setup://...` link from WhatsApp.
119+
if (hasDriveSetupInClipboard) {
120+
Card(
121+
modifier = Modifier.fillMaxWidth(),
122+
colors = CardDefaults.cardColors(
123+
containerColor = MaterialTheme.colorScheme.primaryContainer,
124+
),
125+
) {
126+
Row(
127+
modifier = Modifier
128+
.fillMaxWidth()
129+
.padding(horizontal = 12.dp, vertical = 8.dp),
130+
verticalAlignment = Alignment.CenterVertically,
131+
horizontalArrangement = Arrangement.SpaceBetween,
132+
) {
133+
Text(
134+
stringResource(R.string.banner_drive_setup_clipboard),
135+
style = MaterialTheme.typography.labelMedium,
136+
color = MaterialTheme.colorScheme.onPrimaryContainer,
137+
modifier = Modifier.weight(1f),
138+
)
139+
FilledTonalButton(
140+
onClick = {
141+
val setup = ConfigStore.decodeDriveSetup(clipText)
142+
if (setup != null) {
143+
pendingDriveSetup = setup
144+
} else {
145+
scope.launch { onSnackbar(ctx.getString(R.string.snack_drive_setup_invalid)) }
146+
}
147+
},
148+
) {
149+
Icon(Icons.Default.ContentPaste, null, modifier = Modifier.size(18.dp))
150+
Spacer(Modifier.width(4.dp))
151+
Text(stringResource(R.string.btn_import_clipboard))
152+
}
153+
}
154+
}
155+
} else if (hasConfigInClipboard) {
88156
Card(
89157
modifier = Modifier.fillMaxWidth(),
90158
colors = CardDefaults.cardColors(
@@ -272,6 +340,63 @@ fun ConfigSharingBar(
272340
},
273341
)
274342
}
343+
344+
// --- Drive setup confirmation dialog (clipboard / QR / deep link) ---
345+
pendingDriveSetup?.let { setup ->
346+
DriveSetupConfirmDialog(
347+
setup = setup,
348+
onConfirm = {
349+
onImportDriveSetup(setup)
350+
clipboard.setText(AnnotatedString(""))
351+
pendingDriveSetup = null
352+
scope.launch { onSnackbar(ctx.getString(R.string.snack_drive_setup_imported)) }
353+
},
354+
onDismiss = { pendingDriveSetup = null },
355+
)
356+
}
357+
}
358+
359+
/**
360+
* Trust prompt before applying an `mhrv-rs-setup://...` payload. Same
361+
* shape as the regular import confirm dialog, with copy that calls out
362+
* the credential-bearing nature of the bundle so the user understands
363+
* what they're accepting.
364+
*/
365+
@Composable
366+
internal fun DriveSetupConfirmDialog(
367+
setup: ConfigStore.DriveSetup,
368+
onConfirm: () -> Unit,
369+
onDismiss: () -> Unit,
370+
) {
371+
AlertDialog(
372+
onDismissRequest = onDismiss,
373+
title = { Text(stringResource(R.string.dialog_drive_setup_import_title)) },
374+
text = {
375+
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
376+
Text(
377+
stringResource(R.string.dialog_drive_setup_import_warning),
378+
style = MaterialTheme.typography.bodySmall,
379+
color = MaterialTheme.colorScheme.error,
380+
)
381+
Text(
382+
stringResource(
383+
R.string.dialog_drive_setup_import_summary,
384+
setup.folderId.ifEmpty { "(auto)" },
385+
setup.folderName,
386+
),
387+
style = MaterialTheme.typography.bodySmall,
388+
)
389+
}
390+
},
391+
confirmButton = {
392+
TextButton(onClick = onConfirm) {
393+
Text(stringResource(R.string.btn_drive_setup_import))
394+
}
395+
},
396+
dismissButton = {
397+
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
398+
},
399+
)
275400
}
276401

277402
// =========================================================================

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,13 @@ fun HomeScreen(
261261
ConfigSharingBar(
262262
cfg = cfg,
263263
onImport = { persist(it) },
264+
onImportDriveSetup = { setup ->
265+
val applied = ConfigStore.applyDriveSetup(ctx, cfg, setup)
266+
if (applied != null) persist(applied)
267+
else scope.launch {
268+
snackbar.showSnackbar(ctx.getString(R.string.snack_drive_setup_failed))
269+
}
270+
},
264271
onSnackbar = { snackbar.showSnackbar(it) },
265272
)
266273

@@ -1256,6 +1263,30 @@ private fun DriveSection(
12561263
scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_invalid)) }
12571264
}
12581265
}
1266+
// Gallery image picker → decode any QR payload found in the
1267+
// chosen image. Cheap fallback for the WhatsApp-receives-image
1268+
// case: long-press the QR in chat → save to gallery → tap this
1269+
// button → pick the saved image. No need to point one phone at
1270+
// another's screen.
1271+
val setupImagePicker = rememberLauncherForActivityResult(
1272+
ActivityResultContracts.GetContent(),
1273+
) { uri ->
1274+
if (uri == null) return@rememberLauncherForActivityResult
1275+
val decoded = decodeQrFromImage(ctx, uri)
1276+
when {
1277+
decoded == null -> scope.launch {
1278+
onSnack(ctx.getString(R.string.snack_drive_setup_no_qr_in_image))
1279+
}
1280+
else -> {
1281+
val setup = ConfigStore.decodeDriveSetup(decoded)
1282+
if (setup != null) {
1283+
setupScanResult = setup
1284+
} else {
1285+
scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_invalid)) }
1286+
}
1287+
}
1288+
}
1289+
}
12591290

12601291
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
12611292
Text(
@@ -1296,6 +1327,15 @@ private fun DriveSection(
12961327
Spacer(Modifier.width(8.dp))
12971328
Text(stringResource(R.string.btn_drive_scan_setup))
12981329
}
1330+
// Gallery picker — for the case where someone messaged a
1331+
// QR image instead of letting it be scanned camera-to-camera.
1332+
// Long-press in WhatsApp → Save → tap this → pick the file.
1333+
OutlinedButton(
1334+
onClick = { setupImagePicker.launch("image/*") },
1335+
modifier = Modifier.fillMaxWidth(),
1336+
) {
1337+
Text(stringResource(R.string.btn_drive_setup_from_image))
1338+
}
12991339
Text(
13001340
stringResource(R.string.help_drive_scan_setup),
13011341
style = MaterialTheme.typography.labelSmall,
@@ -1635,6 +1675,45 @@ private fun DriveSetupImportConfirmDialog(
16351675
)
16361676
}
16371677

1678+
/**
1679+
* Decode a QR code embedded in a static image (gallery-picked content
1680+
* URI). Returns the decoded text payload or null if no QR was found,
1681+
* the image couldn't be loaded, or zxing failed to parse.
1682+
*
1683+
* Uses `BinaryBitmap(HybridBinarizer)` over the bitmap pixels because
1684+
* that's what zxing's reference Android samples do — handles screenshots
1685+
* with anti-aliasing, JPEG artefacts, etc. better than the global
1686+
* binarizer. Tries inverted colours as a fallback so dark-mode QRs
1687+
* (white on black) still decode.
1688+
*/
1689+
private fun decodeQrFromImage(
1690+
ctx: android.content.Context,
1691+
uri: android.net.Uri,
1692+
): String? {
1693+
val bitmap = runCatching {
1694+
ctx.contentResolver.openInputStream(uri)?.use {
1695+
android.graphics.BitmapFactory.decodeStream(it)
1696+
}
1697+
}.getOrNull() ?: return null
1698+
1699+
val width = bitmap.width
1700+
val height = bitmap.height
1701+
val pixels = IntArray(width * height)
1702+
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
1703+
1704+
val source = com.google.zxing.RGBLuminanceSource(width, height, pixels)
1705+
val reader = com.google.zxing.qrcode.QRCodeReader()
1706+
1707+
fun tryDecode(src: com.google.zxing.LuminanceSource): String? = runCatching {
1708+
val binary = com.google.zxing.BinaryBitmap(
1709+
com.google.zxing.common.HybridBinarizer(src),
1710+
)
1711+
reader.decode(binary).text
1712+
}.getOrNull()
1713+
1714+
return tryDecode(source) ?: tryDecode(source.invert())
1715+
}
1716+
16381717
/** Same QR generator the regular config-share dialog uses, copied here
16391718
* so DriveSection isn't transitively depending on ConfigSharing.kt's
16401719
* private helper. Returns null when the payload is too large for a

android/app/src/main/res/values-fa/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@
141141
<string name="snack_drive_setup_failed">وارد کردن تنظیمات Drive ناموفق بود (نوشتن فایل‌ها ممکن نشد)</string>
142142
<string name="snack_drive_setup_invalid">QR معتبر تنظیمات Drive نیست</string>
143143
<string name="snack_drive_setup_copied">بستهٔ تنظیمات در کلیپ‌بورد کپی شد</string>
144+
<string name="banner_drive_setup_clipboard">تنظیمات Drive در کلیپ‌بورد یافت شد</string>
145+
<string name="btn_drive_setup_from_image">انتخاب تصویر QR از گالری</string>
146+
<string name="snack_drive_setup_no_qr_in_image">هیچ QR در آن تصویر یافت نشد</string>
144147

145148
<!-- "How to use" guide body. Localized — EN copy lives in values. -->
146149
<string name="help_how_to_use">۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جای‌گذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره می‌شود و برنامهٔ Settings باز می‌شود. داخل Settings از نوار جست‌وجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN &amp; app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید می‌خواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایم‌اوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve می‌شود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامه‌های دستگاه را خودکار از پروکسی رد می‌کند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمی‌دهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جای‌گذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص می‌کند کدام لایه مقصر است.\n\nمحدودیت شناخته‌شده — Cloudflare Turnstile («Verify you are human») روی اکثر سایت‌های پشت Cloudflare به‌طور بی‌پایان loop می‌زند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور می‌کند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش می‌خورد. این مسئله در این برنامه قابل‌حل نیست؛ ذات رلهٔ Apps Script است. سایت‌هایی که فقط بارگذاری اولیه را gate می‌کنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد.</string>

0 commit comments

Comments
 (0)