From b9dc0fb1bdcea9fd9687e51d2e9bf478b5c27a2e Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 2 Aug 2026 13:46:24 +0100 Subject: [PATCH] =?UTF-8?q?feat(companion):=20native=20clipboard=20bridge,?= =?UTF-8?q?=20faster=20QR=20scanner,=20restart=20control=20=E2=80=94=200.5?= =?UTF-8?q?.27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clipboard: the node UI is plain HTTP, so the WebView withholds navigator.clipboard and the page's own polyfill can only fake a write — readText() resolved to '' and every Paste button in the wallet silently did nothing. New ArchipelagoClipboard JS bridge (copy/paste over the Android clipboard, answering through window.__archyClipboardResult) plus an injected shim that points navigator.clipboard at it, so existing pages get working copy AND paste with no web-side change. Registered on the kiosk WebView and the in-app browser (node apps are plain HTTP too). - QR scanner: two-tier decode — a cheap centre-crop pass (no TRY_HARDER) at ~18/s catches the well-framed case immediately, while the old full-frame TRY_HARDER + inverted pass still runs at ~5/s for dense, off-centre and light-on-dark codes. Adds camera/decoder prewarm (on first node page load and via ArchipelagoQr.prewarm()), a torch toggle in both scanners, tap-to-focus, a 1x/1.5x zoom hunt after 3s of no decode, and a success haptic. - Restart: the retained kiosk WebView is a process-scoped static and the FIPS service keeps the process alive after a recents swipe, so closing the app stopped restarting it. MainActivity releases it when the task is finishing, and the hub menu gains a Restart card (mesh service keeps running). Co-Authored-By: Claude Opus 5 (1M context) --- Android/app/build.gradle.kts | 4 +- .../java/com/archipelago/app/MainActivity.kt | 11 + .../archipelago/app/ui/components/NESMenu.kt | 12 + .../app/ui/components/QrScannerOverlay.kt | 219 +++++++++++++++--- .../app/ui/components/WalletQrScannerModal.kt | 55 ++++- .../app/ui/screens/WebViewScreen.kt | 178 +++++++++++++- Android/app/src/main/res/values/strings.xml | 2 + 7 files changed, 441 insertions(+), 40 deletions(-) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index b3d0756a..d7706d12 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 46 - versionName = "0.5.26" + versionCode = 47 + versionName = "0.5.27" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt index 953dce7f..7456061f 100644 --- a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.archipelago.app.ui.navigation.AppNavHost +import com.archipelago.app.ui.screens.releaseKioskWebView import com.archipelago.app.ui.theme.ArchipelagoTheme import kotlinx.coroutines.flow.MutableStateFlow @@ -45,4 +46,14 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) pendingPairUri.value = intent.dataString } + + override fun onDestroy() { + super.onDestroy() + // Swiped out of recents (or otherwise finished) — let go of the + // retained kiosk WebView so the next launch starts clean. Without + // this the FIPS service keeps the process (and the static WebView) + // alive, and "close the app" no longer restarted it. isFinishing + // keeps config changes (rotation) on the fast reattach path. + if (isFinishing) releaseKioskWebView() + } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index 7c9816e5..79331465 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.shape.RoundedCornerShape @@ -70,6 +71,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.archipelago.app.R import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.screens.restartCompanionApp import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.SurfaceDark import com.archipelago.app.ui.theme.TextMuted @@ -233,6 +235,16 @@ private fun MenuPanel( // Small version chip at the hub's foot — the one place a // connected user can always check what build they're on. val hubContext = LocalContext.current + + // Restart: the dashboard WebView is retained across + // remote ⇄ dashboard (that's the point), which also means a + // wedged page can't be cleared by leaving the screen. This + // throws the page away and relaunches the app clean — the mesh + // service keeps running. + HubCard(Icons.Default.RestartAlt, "Restart", "Reload the app from scratch") { + onDismiss() + restartCompanionApp(hubContext) + } val versionLabel = remember { runCatching { hubContext.packageManager 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 61a7940a..3fc88207 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 @@ -1,6 +1,7 @@ package com.archipelago.app.ui.components import android.Manifest +import android.content.Context import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -16,6 +17,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +34,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -47,7 +51,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -65,7 +72,6 @@ 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.PlanarYUVLuminanceSource import com.google.zxing.common.HybridBinarizer import kotlinx.coroutines.delay @@ -84,6 +90,7 @@ fun QrScannerOverlay( onServerScanned: (PairResult.Success) -> Unit, ) { val context = LocalContext.current + val haptics = LocalHapticFeedback.current var hasPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == @@ -92,6 +99,8 @@ fun QrScannerOverlay( } var hintRes by remember { mutableStateOf(null) } var handled by remember { mutableStateOf(false) } + var torchOn by remember { mutableStateOf(false) } + var hasTorch by remember { mutableStateOf(false) } val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() @@ -105,6 +114,8 @@ fun QrScannerOverlay( PackageManager.PERMISSION_GRANTED hasPermission = granted if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } else { + torchOn = false } } @@ -130,6 +141,10 @@ fun QrScannerOverlay( when (val result = ServerQrParser.parse(text)) { is PairResult.Success -> { handled = true + // Confirm the hit in the hand — the eye is + // still on the code, not on the screen. + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + torchOn = false onServerScanned(result) } is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr @@ -137,6 +152,8 @@ fun QrScannerOverlay( } } }, + torchOn = torchOn, + onTorchAvailable = { hasTorch = it }, ) // Aim frame Box( @@ -182,8 +199,21 @@ fun QrScannerOverlay( style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 12.dp), ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary) + Row(verticalAlignment = Alignment.CenterVertically) { + if (hasPermission && hasTorch) { + IconButton(onClick = { torchOn = !torchOn }) { + Icon( + if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff, + stringResource( + if (torchOn) R.string.torch_off else R.string.torch_on, + ), + tint = if (torchOn) BitcoinOrange else TextPrimary, + ) + } + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary) + } } } @@ -217,12 +247,56 @@ fun QrScannerOverlay( } } -/** Shared by the pairing scanner and the wallet scan modal. */ +/** + * Warm the CameraX provider and the ZXing decode path before the user ever + * asks for a scan, so opening the scanner doesn't pay provider init + class + * loading on the critical path. Does NOT open the camera: no permission is + * needed, no LED lights up, nothing is recorded — [ProcessCameraProvider] + * init is process-wide and cached, and the synthetic decode below just walks + * a blank 32x32 frame to class-load the binarizer/detector. + * + * Called once per process from the kiosk WebView (first page load) and by the + * page via `ArchipelagoQr.prewarm()`. + */ +internal fun prewarmQrScanner(context: Context) { + if (!qrPrewarmed.compareAndSet(false, true)) return + val app = context.applicationContext + runCatching { ProcessCameraProvider.getInstance(app) } + // Off the UI thread: the first decode attempt loads a dozen ZXing classes. + Executors.newSingleThreadExecutor().let { exec -> + exec.execute { + runCatching { + val blank = ByteArray(32 * 32) + val reader = MultiFormatReader().apply { + setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } + val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false) + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + } + } + exec.shutdown() + } +} + +private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false) + +/** + * Shared by the pairing scanner and the wallet scan modal. + * + * [torchOn] drives the flash; [onTorchAvailable] reports whether this camera + * has one at all (the caller only draws its toggle when it does). + */ @Composable -internal fun CameraQrPreview(onDecoded: (String) -> Unit) { +internal fun CameraQrPreview( + onDecoded: (String) -> Unit, + torchOn: Boolean = false, + onTorchAvailable: (Boolean) -> Unit = {}, +) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val currentOnDecoded by rememberUpdatedState(onDecoded) + val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable) + var camera by remember { mutableStateOf(null) } val previewView = remember { PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER @@ -232,6 +306,11 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } } + // Set by the analyzer on every decode; the zoom hunt below reads it to tell + // "nothing in view" from "reading fine, leave the camera alone". + val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) } + // A tap-to-focus wins over the periodic centre AF for a few seconds. + val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) } DisposableEffect(Unit) { val analysisExecutor = Executors.newSingleThreadExecutor() @@ -260,12 +339,20 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) { .also { it.setAnalyzer( analysisExecutor, - QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } }, + QrCodeAnalyzer { text -> + lastDecodeAt.set(System.currentTimeMillis()) + mainExecutor.execute { currentOnDecoded(text) } + }, ) } try { p.unbindAll() val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) + camera = cam + currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit()) + // Start the "nothing is decoding" clock at bind time, so the + // zoom hunt below waits for the user to aim before it fires. + lastDecodeAt.set(System.currentTimeMillis()) // Force a centre autofocus on a repeating tick. A hand-held QR is // a static scene, so continuous-AF often never retriggers and the // lens sits at its resting (far) focus — fatal for dense codes. @@ -276,8 +363,26 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) { point, androidx.camera.core.FocusMeteringAction.FLAG_AF, ).disableAutoCancel().build() + val maxZoom = cam.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f + var zoomedIn = false focusScheduler.scheduleWithFixedDelay({ - runCatching { cam.cameraControl.startFocusAndMetering(focusAction) } + val now = System.currentTimeMillis() + // Skip the centre refocus while a tap-to-focus is still fresh. + if (now - lastTapFocusAt.get() > 4000) { + runCatching { cam.cameraControl.startFocusAndMetering(focusAction) } + } + // Zoom hunt: a small or distant printed code can be under the + // decoder's module-size floor at 1x. After ~3s with nothing + // decoding, alternate 1x / 1.5x so both framings get a turn — + // sticking at 1.5x would push a close-held phone out of focus. + if (now - lastDecodeAt.get() > 3000 && maxZoom > 1f) { + zoomedIn = !zoomedIn + val ratio = if (zoomedIn) minOf(1.5f, maxZoom) else 1f + runCatching { cam.cameraControl.setZoomRatio(ratio) } + } else if (zoomedIn && now - lastDecodeAt.get() <= 3000) { + zoomedIn = false + runCatching { cam.cameraControl.setZoomRatio(1f) } + } }, 0, 2, java.util.concurrent.TimeUnit.SECONDS) } catch (_: Exception) { // Camera unavailable — the user can dismiss and enter details manually. @@ -286,17 +391,60 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) { onDispose { focusScheduler.shutdownNow() + runCatching { camera?.cameraControl?.enableTorch(false) } + camera = null provider?.unbindAll() analysisExecutor.shutdown() } } - AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) + // Torch follows the caller's state (and switches off when the view goes). + LaunchedEffect(camera, torchOn) { + runCatching { camera?.cameraControl?.enableTorch(torchOn) } + } + + AndroidView( + factory = { previewView }, + modifier = Modifier + .fillMaxSize() + // Tap-to-focus: the periodic centre AF assumes the code is centred; + // a tap lets the user point at one that isn't (or re-trigger AF the + // instant they've framed it, instead of waiting for the next tick). + .pointerInput(camera) { + detectTapGestures { offset -> + val cam = camera ?: return@detectTapGestures + val factory = previewView.meteringPointFactory + val action = androidx.camera.core.FocusMeteringAction.Builder( + factory.createPoint(offset.x, offset.y), + androidx.camera.core.FocusMeteringAction.FLAG_AF or + androidx.camera.core.FocusMeteringAction.FLAG_AE, + ).build() + lastTapFocusAt.set(System.currentTimeMillis()) + runCatching { cam.cameraControl.startFocusAndMetering(action) } + } + }, + ) } -/** ZXing-based QR decoder over the camera's Y (luminance) plane. */ +/** + * ZXing-based QR decoder over the camera's Y (luminance) plane, in two tiers. + * + * The old single pass was full-frame + TRY_HARDER + an inverted retry, which + * costs enough that it could only run ~7x/s without the CPU contention making + * the preview itself stutter — so a code that was already sharp and centred + * still waited up to 140ms to be seen. + * + * Now: a CHEAP pass (centre crop, no TRY_HARDER) runs at ~18x/s and catches + * the common case — a well-framed code — almost the moment it's readable. Only + * when that misses does the THOROUGH pass (full frame, TRY_HARDER, inverted + * retry) run, still at the old ~5x/s, so off-centre, dense, glare-hit and + * light-on-dark codes decode exactly as well as before. + */ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer { - private val reader = MultiFormatReader().apply { + private val fastReader = MultiFormatReader().apply { + setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } + private val hardReader = MultiFormatReader().apply { setHints( mapOf( DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), @@ -307,19 +455,16 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna ) } - private var lastAttempt = 0L + private var lastFastAt = 0L + private var lastHardAt = 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) { + if (now - lastFastAt < 55) { image.close() return } - lastAttempt = now + lastFastAt = now try { val plane = image.planes[0] val buffer = plane.buffer @@ -327,25 +472,43 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna // may be short of the full stride, so the tail stays zero-padded. val data = ByteArray(plane.rowStride * image.height) buffer.get(data, 0, minOf(buffer.remaining(), data.size)) - val source = PlanarYUVLuminanceSource( + + // Fast pass — the centre 70%, which is where the viewfinder tells + // the user to put the code. Half the pixels, no exhaustive search. + val cropW = (image.width * 0.7f).toInt().coerceAtLeast(1) + val cropH = (image.height * 0.7f).toInt().coerceAtLeast(1) + val cropped = PlanarYUVLuminanceSource( + data, plane.rowStride, image.height, + (image.width - cropW) / 2, (image.height - cropH) / 2, cropW, cropH, + false, + ) + runCatching { fastReader.decodeWithState(BinaryBitmap(HybridBinarizer(cropped))) } + .getOrNull() + ?.let { + onDecoded(it.text) + return + } + + if (now - lastHardAt < 180) return + lastHardAt = now + val full = PlanarYUVLuminanceSource( data, plane.rowStride, image.height, 0, 0, image.width, image.height, false, ) - val result = try { - reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) - } catch (_: NotFoundException) { + val result = runCatching { + hardReader.decodeWithState(BinaryBitmap(HybridBinarizer(full))) + }.getOrNull() ?: runCatching { // Dark-themed pages can render light-on-dark QRs — retry inverted. - reader.reset() - reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))) - } - onDecoded(result.text) - } catch (_: NotFoundException) { - // No QR in this frame — keep scanning. + hardReader.reset() + hardReader.decodeWithState(BinaryBitmap(HybridBinarizer(full.invert()))) + }.getOrNull() + if (result != null) onDecoded(result.text) } catch (_: Exception) { // Malformed frame; skip it. } finally { - reader.reset() + fastReader.reset() + hardReader.reset() image.close() } } 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 index 98c77c65..a4ce9eef 100644 --- 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 @@ -30,6 +30,8 @@ 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.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -44,7 +46,9 @@ 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -80,12 +84,15 @@ fun WalletQrScannerModal( onDismiss: () -> Unit, ) { val context = LocalContext.current + val haptics = LocalHapticFeedback.current var hasPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ) } + var torchOn by remember { mutableStateOf(false) } + var hasTorch by remember { mutableStateOf(false) } val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> hasPermission = granted } @@ -114,6 +121,8 @@ fun WalletQrScannerModal( PackageManager.PERMISSION_GRANTED hasPermission = granted if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } else { + torchOn = false } } LaunchedEffect(status) { if (status != null) uploadError = null } @@ -185,14 +194,24 @@ fun WalletQrScannerModal( // 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) - } - }) + CameraQrPreview( + onDecoded = { text -> + val now = System.currentTimeMillis() + if (text != lastText || now - lastSentAt > 250) { + // Buzz on the FIRST hit only: an animated QR + // streams a new frame every few ms, and one + // buzz each would be a drill in the hand. + if (lastText.isEmpty()) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + lastText = text + lastSentAt = now + onDecoded(text) + } + }, + torchOn = torchOn, + onTorchAvailable = { hasTorch = it }, + ) Box( Modifier .fillMaxSize(0.62f) @@ -202,6 +221,26 @@ fun WalletQrScannerModal( RoundedCornerShape(16.dp), ), ) + // Torch — the dim-room complaint. Sits in the preview's + // corner so it never covers the aim frame. + if (hasTorch) { + IconButton( + onClick = { torchOn = !torchOn }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .clip(RoundedCornerShape(50)) + .background(Color.Black.copy(alpha = 0.45f)), + ) { + Icon( + if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff, + stringResource( + if (torchOn) R.string.torch_off else R.string.torch_on, + ), + tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f), + ) + } + } } else { Column( Modifier.padding(horizontal = 24.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 a5ca350d..102ddf4e 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 @@ -102,6 +102,7 @@ import com.archipelago.app.ui.components.MeshLoadingScreen import com.archipelago.app.ui.components.NESMenu import com.archipelago.app.ui.components.QrScannerOverlay import com.archipelago.app.ui.components.WalletQrScannerModal +import com.archipelago.app.ui.components.prewarmQrScanner import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.ErrorRed import com.archipelago.app.ui.theme.SurfaceBlack @@ -130,6 +131,145 @@ private fun openExternalUrl(context: android.content.Context, url: String) { } catch (_: Exception) {} } +/** Drop the retained kiosk WebView (see [KioskWebView]). + * + * Retaining it is what keeps the dashboard alive across remote ⇄ dashboard — + * but it's a process-scoped static, and the FIPS mesh service keeps this + * process alive after the task is swiped out of recents. So a swipe-away + * followed by a relaunch used to reattach the OLD page instead of starting + * clean, i.e. "closing the app" stopped restarting it. MainActivity calls + * this when the task is genuinely finishing. */ +fun releaseKioskWebView() = KioskWebView.drop() + +/** Restart the app in place: throw away the retained page and relaunch the + * task from scratch. The mesh/VPN service is deliberately left running — this + * is the "give me a clean app" button (hub menu), not a process kill. */ +fun restartCompanionApp(context: android.content.Context) { + // Drop BEFORE relaunching: with singleTask, the new instance can compose + // before the old one is destroyed and would otherwise reattach the very + // WebView we're trying to discard. + KioskWebView.drop() + val intent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { + addFlags( + android.content.Intent.FLAG_ACTIVITY_NEW_TASK or + android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK, + ) + } ?: return + context.startActivity(intent) +} + +/** Copy/paste for pages running inside the app. + * + * The node UI is served over plain HTTP on the LAN/mesh, so it is NOT a secure + * context and the browser withholds `navigator.clipboard` entirely. The web + * UI's own fallback can only fake a WRITE (hidden textarea + execCommand) and + * its readText() resolves to an empty string — which is why every "Paste" + * button in the wallet was dead inside the app: it fired, got "", and silently + * did nothing. This bridge hands both directions to the Android clipboard; + * [CLIPBOARD_SHIM_JS] then points `navigator.clipboard` at it, so pages get + * working copy AND paste with no change on the web side. + * + * JS calls `ArchipelagoClipboard.copy(text)` / `.paste()`; a paste comes back + * through `window.__archyClipboardResult(text)` (same shape as the QR bridge). + */ +private fun WebView.addClipboardBridge() { + val appContext = context.applicationContext + val view = this + fun clipboard() = + appContext.getSystemService(android.content.Context.CLIPBOARD_SERVICE) + as? android.content.ClipboardManager + + addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun copy(text: String) { + // ClipboardManager is main-thread-only; bridge calls land on + // the JS thread. + view.post { + runCatching { + clipboard()?.setPrimaryClip( + android.content.ClipData.newPlainText("Archipelago", text), + ) + } + } + } + + @android.webkit.JavascriptInterface + fun paste() { + view.post { + val text = runCatching { + val clip = clipboard()?.primaryClip + if (clip == null || clip.itemCount == 0) { + "" + } else { + clip.getItemAt(0).coerceToText(appContext)?.toString() ?: "" + } + }.getOrDefault("") + view.evaluateJavascript( + "window.__archyClipboardResult && " + + "window.__archyClipboardResult(${JSONObject.quote(text)})", + null, + ) + } + } + }, + "ArchipelagoClipboard", + ) +} + +/** Point `navigator.clipboard` at the native bridge. Deliberately tolerant: + * the web UI defines its own `navigator.clipboard` stand-in at boot with + * Object.defineProperty (non-configurable), so redefining the property can + * throw — patching the two methods on whatever object is there works for the + * stand-in, for a real Clipboard instance, and for our own placeholder. */ +private const val CLIPBOARD_SHIM_JS = """ +(function() { + var bridge = window.ArchipelagoClipboard; + if (!bridge || window.__archyClipboardPatched) return; + window.__archyClipboardPatched = true; + + var waiting = []; + window.__archyClipboardResult = function(text) { + var queued = waiting; + waiting = []; + for (var i = 0; i < queued.length; i++) queued[i](text || ''); + }; + + function readText() { + return new Promise(function(resolve) { + var settled = false; + function finish(t) { if (!settled) { settled = true; resolve(t) } } + waiting.push(finish); + // The bridge always answers; the timeout is only so a page can + // never hang on a clipboard read. + setTimeout(function() { finish('') }, 1500); + try { bridge.paste() } catch (e) { finish('') } + }); + } + + function writeText(text) { + try { bridge.copy(String(text)); return Promise.resolve() } + catch (e) { return Promise.reject(e) } + } + + try { + if (!navigator.clipboard) { + Object.defineProperty(navigator, 'clipboard', { value: {}, configurable: true }); + } + } catch (e) { /* something already owns it — patch its methods below */ } + try { + navigator.clipboard.readText = readText; + navigator.clipboard.writeText = writeText; + } catch (e) { /* frozen: the page keeps its own (write-only) fallback */ } +})(); +""" + +private fun injectClipboardShim(view: WebView) { + view.evaluateJavascript(CLIPBOARD_SHIM_JS, null) +} + /** True when [url] points at the same host as the connected Archipelago node * (ignoring port). Such URLs are node apps — e.g. one that can't be iframed — * and should stay inside the app rather than bouncing out to the browser. */ @@ -768,10 +908,16 @@ fun WebViewScreen( "ArchipelagoNative", ) + // Clipboard bridge — the node UI is plain HTTP, so the + // page's own navigator.clipboard can't read (see + // addClipboardBridge). + if (reused == null) addClipboardBridge() + // 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 + // window.ArchipelagoQr.prewarm() — scan is likely; warm the camera // Decodes flow back through window.__archyQrResult(text); // a user cancel calls window.__archyQrCancelled(). if (reused == null) addJavascriptInterface( @@ -781,6 +927,16 @@ fun WebViewScreen( webViewRef.post { KioskWebView.onQrOpen() } } + /** Optional hint from the page (e.g. the wallet + * modal opening) that a scan is coming: pays + * CameraX/decoder init now instead of on the + * user's first frame. Safe to call repeatedly — + * it runs once per process. */ + @android.webkit.JavascriptInterface + fun prewarm() { + prewarmQrScanner(appContext) + } + @android.webkit.JavascriptInterface fun setStatus(message: String, isError: Boolean) { webViewRef.post { KioskWebView.onQrStatus(message, isError) } @@ -801,6 +957,9 @@ fun WebViewScreen( // New document — the injected safe-area style is // gone; reset the dedup stamp so it re-injects. view?.tag = null + // Before the app boots, so its own clipboard + // stand-in never takes the slot. + view?.let { injectClipboardShim(it) } } override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { @@ -813,6 +972,12 @@ fun WebViewScreen( if (view == null) return injectSafeAreaVars(view) + // Idempotent — covers a document that swapped + // navigator.clipboard after onPageStarted. + injectClipboardShim(view) + // The wallet is one tap away on any node page; + // warm the scanner while the user reads it. + prewarmQrScanner(view.context.applicationContext) // Auto-login with the stored password (QR pairing / // saved server) — only on our own server's pages @@ -1305,6 +1470,9 @@ private fun InAppBrowser( CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) applyArchipelagoSettings() + // Node apps (BTCPay invoices, LND, Portainer tokens) are + // served over plain HTTP too — same dead-clipboard trap. + addClipboardBridge() webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { @@ -1336,14 +1504,20 @@ private fun InAppBrowser( override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { loading = true loadError = false - view?.let { injectTopInset(it) } + view?.let { + injectTopInset(it) + injectClipboardShim(it) + } } override fun onPageFinished(view: WebView?, u: String?) { loading = false canGoBack = view?.canGoBack() == true canGoForward = view?.canGoForward() == true - view?.let { injectTopInset(it) } + view?.let { + injectTopInset(it) + injectClipboardShim(it) + } } override fun onReceivedError( diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 6f92ff2c..11221464 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -49,4 +49,6 @@ 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 + Turn on the torch + Turn off the torch