feat(companion): native clipboard bridge, faster QR scanner, restart control — 0.5.27
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Int?>(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<androidx.camera.core.Camera?>(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()
|
||||
}
|
||||
}
|
||||
|
||||
+47
-8
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -49,4 +49,6 @@
|
||||
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||
<string name="upload_qr_image">Upload image</string>
|
||||
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||
<string name="torch_on">Turn on the torch</string>
|
||||
<string name="torch_off">Turn off the torch</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user