From 1df075f7b1a031bb0543decea2f7319ee1364c54 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 20:51:49 +0100 Subject: [PATCH 1/4] feat(companion): menu gesture is now a three-finger hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-finger hold collided with two-finger scrolling — any scroll longer than 500ms popped the NESMenu. Three fingers hold for the menu; two fingers scroll, on the trackpad, both controllers and the gamepad. Co-Authored-By: Claude Fable 5 --- .../app/ui/components/GamepadLayout.kt | 8 ++-- .../app/ui/components/NESController.kt | 12 +++--- .../ui/components/NESPortraitController.kt | 4 +- .../archipelago/app/ui/components/Trackpad.kt | 37 ++++++++++++------- .../app/ui/screens/RemoteInputScreen.kt | 2 +- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt index f931ff21..c93991d6 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt @@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised @Composable fun GamepadLayout( onKey: (String) -> Unit, - onTwoFingerHold: () -> Unit, + onThreeFingerHold: () -> Unit, modifier: Modifier = Modifier, ) { val surface = Neo.surface() @@ -54,9 +54,9 @@ fun GamepadLayout( do { val ev = awaitPointerEvent() val a = ev.changes.filter { !it.changedToUp() } - if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() - if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() } - if (a.size < 2) t = 0L + if (a.size >= 3 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() } + if (a.size < 3) t = 0L } while (ev.changes.any { it.pressed }) } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt index 6d4cb2b3..724f7c3b 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -116,7 +116,7 @@ fun NESController( Box( modifier = modifier .fillMaxSize() - .twoFingerHold(onMenu) + .threeFingerHold(onMenu) .padding(horizontal = 40.dp, vertical = 24.dp), contentAlignment = Alignment.Center, ) { @@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) { } } -/** Two-finger hold gesture modifier */ -fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) { +/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */ +fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false) var t = 0L; var fired = false do { val ev = awaitPointerEvent() val a = ev.changes.filter { !it.changedToUp() } - if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() - if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } - if (a.size < 2) t = 0L + if (a.size >= 3 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } + if (a.size < 3) t = 0L } while (ev.changes.any { it.pressed }) } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt index 59fac842..0b93e684 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -50,7 +50,7 @@ fun NESPortraitController( Box( Modifier .fillMaxSize() - .twoFingerHold(onMenu) + .threeFingerHold(onMenu) .padding(horizontal = 40.dp, vertical = 24.dp), contentAlignment = Alignment.Center, ) { @@ -87,7 +87,7 @@ fun NESPortraitController( onMove = { dx, dy -> onMouseMove(dx, dy) }, onClick = { onMouseClick(it) }, onScroll = { dy -> onMouseScroll(dy) }, - onTwoFingerHold = onMenu, + onThreeFingerHold = onMenu, modifier = Modifier .fillMaxWidth() .weight(1f), diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt index 27b27ec7..bb02faa7 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt @@ -32,7 +32,7 @@ fun Trackpad( onMove: (dx: Int, dy: Int) -> Unit, onClick: (button: Int) -> Unit, onScroll: (dy: Int) -> Unit, - onTwoFingerHold: () -> Unit, + onThreeFingerHold: () -> Unit, modifier: Modifier = Modifier, ) { var fingers by remember { mutableIntStateOf(0) } @@ -53,7 +53,7 @@ fun Trackpad( val t0 = System.currentTimeMillis() var maxPtrs = 1 var holdFired = false - var twoStart = 0L + var threeStart = 0L var scrollAcc = 0f fingers = 1 @@ -64,19 +64,24 @@ fun Trackpad( fingers = active.size when { - active.size >= 2 -> { - if (twoStart == 0L) twoStart = System.currentTimeMillis() - if (!holdFired && System.currentTimeMillis() - twoStart > 500) { + // Three fingers = hold for menu; two = scroll. Kept + // on separate counts so a long two-finger scroll can + // never fire the menu mid-gesture. + active.size >= 3 -> { + if (threeStart == 0L) threeStart = System.currentTimeMillis() + if (!holdFired && System.currentTimeMillis() - threeStart > 500) { holdFired = true - onTwoFingerHold() + onThreeFingerHold() } - if (!holdFired) { - val dy = active.map { it.positionChange().y }.average().toFloat() - scrollAcc += dy - if (kotlin.math.abs(scrollAcc) > 12f) { - onScroll(if (scrollAcc > 0) 1 else -1) - scrollAcc = 0f - } + ev.changes.forEach { it.consume() } + } + active.size == 2 -> { + threeStart = 0L + val dy = active.map { it.positionChange().y }.average().toFloat() + scrollAcc += dy + if (kotlin.math.abs(scrollAcc) > 12f) { + onScroll(if (scrollAcc > 0) 1 else -1) + scrollAcc = 0f } ev.changes.forEach { it.consume() } } @@ -99,7 +104,11 @@ fun Trackpad( contentAlignment = Alignment.Center, ) { Text( - text = if (fingers >= 2) "hold for menu" else "", + text = when { + fingers >= 3 -> "hold for menu" + fingers == 2 -> "scroll" + else -> "" + }, style = MaterialTheme.typography.labelSmall, color = muted.copy(alpha = 0.4f), ) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index 646d509d..f38cc485 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -172,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) { onMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, onClick = { ws.sendClick(it) }, onScroll = { ws.sendScroll(it) }, - onTwoFingerHold = { showModal = true }, + onThreeFingerHold = { showModal = true }, modifier = Modifier.fillMaxWidth().weight(1f) .padding(horizontal = 16.dp, vertical = 8.dp), ) From 15954aec1442d5958d9d91897753296cb9b4eece Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 20:51:57 +0100 Subject: [PATCH 2/4] fix(companion): smooth camera preview + no flash on scanner open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZXing TRY_HARDER (plus the inverted retry) ran on every camera frame, pegging a core — that CPU contention is what made the preview stutter. Decode attempts are now gated to ~7/s; KEEP_ONLY_LATEST drops the rest. PreviewView switches to TextureView (COMPATIBLE): the SurfaceView default punches a window hole that black-flashes inside Compose fades and ignores rounded-corner clipping. CameraQrPreview is now shared with the wallet scan modal. Co-Authored-By: Claude Fable 5 --- .../app/ui/components/QrScannerOverlay.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt index 644524ed..e6d531e3 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt @@ -217,13 +217,20 @@ fun QrScannerOverlay( } } +/** Shared by the pairing scanner and the wallet scan modal. */ @Composable -private fun CameraQrPreview(onDecoded: (String) -> Unit) { +internal fun CameraQrPreview(onDecoded: (String) -> Unit) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val currentOnDecoded by rememberUpdatedState(onDecoded) val previewView = remember { - PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + // TextureView, not the SurfaceView default: SurfaceView punches a + // hole in the window, which black-flashes inside Compose fades and + // ignores rounded-corner clipping (wallet modal). + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } } DisposableEffect(Unit) { @@ -282,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna ) } + private var lastAttempt = 0L + override fun analyze(image: ImageProxy) { + // Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted + // retry) pegs a core when run at camera rate, and that CPU contention + // is what made the preview itself stutter. KEEP_ONLY_LATEST means the + // frames skipped here are simply dropped, so decodes stay current. + val now = System.currentTimeMillis() + if (now - lastAttempt < 140) { + image.close() + return + } + lastAttempt = now try { val plane = image.planes[0] val buffer = plane.buffer From 87c324e36c331e2129ba87062990d0051da1da88 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 20:52:42 +0100 Subject: [PATCH 3/4] 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 From fbed32f95d4fda5ec12c48efd1b372d73a9f4bc9 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 21:00:28 +0100 Subject: [PATCH 4/4] feat(wallet): hand live scanning to the companion's native camera when present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan/upload chooser (already on main) keeps the first move; when the Android companion's window.ArchipelagoQr bridge exists, 'Scan with camera' opens the native CameraX modal instead of getUserMedia — which the companion's plain-http origin doesn't even have, so the button now shows there too. Decodes come back via window.__archyQrResult, status lines mirror out via setStatus, and stopScanning closes the native modal once a code is accepted. Co-Authored-By: Claude Fable 5 --- neode-ui/src/components/WalletScanModal.vue | 48 ++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 650588dc..01dc82a9 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -50,7 +50,9 @@

How do you want to read the QR?

-