From 37c8992d21999b73d3b0a038b2d210eb1dfe8b32 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 20:52:42 +0100 Subject: [PATCH] feat(companion): native wallet QR scanner, gesture hint overlay, WebView file uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WalletQrScannerModal: native CameraX+zxing scanner styled like the web wallet modal, with an upload-image decode path; opened by the web UI through the new window.ArchipelagoQr JS bridge. Decodes stream back via window.__archyQrResult; the page mirrors status lines out and closes the modal when it accepts a code. Detection/spend logic stays in the web modal. - onShowFileChooser: was silently ignored by the WebView — file pickers (incl. the wallet's upload option) now work. - GestureHintOverlay: one-time animation teaching the three-finger hold, armed ~2 minutes after login (gesture_hint_seen flag). Co-Authored-By: Claude Fable 5 --- .../archipelago/app/data/ServerPreferences.kt | 12 + .../app/ui/components/GestureHintOverlay.kt | 147 +++++++++ .../app/ui/components/WalletQrScannerModal.kt | 294 ++++++++++++++++++ .../app/ui/screens/WebViewScreen.kt | 150 ++++++++- Android/app/src/main/res/values/strings.xml | 7 + 5 files changed, 599 insertions(+), 11 deletions(-) create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt index 909a653d..f4ff0c17 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -75,6 +75,7 @@ class ServerPreferences(private val context: Context) { private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip") private val savedServersKey = stringSetPreferencesKey("saved_servers") private val introSeenKey = booleanPreferencesKey("intro_seen") + private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen") val activeServer: Flow = context.dataStore.data.map { prefs -> val address = prefs[activeAddressKey] ?: return@map null @@ -97,6 +98,11 @@ class ServerPreferences(private val context: Context) { prefs[introSeenKey] ?: false } + /** One-shot flag for the three-finger-hold teaching overlay. */ + val gestureHintSeen: Flow = context.dataStore.data.map { prefs -> + prefs[gestureHintSeenKey] ?: false + } + suspend fun setActiveServer(server: ServerEntry) { context.dataStore.edit { prefs -> prefs[activeAddressKey] = server.address @@ -214,4 +220,10 @@ class ServerPreferences(private val context: Context) { prefs[introSeenKey] = true } } + + suspend fun markGestureHintSeen() { + context.dataStore.edit { prefs -> + prefs[gestureHintSeenKey] = true + } + } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt new file mode 100644 index 00000000..62390b1e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt @@ -0,0 +1,147 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.BitcoinOrange +import kotlinx.coroutines.delay + +/** + * First-launch teaching overlay for the three-finger hold gesture. Three + * fingertip dots pulse in a "press" rhythm with an expanding ring while a + * short caption explains what the gesture opens. Dismissed by tapping + * anywhere (or automatically after a few seconds) — shown once, ever. + */ +@Composable +fun GestureHintOverlay(onDismiss: () -> Unit) { + // Auto-dismiss so a user who taps nothing is never stuck behind the scrim. + LaunchedEffect(Unit) { + delay(6500) + onDismiss() + } + + val transition = rememberInfiniteTransition(label = "gesture-hint") + // Fingertips press down together… + val press by transition.animateFloat( + initialValue = 1f, + targetValue = 0.86f, + animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse), + label = "press", + ) + // …while a ring ripples outward on each press cycle. + val ripple by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)), + label = "ripple", + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Hand: three fingertip dots in a natural arc + ripple ring. + Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(150.dp) + .scale(0.4f + ripple * 0.6f) + .border( + 2.dp, + BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f), + CircleShape, + ), + ) + FingerDot(x = (-44).dp, y = 14.dp, scale = press) + FingerDot(x = 0.dp, y = (-12).dp, scale = press) + FingerDot(x = 44.dp, y = 8.dp, scale = press) + } + + Spacer(Modifier.height(28.dp)) + + Text( + text = stringResource(R.string.gesture_hint_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource(R.string.gesture_hint_body), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 48.dp), + ) + + Spacer(Modifier.height(32.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.12f)) + .clickable(onClick = onDismiss) + .padding(horizontal = 28.dp, vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.gesture_hint_got_it), + style = MaterialTheme.typography.labelLarge, + color = Color.White, + ) + } + } + } +} + +@Composable +private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) { + Box( + Modifier + .offset(x = x, y = y) + .size(26.dp) + .scale(scale) + .background(Color.White.copy(alpha = 0.92f), CircleShape), + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt new file mode 100644 index 00000000..98c77c65 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt @@ -0,0 +1,294 @@ +package com.archipelago.app.ui.components + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.archipelago.app.R +import com.archipelago.app.ui.screens.GlassButton +import com.archipelago.app.ui.theme.BitcoinOrange +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer + +/** + * Native replacement for the web wallet's scan pane — same visual design as + * neode-ui's WalletScanModal (dark glass card, square preview, orange + * viewfinder, status strip) but the camera and decoding run natively, so the + * preview doesn't lag the way getUserMedia does inside a WebView. + * + * Decoded text is handed back to the page ([onDecoded]) which does all the + * detection/spend logic; the page in turn streams status lines (animated-QR + * progress, "not recognised" errors) back in via [status] and closes the + * modal through the JS bridge once it accepts a code. + */ +@Composable +fun WalletQrScannerModal( + visible: Boolean, + status: Pair?, // message from the web page + isError + onDecoded: (String) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + // Local error from a failed image upload; a fresh web status replaces it. + var uploadError by remember { mutableStateOf(null) } + val noQrMessage = stringResource(R.string.no_qr_in_image) + val imagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val decoded = decodeQrFromUri(context, uri) + if (decoded != null) { + uploadError = null + onDecoded(decoded) + } else { + uploadError = noQrMessage + } + } + } + + LaunchedEffect(visible) { + if (visible) { + uploadError = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + hasPermission = granted + if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + LaunchedEffect(status) { if (status != null) uploadError = null } + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + BackHandler { onDismiss() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(16.dp) + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF212151C)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, // swallow — only the scrim dismisses + ) + .padding(24.dp), + ) { + // Header — mirrors the web modal's title row + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.scan_to_send), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + stringResource(R.string.close), + tint = Color.White.copy(alpha = 0.7f), + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // Square camera preview with the orange viewfinder + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.4f)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + if (hasPermission) { + // Throttle repeat frames: a static QR decodes ~20x/s but + // the page only needs one; animated QRs still stream + // because each frame's text differs. + var lastText by remember { mutableStateOf("") } + var lastSentAt by remember { mutableStateOf(0L) } + CameraQrPreview(onDecoded = { text -> + val now = System.currentTimeMillis() + if (text != lastText || now - lastSentAt > 250) { + lastText = text + lastSentAt = now + onDecoded(text) + } + }) + Box( + Modifier + .fillMaxSize(0.62f) + .border( + 2.dp, + BitcoinOrange.copy(alpha = 0.85f), + RoundedCornerShape(16.dp), + ), + ) + } else { + Column( + Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.camera_permission_needed), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + GlassButton( + text = stringResource(R.string.grant_camera_access), + onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Status strip — same slot the web modal uses for hints/errors + val message = uploadError ?: status?.first + val isError = uploadError != null || status?.second == true + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(12.dp) + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.scan_wallet_hint), + style = MaterialTheme.typography.bodySmall, + color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(16.dp)) + + GlassButton( + text = stringResource(R.string.upload_qr_image), + onClick = { imagePicker.launch("image/*") }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } +} + +/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */ +private fun decodeQrFromUri(context: Context, uri: Uri): String? { + return try { + val resolver = context.contentResolver + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + var sample = 1 + val maxDim = maxOf(bounds.outWidth, bounds.outHeight) + while (maxDim / (sample * 2) >= 1600) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + ?: return null + val pixels = IntArray(bmp.width * bmp.height) + bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height) + val source = RGBLuminanceSource(bmp.width, bmp.height, pixels) + val reader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.TRY_HARDER to true, + ) + ) + } + try { + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text + } catch (_: NotFoundException) { + // Light-on-dark QRs (dark-themed wallets) decode inverted. + reader.reset() + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text + } + } catch (_: Exception) { + null + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index d75ffd6b..039e0be2 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -53,11 +53,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -68,13 +71,21 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import android.webkit.ValueCallback import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.ui.components.GestureHintOverlay +import com.archipelago.app.ui.components.WalletQrScannerModal import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.SurfaceBlack import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextPrimary import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONObject /** Open a URL in the phone's default browser (genuinely external links). */ private fun openExternalUrl(context: android.content.Context, url: String) { @@ -176,6 +187,39 @@ fun WebViewScreen( // while this is shown, so closing it returns instantly with no reload. var inAppUrl by remember { mutableStateOf(null) } + // Native wallet QR scanner, opened by the web UI via the ArchipelagoQr + // bridge; status lines stream back from the page while it's up. + var walletScannerVisible by remember { mutableStateOf(false) } + var walletScannerStatus by remember { mutableStateOf?>(null) } + + // One-time three-finger-hold teaching overlay (initial=true: never flash + // it while DataStore is still loading). + val prefs = remember { ServerPreferences(webViewContext) } + val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true) + var gestureHintDismissed by remember { mutableStateOf(false) } + // Don't teach the gesture on top of the login/splash — arm the overlay + // ~2 minutes after the kiosk first finishes loading, once the user has + // settled in. + var gestureHintReady by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { isLoading }.first { !it } + delay(120_000) + gestureHintReady = true + } + val scope = rememberCoroutineScope() + + // support — without a chooser implementation the + // WebView silently ignores file inputs (broke the wallet's upload path). + var pendingFileChooser by remember { mutableStateOf>?>(null) } + val fileChooserLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + pendingFileChooser?.onReceiveValue( + WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data), + ) + pendingFileChooser = null + } + BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) { webView?.goBack() } @@ -301,6 +345,35 @@ fun WebViewScreen( "ArchipelagoNative", ) + // Wallet QR bridge. The web scan modal calls: + // window.ArchipelagoQr.open() — show the native scanner + // window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines + // window.ArchipelagoQr.close() — code accepted, tear down + // Decodes flow back through window.__archyQrResult(text); + // a user cancel calls window.__archyQrCancelled(). + addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun open() { + webViewRef.post { + walletScannerStatus = null + walletScannerVisible = true + } + } + + @android.webkit.JavascriptInterface + fun setStatus(message: String, isError: Boolean) { + webViewRef.post { walletScannerStatus = message to isError } + } + + @android.webkit.JavascriptInterface + fun close() { + webViewRef.post { walletScannerVisible = false } + } + }, + "ArchipelagoQr", + ) + webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { isLoading = true @@ -412,6 +485,27 @@ fun WebViewScreen( loadProgress = newProgress } + override fun onShowFileChooser( + view: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean { + pendingFileChooser?.onReceiveValue(null) + pendingFileChooser = filePathCallback + val intent = fileChooserParams?.createIntent() + if (intent == null) { + pendingFileChooser = null + return false + } + return try { + fileChooserLauncher.launch(intent) + true + } catch (_: Exception) { + pendingFileChooser = null + false + } + } + // Wallet QR scanner: grant the page camera access. // Only video capture is granted — anything else the // page asks for is denied as before. @@ -467,22 +561,24 @@ fun WebViewScreen( } } - // Two-finger hold (500ms) → navigate to remote input - var twoFingerStart = 0L - var twoFingerFired = false + // Three-finger hold (500ms) → navigate to remote input. + // Three fingers, not two: two-finger scroll/pinch on the + // page collided with the old two-finger hold. + var threeFingerStart = 0L + var threeFingerFired = false setOnTouchListener { _, event -> val pointerCount = event.pointerCount when (event.actionMasked) { android.view.MotionEvent.ACTION_POINTER_DOWN -> { - if (pointerCount >= 2) { - twoFingerStart = System.currentTimeMillis() - twoFingerFired = false + if (pointerCount >= 3) { + threeFingerStart = System.currentTimeMillis() + threeFingerFired = false } } android.view.MotionEvent.ACTION_MOVE -> { - if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) { - if (System.currentTimeMillis() - twoFingerStart > 500) { - twoFingerFired = true + if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) { + if (System.currentTimeMillis() - threeFingerStart > 500) { + threeFingerFired = true onRemoteInput() } } @@ -490,8 +586,8 @@ fun WebViewScreen( android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_POINTER_UP, android.view.MotionEvent.ACTION_CANCEL -> { - if (event.pointerCount <= 2) { - twoFingerStart = 0L + if (event.pointerCount <= 3) { + threeFingerStart = 0L } } } @@ -527,6 +623,38 @@ fun WebViewScreen( onClose = { inAppUrl = null }, ) } + + // Native wallet QR scanner, opened by the page via ArchipelagoQr. + WalletQrScannerModal( + visible = walletScannerVisible, + status = walletScannerStatus, + onDecoded = { text -> + webView?.evaluateJavascript( + "window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})", + null, + ) + }, + onDismiss = { + walletScannerVisible = false + webView?.evaluateJavascript( + "window.__archyQrCancelled && window.__archyQrCancelled()", + null, + ) + }, + ) + + // First-launch teaching overlay for the three-finger hold — armed + // ~2 minutes after login so it never fights the splash/first look. + if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed && + !isLoading && inAppUrl == null + ) { + GestureHintOverlay( + onDismiss = { + gestureHintDismissed = true + scope.launch { prefs.markGestureHintSeen() } + }, + ) + } } } } diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index c422e135..40be6925 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -41,4 +41,11 @@ Edit Server Save Changes Cancel + Hold with three fingers + Anywhere in the app — opens the remote control and menu + Got it + Scan to send + Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code + Upload image + No QR code found in that image — try another, closer and well-lit