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
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:
parent
5982fceb7c
commit
2a376ab275
@ -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() {
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user