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/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/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/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/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 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/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/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), ) 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 diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 650588dc..6aa86204 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -18,7 +18,7 @@
-
+ -
+
+

+ {{ scanStatus }} +

+
+ + +
+ + +
+
+ + +
+
@@ -88,20 +127,17 @@ +
- - -

{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }} @@ -269,7 +305,23 @@ import { useBodyScrollLock } from '@/composables/useBodyScrollLock' type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint' type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join' -type Pane = 'scan' | 'amount' | 'success' +type Pane = 'choose' | 'scan' | 'amount' | 'success' + +// JS bridge the Android companion app injects: when present, live scanning is +// delegated to a native camera modal (styled like this one) — the WebView's +// getUserMedia preview lags badly on phones. Decodes come back through the +// window.__archyQr* callbacks; we mirror status lines out to the native modal. +interface ArchipelagoQrBridge { + open(): void + setStatus(message: string, isError: boolean): void + close(): void +} +interface NativeWindow extends Window { + ArchipelagoQr?: ArchipelagoQrBridge + __archyQrResult?: (text: string) => void + __archyQrCancelled?: () => void +} +const nativeWin = window as NativeWindow const PRESETS = [21, 2100, 21000, 100000] @@ -281,9 +333,10 @@ useModalKeyboard(modalRef, computed(() => props.show), close) useBodyScrollLock(computed(() => props.show)) // --- Pane state --- -const pane = ref('scan') +const pane = ref('choose') const direction = ref<'forward' | 'back'>('forward') const paneTitle = computed(() => { + if (pane.value === 'choose') return 'Send' if (pane.value === 'scan') return 'Scan to send' if (pane.value === 'success') return 'Success' if (action.value === 'fedimint-join') return 'Join federation' @@ -297,17 +350,26 @@ function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') { } function goBack() { - if (pane.value === 'amount') { + if (pane.value === 'scan') { + stopScanning() + goTo('choose', 'back') + } else if (pane.value === 'amount') { error.value = '' - goTo('scan', 'back') - // Only relight the camera when that's what the user chose; otherwise - // they land back on the scan/upload chooser. - nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() }) + goTo('choose', 'back') } else if (pane.value === 'success') { close() } } +// --- Chooser --- +function chooseCamera() { + scanStatus.value = '' + scanStatusIsError.value = false + if (startNativeScan()) return + goTo('scan') + nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) +} + // --- Scanner --- const videoElement = ref(null) const isScanning = ref(false) @@ -315,16 +377,6 @@ const isScanning = ref(false) // open) — the fallback buttons stay hidden until it resolves, so they don't // flash for a second on every open. const autoStarting = ref(false) - -// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23): -// nothing starts until the user picks, and Back from a later pane returns to -// the live camera only if that's what they chose. -const scanChoice = ref<'unset' | 'camera'>('unset') - -function chooseCamera() { - scanChoice.value = 'camera' - void nextTick(() => startScanning()) -} const scanStatus = ref('') const scanStatusIsError = ref(false) const cameraError = ref(false) @@ -332,6 +384,27 @@ const pasteInput = ref('') const qrScanner = ref(null) const animatedDecoder = useAnimatedQRDecoder() +// --- Native scanner (companion app) --- +const nativeScanActive = ref(false) + +function startNativeScan(): boolean { + const bridge = nativeWin.ArchipelagoQr + if (!bridge) return false + nativeWin.__archyQrResult = (text: string) => handleScanned(text) + nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false } + nativeScanActive.value = true + bridge.open() + return true +} + +// Mirror scan status (animated-QR progress, "not recognised" errors) onto the +// native modal's status strip while it's up. +watch([scanStatus, scanStatusIsError], () => { + if (nativeScanActive.value) { + nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value) + } +}) + async function startScanning() { cameraError.value = false scanStatusIsError.value = false @@ -374,6 +447,10 @@ function stopScanning() { qrScanner.value?.destroy() qrScanner.value = null isScanning.value = false + if (nativeScanActive.value) { + nativeScanActive.value = false + nativeWin.ArchipelagoQr?.close() + } } function submitPaste() { @@ -387,16 +464,18 @@ function submitPaste() { // undefined), but opens the native camera in any browser, // PWA or WebView; the shot is decoded locally by qr-scanner's scanImage. const photoInput = ref(null) +// Chooser-pane picker: same decode path, but no capture attribute — the user +// picks an existing image (screenshot, saved QR) rather than taking a photo. +const uploadInput = ref(null) const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia) async function onPhotoPicked(e: Event) { const input = e.target as HTMLInputElement const file = input.files?.[0] if (!file) return - scanStatusIsError.value = false - scanStatus.value = 'Reading photo…' try { - handleScanned(await decodePhotoRobust(file)) + const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }) + handleScanned(result.data) } catch { scanStatusIsError.value = true scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit' @@ -405,28 +484,6 @@ async function onPhotoPicked(e: Event) { } } -/** Decode a QR photo with every engine we have. On the companion app the - * photo path IS the scan path (plain-http LAN = no secure context = no - * live camera), and Lightning invoices make DENSE codes that the wasm - * engine's single pass often misses (reported 2026-07-22: "camera not - * picking up the invoice"). Android WebView's native BarcodeDetector is - * far stronger on dense codes, so try it first; fall back to qr-scanner. */ -async function decodePhotoRobust(file: File): Promise { - try { - const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise> } }).BarcodeDetector - if (Detector) { - const bmp = await createImageBitmap(file) - const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp) - const hit = codes.find(c => c.rawValue) - if (hit) return hit.rawValue - } - } catch { - // Native detector unavailable/failed — wasm engine below. - } - const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }) - return result.data -} - // --- Detection (ported from k484's scanner) --- const rail = ref('lightning') const action = ref('pay-invoice') @@ -716,7 +773,7 @@ async function confirmSend() { function resetAll() { stopScanning() animatedDecoder.reset() - pane.value = 'scan' + pane.value = 'choose' direction.value = 'forward' scanStatus.value = '' scanStatusIsError.value = false @@ -736,11 +793,10 @@ function close() { emit('close') } +// Every open lands on the chooser — the camera never auto-starts. watch(() => props.show, (open) => { if (open) { resetAll() - // No auto-start: the chooser interstitial owns the first move every time. - scanChoice.value = 'unset' } else { stopScanning() }