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
15954aec14
commit
87c324e36c
@ -75,6 +75,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
|
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||||
|
|
||||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||||
val address = prefs[activeAddressKey] ?: return@map null
|
val address = prefs[activeAddressKey] ?: return@map null
|
||||||
@ -97,6 +98,11 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] ?: false
|
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) {
|
suspend fun setActiveServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[activeAddressKey] = server.address
|
prefs[activeAddressKey] = server.address
|
||||||
@ -214,4 +220,10 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] = true
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import android.webkit.ValueCallback
|
||||||
import com.archipelago.app.R
|
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.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
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.
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
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) {
|
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||||
webView?.goBack()
|
webView?.goBack()
|
||||||
}
|
}
|
||||||
@ -301,6 +345,35 @@ fun WebViewScreen(
|
|||||||
"ArchipelagoNative",
|
"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() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@ -412,6 +485,27 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
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.
|
// Wallet QR scanner: grant the page camera access.
|
||||||
// Only video capture is granted — anything else the
|
// Only video capture is granted — anything else the
|
||||||
// page asks for is denied as before.
|
// page asks for is denied as before.
|
||||||
@ -467,22 +561,24 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-finger hold (500ms) → navigate to remote input
|
// Three-finger hold (500ms) → navigate to remote input.
|
||||||
var twoFingerStart = 0L
|
// Three fingers, not two: two-finger scroll/pinch on the
|
||||||
var twoFingerFired = false
|
// page collided with the old two-finger hold.
|
||||||
|
var threeFingerStart = 0L
|
||||||
|
var threeFingerFired = false
|
||||||
setOnTouchListener { _, event ->
|
setOnTouchListener { _, event ->
|
||||||
val pointerCount = event.pointerCount
|
val pointerCount = event.pointerCount
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
if (pointerCount >= 2) {
|
if (pointerCount >= 3) {
|
||||||
twoFingerStart = System.currentTimeMillis()
|
threeFingerStart = System.currentTimeMillis()
|
||||||
twoFingerFired = false
|
threeFingerFired = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
android.view.MotionEvent.ACTION_MOVE -> {
|
android.view.MotionEvent.ACTION_MOVE -> {
|
||||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||||
twoFingerFired = true
|
threeFingerFired = true
|
||||||
onRemoteInput()
|
onRemoteInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -490,8 +586,8 @@ fun WebViewScreen(
|
|||||||
android.view.MotionEvent.ACTION_UP,
|
android.view.MotionEvent.ACTION_UP,
|
||||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||||
if (event.pointerCount <= 2) {
|
if (event.pointerCount <= 3) {
|
||||||
twoFingerStart = 0L
|
threeFingerStart = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -527,6 +623,38 @@ fun WebViewScreen(
|
|||||||
onClose = { inAppUrl = null },
|
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="edit_server_title">Edit Server</string>
|
||||||
<string name="save_changes">Save Changes</string>
|
<string name="save_changes">Save Changes</string>
|
||||||
<string name="cancel">Cancel</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>
|
</resources>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user