feat(companion): native wallet QR scanner, gesture hint overlay, WebView file uploads

- WalletQrScannerModal: native CameraX+zxing scanner styled like the web
  wallet modal, with an upload-image decode path; opened by the web UI
  through the new window.ArchipelagoQr JS bridge. Decodes stream back via
  window.__archyQrResult; the page mirrors status lines out and closes
  the modal when it accepts a code. Detection/spend logic stays in the
  web modal.
- onShowFileChooser: <input type=file> was silently ignored by the
  WebView — file pickers (incl. the wallet's upload option) now work.
- GestureHintOverlay: one-time animation teaching the three-finger hold,
  armed ~2 minutes after login (gesture_hint_seen flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-23 20:52:42 +01:00
parent 15954aec14
commit 87c324e36c
5 changed files with 599 additions and 11 deletions

View File

@ -75,6 +75,7 @@ class ServerPreferences(private val context: Context) {
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return@map null
@ -97,6 +98,11 @@ class ServerPreferences(private val context: Context) {
prefs[introSeenKey] ?: false
}
/** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[gestureHintSeenKey] ?: false
}
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
prefs[activeAddressKey] = server.address
@ -214,4 +220,10 @@ class ServerPreferences(private val context: Context) {
prefs[introSeenKey] = true
}
}
suspend fun markGestureHintSeen() {
context.dataStore.edit { prefs ->
prefs[gestureHintSeenKey] = true
}
}
}

View File

@ -0,0 +1,147 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
/**
* First-launch teaching overlay for the three-finger hold gesture. Three
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
* short caption explains what the gesture opens. Dismissed by tapping
* anywhere (or automatically after a few seconds) shown once, ever.
*/
@Composable
fun GestureHintOverlay(onDismiss: () -> Unit) {
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
LaunchedEffect(Unit) {
delay(6500)
onDismiss()
}
val transition = rememberInfiniteTransition(label = "gesture-hint")
// Fingertips press down together…
val press by transition.animateFloat(
initialValue = 1f,
targetValue = 0.86f,
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
label = "press",
)
// …while a ring ripples outward on each press cycle.
val ripple by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
label = "ripple",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Hand: three fingertip dots in a natural arc + ripple ring.
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(150.dp)
.scale(0.4f + ripple * 0.6f)
.border(
2.dp,
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
CircleShape,
),
)
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
FingerDot(x = 44.dp, y = 8.dp, scale = press)
}
Spacer(Modifier.height(28.dp))
Text(
text = stringResource(R.string.gesture_hint_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(R.string.gesture_hint_body),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 48.dp),
)
Spacer(Modifier.height(32.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.clickable(onClick = onDismiss)
.padding(horizontal = 28.dp, vertical = 12.dp),
) {
Text(
text = stringResource(R.string.gesture_hint_got_it),
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
}
}
}
}
@Composable
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
Box(
Modifier
.offset(x = x, y = y)
.size(26.dp)
.scale(scale)
.background(Color.White.copy(alpha = 0.92f), CircleShape),
)
}

View File

@ -0,0 +1,294 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
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.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Native replacement for the web wallet's scan pane same visual design as
* neode-ui's WalletScanModal (dark glass card, square preview, orange
* viewfinder, status strip) but the camera and decoding run natively, so the
* preview doesn't lag the way getUserMedia does inside a WebView.
*
* Decoded text is handed back to the page ([onDecoded]) which does all the
* detection/spend logic; the page in turn streams status lines (animated-QR
* progress, "not recognised" errors) back in via [status] and closes the
* modal through the JS bridge once it accepts a code.
*/
@Composable
fun WalletQrScannerModal(
visible: Boolean,
status: Pair<String, Boolean>?, // message from the web page + isError
onDecoded: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
// Local error from a failed image upload; a fresh web status replaces it.
var uploadError by remember { mutableStateOf<String?>(null) }
val noQrMessage = stringResource(R.string.no_qr_in_image)
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
val decoded = decodeQrFromUri(context, uri)
if (decoded != null) {
uploadError = null
onDecoded(decoded)
} else {
uploadError = noQrMessage
}
}
}
LaunchedEffect(visible) {
if (visible) {
uploadError = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
LaunchedEffect(status) { if (status != null) uploadError = null }
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(16.dp)
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF212151C))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}, // swallow — only the scrim dismisses
)
.padding(24.dp),
) {
// Header — mirrors the web modal's title row
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_to_send),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
}
Spacer(Modifier.height(8.dp))
// Square camera preview with the orange viewfinder
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.4f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
// Throttle repeat frames: a static QR decodes ~20x/s but
// the page only needs one; animated QRs still stream
// 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)
}
})
Box(
Modifier
.fillMaxSize(0.62f)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
// Status strip — same slot the web modal uses for hints/errors
val message = uploadError ?: status?.first
val isError = uploadError != null || status?.second == true
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp)
.defaultMinSize(minHeight = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message?.takeIf { it.isNotBlank() }
?: stringResource(R.string.scan_wallet_hint),
style = MaterialTheme.typography.bodySmall,
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
GlassButton(
text = stringResource(R.string.upload_qr_image),
onClick = { imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
}
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
return try {
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
while (maxDim / (sample * 2) >= 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
?: return null
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
)
)
}
try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
} catch (_: NotFoundException) {
// Light-on-dark QRs (dark-themed wallets) decode inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
}
} catch (_: Exception) {
null
}
}

View File

@ -53,11 +53,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -68,13 +71,21 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
@ -176,6 +187,39 @@ fun WebViewScreen(
// while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) }
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) }
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
// One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) }
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
var gestureHintDismissed by remember { mutableStateOf(false) }
// Don't teach the gesture on top of the login/splash — arm the overlay
// ~2 minutes after the kiosk first finishes loading, once the user has
// settled in.
var gestureHintReady by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { isLoading }.first { !it }
delay(120_000)
gestureHintReady = true
}
val scope = rememberCoroutineScope()
// <input type="file"> support — without a chooser implementation the
// WebView silently ignores file inputs (broke the wallet's upload path).
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
val fileChooserLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
pendingFileChooser?.onReceiveValue(
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
)
pendingFileChooser = null
}
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
webView?.goBack()
}
@ -301,6 +345,35 @@ fun WebViewScreen(
"ArchipelagoNative",
)
// 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
// Decodes flow back through window.__archyQrResult(text);
// a user cancel calls window.__archyQrCancelled().
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun open() {
webViewRef.post {
walletScannerStatus = null
walletScannerVisible = true
}
}
@android.webkit.JavascriptInterface
fun setStatus(message: String, isError: Boolean) {
webViewRef.post { walletScannerStatus = message to isError }
}
@android.webkit.JavascriptInterface
fun close() {
webViewRef.post { walletScannerVisible = false }
}
},
"ArchipelagoQr",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true
@ -412,6 +485,27 @@ fun WebViewScreen(
loadProgress = newProgress
}
override fun onShowFileChooser(
view: WebView?,
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
fileChooserParams: FileChooserParams?,
): Boolean {
pendingFileChooser?.onReceiveValue(null)
pendingFileChooser = filePathCallback
val intent = fileChooserParams?.createIntent()
if (intent == null) {
pendingFileChooser = null
return false
}
return try {
fileChooserLauncher.launch(intent)
true
} catch (_: Exception) {
pendingFileChooser = null
false
}
}
// Wallet QR scanner: grant the page camera access.
// Only video capture is granted — anything else the
// page asks for is denied as before.
@ -467,22 +561,24 @@ fun WebViewScreen(
}
}
// Two-finger hold (500ms) → navigate to remote input
var twoFingerStart = 0L
var twoFingerFired = false
// Three-finger hold (500ms) → navigate to remote input.
// Three fingers, not two: two-finger scroll/pinch on the
// page collided with the old two-finger hold.
var threeFingerStart = 0L
var threeFingerFired = false
setOnTouchListener { _, event ->
val pointerCount = event.pointerCount
when (event.actionMasked) {
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
if (pointerCount >= 2) {
twoFingerStart = System.currentTimeMillis()
twoFingerFired = false
if (pointerCount >= 3) {
threeFingerStart = System.currentTimeMillis()
threeFingerFired = false
}
}
android.view.MotionEvent.ACTION_MOVE -> {
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
if (System.currentTimeMillis() - twoFingerStart > 500) {
twoFingerFired = true
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
onRemoteInput()
}
}
@ -490,8 +586,8 @@ fun WebViewScreen(
android.view.MotionEvent.ACTION_UP,
android.view.MotionEvent.ACTION_POINTER_UP,
android.view.MotionEvent.ACTION_CANCEL -> {
if (event.pointerCount <= 2) {
twoFingerStart = 0L
if (event.pointerCount <= 3) {
threeFingerStart = 0L
}
}
}
@ -527,6 +623,38 @@ fun WebViewScreen(
onClose = { inAppUrl = null },
)
}
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
WalletQrScannerModal(
visible = walletScannerVisible,
status = walletScannerStatus,
onDecoded = { text ->
webView?.evaluateJavascript(
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
null,
)
},
onDismiss = {
walletScannerVisible = false
webView?.evaluateJavascript(
"window.__archyQrCancelled && window.__archyQrCancelled()",
null,
)
},
)
// First-launch teaching overlay for the three-finger hold — armed
// ~2 minutes after login so it never fights the splash/first look.
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
!isLoading && inAppUrl == null
) {
GestureHintOverlay(
onDismiss = {
gestureHintDismissed = true
scope.launch { prefs.markGestureHintSeen() }
},
)
}
}
}
}

View File

@ -41,4 +41,11 @@
<string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
<string name="gesture_hint_title">Hold with three fingers</string>
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
<string name="gesture_hint_got_it">Got it</string>
<string name="scan_to_send">Scan to send</string>
<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>
</resources>