feat(scan): camera on companion + insecure origins — WebView bridge & photo fallback
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m55s

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 <input capture=environment> — 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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-21 13:44:13 -04:00
parent 5982fceb7c
commit 2a376ab275
2 changed files with 104 additions and 1 deletions

View File

@ -1,10 +1,13 @@
package com.archipelago.app.ui.screens package com.archipelago.app.ui.screens
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebResourceError import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
@ -12,6 +15,9 @@ import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler 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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -146,6 +152,20 @@ fun WebViewScreen(
var hasError by remember { mutableStateOf(false) } var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) } var webView by remember { mutableStateOf<WebView?>(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<PermissionRequest?>(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. // A node app that refused iframing, opened in a local WebView overlay.
// null = no overlay. The kiosk WebView underneath stays alive (and warm) // null = no overlay. The kiosk WebView underneath stays alive (and warm)
// while this is shown, so closing it returns instantly with no reload. // while this is shown, so closing it returns instantly with no reload.
@ -371,6 +391,25 @@ fun WebViewScreen(
loadProgress = newProgress 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" // window.open() — e.g. the kiosk's "Open in new tab"
// for an app that can't be iframed. Capture the target // for an app that can't be iframed. Capture the target
// URL via a throwaway WebView and route it ourselves. // URL via a throwaway WebView and route it ourselves.
@ -516,6 +555,18 @@ private fun InAppBrowser(
var canGoBack by remember { mutableStateOf(false) } var canGoBack by remember { mutableStateOf(false) }
var canGoForward 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<PermissionRequest?>(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 // Seed the loading-screen icon immediately from a best-effort favicon
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the // 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 // 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?) { override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) favicon = icon 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() { webViewClient = object : WebViewClient() {

View File

@ -54,9 +54,23 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
<button @click="startScanning" class="glass-button px-4 py-2 rounded-lg text-sm font-medium"> <button v-if="!liveCameraUnavailable" @click="startScanning" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ cameraError ? 'Retry camera' : 'Start camera' }} {{ cameraError ? 'Retry camera' : 'Start camera' }}
</button> </button>
<!-- Insecure-context fallback: live getUserMedia preview needs
HTTPS, but the native camera via a file input does not
snap a photo of the QR and decode it locally. -->
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
Take photo of QR
</button>
<input
ref="photoInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhotoPicked"
/>
</div> </div>
</div> </div>
@ -316,6 +330,28 @@ function submitPaste() {
handleScanned(text) handleScanned(text)
} }
// --- Photo-capture fallback (works without a secure context) ---
// getUserMedia is unreachable over plain http (navigator.mediaDevices is
// undefined), but <input capture> opens the native camera in any browser,
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
const photoInput = ref<HTMLInputElement | null>(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) --- // --- Detection (ported from k484's scanner) ---
const rail = ref<Rail>('lightning') const rail = ref<Rail>('lightning')
const action = ref<Action>('pay-invoice') const action = ref<Action>('pay-invoice')