From 2a376ab2753642447bd0c05ec027ab85880ed385 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 21 Jul 2026 13:44:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(scan):=20camera=20on=20companion=20+=20ins?= =?UTF-8?q?ecure=20origins=20=E2=80=94=20WebView=20bridge=20&=20photo=20fa?= =?UTF-8?q?llback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps kept the wallet QR scanner camera-less outside a desktop browser: - Companion app: the WebView's default WebChromeClient silently denies getUserMedia. Both WebViews (main + in-app overlay) now implement onPermissionRequest — granting video capture only, requesting the app-level CAMERA permission on first use (manifest already declares it). - Plain-http origins (mobile web/PWA on a LAN node): browsers hide navigator.mediaDevices entirely, no permission can bring it back. New "Take photo of QR" fallback uses — the native camera needs no secure context — and decodes the shot locally with qr-scanner's scanImage. Live preview still used when available. Co-Authored-By: Claude Fable 5 --- .../app/ui/screens/WebViewScreen.kt | 67 +++++++++++++++++++ neode-ui/src/components/WalletScanModal.vue | 38 ++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) 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 f38a4ec9..cc62e2a5 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 @@ -1,10 +1,13 @@ package com.archipelago.app.ui.screens +import android.Manifest import android.annotation.SuppressLint +import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.view.ViewGroup import android.webkit.CookieManager +import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest @@ -12,6 +15,9 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -146,6 +152,20 @@ fun WebViewScreen( var hasError by remember { mutableStateOf(false) } var webView by remember { mutableStateOf(null) } + // Web-page camera access (wallet QR scanner). The WebView's default + // WebChromeClient silently denies getUserMedia, so grant video capture — + // asking for the app-level CAMERA permission first when needed. + val webViewContext = LocalContext.current + var pendingWebPermission by remember { mutableStateOf(null) } + val webCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + pendingWebPermission?.let { req -> + if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny() + } + pendingWebPermission = null + } + // A node app that refused iframing, opened in a local WebView overlay. // null = no overlay. The kiosk WebView underneath stays alive (and warm) // while this is shown, so closing it returns instantly with no reload. @@ -371,6 +391,25 @@ fun WebViewScreen( loadProgress = newProgress } + // Wallet QR scanner: grant the page camera access. + // Only video capture is granted — anything else the + // page asks for is denied as before. + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { + request.deny() + return + } + val hasCamera = ContextCompat.checkSelfPermission( + webViewContext, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCamera) { + request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } else { + pendingWebPermission = request + webCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + // window.open() — e.g. the kiosk's "Open in new tab" // for an app that can't be iframed. Capture the target // URL via a throwaway WebView and route it ourselves. @@ -516,6 +555,18 @@ private fun InAppBrowser( var canGoBack by remember { mutableStateOf(false) } var canGoForward by remember { mutableStateOf(false) } + // Same camera bridge as the main WebView — node apps opened in the overlay + // (e.g. anything with a QR scanner) get getUserMedia too. + var pendingWebPermission by remember { mutableStateOf(null) } + val webCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + pendingWebPermission?.let { req -> + if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny() + } + pendingWebPermission = null + } + // Seed the loading-screen icon immediately from a best-effort favicon // pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the // loader shows an icon right away instead of staying blank until the page @@ -565,6 +616,22 @@ private fun InAppBrowser( override fun onReceivedIcon(view: WebView?, icon: Bitmap?) { if (icon != null) favicon = icon } + + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { + request.deny() + return + } + val hasCamera = ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCamera) { + request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } else { + pendingWebPermission = request + webCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } } webViewClient = object : WebViewClient() { diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 7eb3e860..8c8e270d 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -54,9 +54,23 @@ - + + + @@ -316,6 +330,28 @@ function submitPaste() { handleScanned(text) } +// --- Photo-capture fallback (works without a secure context) --- +// getUserMedia is unreachable over plain http (navigator.mediaDevices is +// 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) +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 + try { + 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' + } finally { + input.value = '' + } +} + // --- Detection (ported from k484's scanner) --- const rail = ref('lightning') const action = ref('pay-invoice')