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:
Dorian
2026-08-02 13:46:24 +01:00
co-authored by Claude Opus 5
parent f772620d17
commit b9dc0fb1bd
7 changed files with 441 additions and 40 deletions
+2 -2
View File
@@ -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()
}
}
@@ -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>