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:
parent
1ecb5c9aa3
commit
37c8992d21
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user