feat(scan): camera on companion + insecure origins — WebView bridge & photo fallback
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
co-authored by Claude Fable 5
parent 5982fceb7c
commit 2a376ab275
2 changed files with 104 additions and 1 deletions
@@ -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<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.
// 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<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
// 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() {