Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75364178ad | ||
|
|
b9dc0fb1bd | ||
|
|
f772620d17 | ||
|
|
372ff7aa65 | ||
|
|
f893a9e804 | ||
|
|
9628dd8bc2 | ||
|
|
4ea3fb2deb |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ArchipelagoApp : Application()
|
||||
class ArchipelagoApp : Application() {
|
||||
|
||||
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Warmups that otherwise land inside the first frame:
|
||||
// - FipsNative.available dlopens the 7 MB Rust core; referenced from
|
||||
// composition (NESMenu, mesh auto-start), it blocked the UI thread.
|
||||
// - The first DataStore read gates the nav graph's start destination;
|
||||
// parsing it here means the launch gate resolves in the first
|
||||
// emission instead of waiting on cold disk IO.
|
||||
warmupScope.launch {
|
||||
FipsNative.available
|
||||
runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() }
|
||||
}
|
||||
|
||||
// First WebView construction pays Chromium provider load (~150-400 ms
|
||||
// cold). Absorb it while the main thread is idle before the kiosk
|
||||
// needs it, instead of serially after the connection probe.
|
||||
Looper.getMainLooper().queue.addIdleHandler {
|
||||
runCatching { WebView(this).destroy() }
|
||||
false // one-shot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +20,13 @@ class MainActivity : ComponentActivity() {
|
||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
// Hold the branded system splash until the nav graph has its launch
|
||||
// state — without this the splash dropped at the first composed frame,
|
||||
// which was EMPTY (the DataStore read hadn't landed): splash → black
|
||||
// flash → UI on every launch.
|
||||
var navReady = false
|
||||
val splash = installSplashScreen()
|
||||
splash.setKeepOnScreenCondition { !navReady }
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingPairUri.value = intent?.dataString
|
||||
@@ -29,6 +36,7 @@ class MainActivity : ComponentActivity() {
|
||||
AppNavHost(
|
||||
pairUri = pairUri,
|
||||
onPairUriConsumed = { pendingPairUri.value = null },
|
||||
onReady = { navReady = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
|
||||
@@ -89,9 +90,9 @@ class ServerPreferences(private val context: Context) {
|
||||
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
|
||||
ServerEntry(
|
||||
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
|
||||
val address = prefs[activeAddressKey] ?: return null
|
||||
return ServerEntry(
|
||||
address = address,
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
@@ -102,19 +103,43 @@ class ServerPreferences(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// distinctUntilChanged on every flow: DataStore emits on EVERY write to the
|
||||
// file regardless of key, and each spurious emission recomposed whatever
|
||||
// screen collected it (the kiosk recomposed on gesture-hint writes).
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data
|
||||
.map { prefs -> activeServerFrom(prefs) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[savedServersKey] ?: emptySet()
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }
|
||||
}
|
||||
// Sorted so set-iteration order can't produce a structurally different
|
||||
// list for the same servers (which defeats distinctUntilChanged).
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[gestureHintSeenKey] ?: false
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** Everything the nav graph needs to pick a start destination, derived
|
||||
* from ONE DataStore emission. Collecting introSeen and activeServer as
|
||||
* two separate flows let them land in different frames — the intro flag
|
||||
* could resolve first and flash the Connect screen at a paired user
|
||||
* before the active server arrived. */
|
||||
data class LaunchState(val introSeen: Boolean, val activeServer: ServerEntry?)
|
||||
|
||||
val launchState: Flow<LaunchState> = context.dataStore.data.map { prefs ->
|
||||
LaunchState(
|
||||
introSeen = prefs[introSeenKey] ?: false,
|
||||
activeServer = activeServerFrom(prefs),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
|
||||
@@ -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
|
||||
@@ -229,6 +231,36 @@ private fun MenuPanel(
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
|
||||
// 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
|
||||
.getPackageInfo(hubContext.packageName, 0).versionName
|
||||
}.getOrNull()?.let { "Companion v$it" } ?: ""
|
||||
}
|
||||
if (versionLabel.isNotEmpty()) {
|
||||
Text(
|
||||
versionLabel,
|
||||
color = TextMuted.copy(alpha = 0.6f),
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.NODES -> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,6 +34,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -47,7 +51,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -65,7 +72,6 @@ import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -84,6 +90,7 @@ fun QrScannerOverlay(
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
@@ -92,6 +99,8 @@ fun QrScannerOverlay(
|
||||
}
|
||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||
var handled by remember { mutableStateOf(false) }
|
||||
var torchOn by remember { mutableStateOf(false) }
|
||||
var hasTorch by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
@@ -105,6 +114,8 @@ fun QrScannerOverlay(
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
} else {
|
||||
torchOn = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +141,10 @@ fun QrScannerOverlay(
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
// Confirm the hit in the hand — the eye is
|
||||
// still on the code, not on the screen.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
torchOn = false
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
@@ -137,6 +152,8 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
},
|
||||
torchOn = torchOn,
|
||||
onTorchAvailable = { hasTorch = it },
|
||||
)
|
||||
// Aim frame
|
||||
Box(
|
||||
@@ -182,8 +199,21 @@ fun QrScannerOverlay(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (hasPermission && hasTorch) {
|
||||
IconButton(onClick = { torchOn = !torchOn }) {
|
||||
Icon(
|
||||
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
|
||||
stringResource(
|
||||
if (torchOn) R.string.torch_off else R.string.torch_on,
|
||||
),
|
||||
tint = if (torchOn) BitcoinOrange else TextPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,12 +247,56 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||
/**
|
||||
* Warm the CameraX provider and the ZXing decode path before the user ever
|
||||
* asks for a scan, so opening the scanner doesn't pay provider init + class
|
||||
* loading on the critical path. Does NOT open the camera: no permission is
|
||||
* needed, no LED lights up, nothing is recorded — [ProcessCameraProvider]
|
||||
* init is process-wide and cached, and the synthetic decode below just walks
|
||||
* a blank 32x32 frame to class-load the binarizer/detector.
|
||||
*
|
||||
* Called once per process from the kiosk WebView (first page load) and by the
|
||||
* page via `ArchipelagoQr.prewarm()`.
|
||||
*/
|
||||
internal fun prewarmQrScanner(context: Context) {
|
||||
if (!qrPrewarmed.compareAndSet(false, true)) return
|
||||
val app = context.applicationContext
|
||||
runCatching { ProcessCameraProvider.getInstance(app) }
|
||||
// Off the UI thread: the first decode attempt loads a dozen ZXing classes.
|
||||
Executors.newSingleThreadExecutor().let { exec ->
|
||||
exec.execute {
|
||||
runCatching {
|
||||
val blank = ByteArray(32 * 32)
|
||||
val reader = MultiFormatReader().apply {
|
||||
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
|
||||
}
|
||||
val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false)
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
}
|
||||
}
|
||||
exec.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Shared by the pairing scanner and the wallet scan modal.
|
||||
*
|
||||
* [torchOn] drives the flash; [onTorchAvailable] reports whether this camera
|
||||
* has one at all (the caller only draws its toggle when it does).
|
||||
*/
|
||||
@Composable
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
internal fun CameraQrPreview(
|
||||
onDecoded: (String) -> Unit,
|
||||
torchOn: Boolean = false,
|
||||
onTorchAvailable: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
|
||||
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
@@ -232,6 +306,11 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
// Set by the analyzer on every decode; the zoom hunt below reads it to tell
|
||||
// "nothing in view" from "reading fine, leave the camera alone".
|
||||
val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
// A tap-to-focus wins over the periodic centre AF for a few seconds.
|
||||
val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
@@ -260,12 +339,20 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
analysisExecutor,
|
||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||
QrCodeAnalyzer { text ->
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
mainExecutor.execute { currentOnDecoded(text) }
|
||||
},
|
||||
)
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
camera = cam
|
||||
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit())
|
||||
// Start the "nothing is decoding" clock at bind time, so the
|
||||
// zoom hunt below waits for the user to aim before it fires.
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||
// a static scene, so continuous-AF often never retriggers and the
|
||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||
@@ -276,8 +363,26 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
point,
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||
).disableAutoCancel().build()
|
||||
val maxZoom = cam.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f
|
||||
var zoomedIn = false
|
||||
focusScheduler.scheduleWithFixedDelay({
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
val now = System.currentTimeMillis()
|
||||
// Skip the centre refocus while a tap-to-focus is still fresh.
|
||||
if (now - lastTapFocusAt.get() > 4000) {
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}
|
||||
// Zoom hunt: a small or distant printed code can be under the
|
||||
// decoder's module-size floor at 1x. After ~3s with nothing
|
||||
// decoding, alternate 1x / 1.5x so both framings get a turn —
|
||||
// sticking at 1.5x would push a close-held phone out of focus.
|
||||
if (now - lastDecodeAt.get() > 3000 && maxZoom > 1f) {
|
||||
zoomedIn = !zoomedIn
|
||||
val ratio = if (zoomedIn) minOf(1.5f, maxZoom) else 1f
|
||||
runCatching { cam.cameraControl.setZoomRatio(ratio) }
|
||||
} else if (zoomedIn && now - lastDecodeAt.get() <= 3000) {
|
||||
zoomedIn = false
|
||||
runCatching { cam.cameraControl.setZoomRatio(1f) }
|
||||
}
|
||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
@@ -286,17 +391,60 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
|
||||
onDispose {
|
||||
focusScheduler.shutdownNow()
|
||||
runCatching { camera?.cameraControl?.enableTorch(false) }
|
||||
camera = null
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
// Torch follows the caller's state (and switches off when the view goes).
|
||||
LaunchedEffect(camera, torchOn) {
|
||||
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Tap-to-focus: the periodic centre AF assumes the code is centred;
|
||||
// a tap lets the user point at one that isn't (or re-trigger AF the
|
||||
// instant they've framed it, instead of waiting for the next tick).
|
||||
.pointerInput(camera) {
|
||||
detectTapGestures { offset ->
|
||||
val cam = camera ?: return@detectTapGestures
|
||||
val factory = previewView.meteringPointFactory
|
||||
val action = androidx.camera.core.FocusMeteringAction.Builder(
|
||||
factory.createPoint(offset.x, offset.y),
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AF or
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AE,
|
||||
).build()
|
||||
lastTapFocusAt.set(System.currentTimeMillis())
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(action) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
/**
|
||||
* ZXing-based QR decoder over the camera's Y (luminance) plane, in two tiers.
|
||||
*
|
||||
* The old single pass was full-frame + TRY_HARDER + an inverted retry, which
|
||||
* costs enough that it could only run ~7x/s without the CPU contention making
|
||||
* the preview itself stutter — so a code that was already sharp and centred
|
||||
* still waited up to 140ms to be seen.
|
||||
*
|
||||
* Now: a CHEAP pass (centre crop, no TRY_HARDER) runs at ~18x/s and catches
|
||||
* the common case — a well-framed code — almost the moment it's readable. Only
|
||||
* when that misses does the THOROUGH pass (full frame, TRY_HARDER, inverted
|
||||
* retry) run, still at the old ~5x/s, so off-centre, dense, glare-hit and
|
||||
* light-on-dark codes decode exactly as well as before.
|
||||
*/
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader().apply {
|
||||
private val fastReader = MultiFormatReader().apply {
|
||||
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
|
||||
}
|
||||
private val hardReader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
@@ -307,19 +455,16 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
||||
)
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
private var lastFastAt = 0L
|
||||
private var lastHardAt = 0L
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||
// frames skipped here are simply dropped, so decodes stay current.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttempt < 140) {
|
||||
if (now - lastFastAt < 55) {
|
||||
image.close()
|
||||
return
|
||||
}
|
||||
lastAttempt = now
|
||||
lastFastAt = now
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
@@ -327,25 +472,43 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
||||
// may be short of the full stride, so the tail stays zero-padded.
|
||||
val data = ByteArray(plane.rowStride * image.height)
|
||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
|
||||
// Fast pass — the centre 70%, which is where the viewfinder tells
|
||||
// the user to put the code. Half the pixels, no exhaustive search.
|
||||
val cropW = (image.width * 0.7f).toInt().coerceAtLeast(1)
|
||||
val cropH = (image.height * 0.7f).toInt().coerceAtLeast(1)
|
||||
val cropped = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
(image.width - cropW) / 2, (image.height - cropH) / 2, cropW, cropH,
|
||||
false,
|
||||
)
|
||||
runCatching { fastReader.decodeWithState(BinaryBitmap(HybridBinarizer(cropped))) }
|
||||
.getOrNull()
|
||||
?.let {
|
||||
onDecoded(it.text)
|
||||
return
|
||||
}
|
||||
|
||||
if (now - lastHardAt < 180) return
|
||||
lastHardAt = now
|
||||
val full = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
val result = runCatching {
|
||||
hardReader.decodeWithState(BinaryBitmap(HybridBinarizer(full)))
|
||||
}.getOrNull() ?: runCatching {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
}
|
||||
onDecoded(result.text)
|
||||
} catch (_: NotFoundException) {
|
||||
// No QR in this frame — keep scanning.
|
||||
hardReader.reset()
|
||||
hardReader.decodeWithState(BinaryBitmap(HybridBinarizer(full.invert())))
|
||||
}.getOrNull()
|
||||
if (result != null) onDecoded(result.text)
|
||||
} catch (_: Exception) {
|
||||
// Malformed frame; skip it.
|
||||
} finally {
|
||||
reader.reset()
|
||||
fastReader.reset()
|
||||
hardReader.reset()
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
+47
-8
@@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -44,7 +46,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -80,12 +84,15 @@ fun WalletQrScannerModal(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var torchOn by remember { mutableStateOf(false) }
|
||||
var hasTorch by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
@@ -114,6 +121,8 @@ fun WalletQrScannerModal(
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
} else {
|
||||
torchOn = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
@@ -185,14 +194,24 @@ fun WalletQrScannerModal(
|
||||
// because each frame's text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
CameraQrPreview(onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
})
|
||||
CameraQrPreview(
|
||||
onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
// Buzz on the FIRST hit only: an animated QR
|
||||
// streams a new frame every few ms, and one
|
||||
// buzz each would be a drill in the hand.
|
||||
if (lastText.isEmpty()) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
},
|
||||
torchOn = torchOn,
|
||||
onTorchAvailable = { hasTorch = it },
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(0.62f)
|
||||
@@ -202,6 +221,26 @@ fun WalletQrScannerModal(
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
// Torch — the dim-room complaint. Sits in the preview's
|
||||
// corner so it never covers the aim frame.
|
||||
if (hasTorch) {
|
||||
IconButton(
|
||||
onClick = { torchOn = !torchOn },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.45f)),
|
||||
) {
|
||||
Icon(
|
||||
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
|
||||
stringResource(
|
||||
if (torchOn) R.string.torch_off else R.string.torch_on,
|
||||
),
|
||||
tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
|
||||
@@ -28,7 +28,9 @@ import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object Routes {
|
||||
const val INTRO = "intro"
|
||||
@@ -43,14 +45,18 @@ object Routes {
|
||||
fun AppNavHost(
|
||||
pairUri: String? = null,
|
||||
onPairUriConsumed: () -> Unit = {},
|
||||
onReady: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
// One combined emission — introSeen and activeServer resolving in separate
|
||||
// frames used to flash the Connect screen at paired users on launch.
|
||||
val launchState by prefs.launchState.collectAsState(initial = null)
|
||||
val introSeen = launchState?.introSeen
|
||||
val activeServer = launchState?.activeServer
|
||||
|
||||
// Pairing entry from a deep link that carried no password — prefills the
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
@@ -80,11 +86,17 @@ fun AppNavHost(
|
||||
}
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
// Off the main dispatcher: this path dlopens the 7 MB fips core and does a
|
||||
// binder round-trip (VpnService.prepare) — it was landing inside the first
|
||||
// frame's effect batch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
// Launch state resolved — MainActivity holds the system splash until now,
|
||||
// so the first visible frame is the real UI, never a black gap.
|
||||
LaunchedEffect(Unit) { onReady() }
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||
|
||||
@@ -107,7 +107,11 @@ fun FlareScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||
// derivedStateOf: filtering inline re-ran over the whole store on every
|
||||
// recomposition — including one per keystroke in the composer.
|
||||
val messages by remember(selectedNpub) {
|
||||
androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } }
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
@@ -305,7 +309,13 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (msg.photoPath.isNotBlank()) {
|
||||
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||
// Decoded off-main and downsampled to the bubble width —
|
||||
// full-size decode in remember{} ran on the UI thread mid-
|
||||
// scroll and held ~8 MB per visible photo (OOM territory).
|
||||
var bmp by remember(msg.photoPath) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(msg.photoPath) {
|
||||
bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) }
|
||||
}
|
||||
bmp?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
@@ -336,6 +346,19 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
}
|
||||
|
||||
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
||||
/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the
|
||||
* bubble renders at ~300 dp, so the stored 1600 px original is 25× the
|
||||
* pixels needed. Blocking — call on IO. */
|
||||
private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
|
||||
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -65,9 +66,10 @@ fun IntroScreen(
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(800))
|
||||
delay(300)
|
||||
// Content fades in WITH the logo, not after it — the serial
|
||||
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
|
||||
showContent = true
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(450))
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -111,7 +113,9 @@ fun IntroScreen(
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.size(160.dp)
|
||||
.alpha(logoAlpha.value),
|
||||
// graphicsLayer defers the alpha read to the draw phase —
|
||||
// .alpha(value) recomposed the whole screen per frame.
|
||||
.graphicsLayer { alpha = logoAlpha.value },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
@@ -123,9 +123,12 @@ fun PartyScreen(
|
||||
name = prefs.partyName()
|
||||
// The hotspot/WiFi address can change while this screen is open
|
||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
||||
// Tight only at first (the hotspot-flip window); interface walks
|
||||
// allocate, so back off once the screen has been open a while.
|
||||
var round = 0
|
||||
while (true) {
|
||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
||||
delay(3_000)
|
||||
delay(if (round++ < 10) 3_000 else 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +141,16 @@ fun PartyScreen(
|
||||
port = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
}
|
||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||
// QR encode + bitmap fill off the composition: done in remember{} it ran
|
||||
// on the UI thread PER KEYSTROKE of the name field (the payload embeds the
|
||||
// name) — a ZXing encode plus a megabyte-plus allocation per character.
|
||||
// The 250 ms delay is a free debounce via coroutine cancellation.
|
||||
var qrBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(qrPayload) {
|
||||
if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect }
|
||||
if (qrBitmap != null) delay(250)
|
||||
qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
@@ -337,7 +349,12 @@ fun PartyScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||
// Encoded off-main; done in remember{} it dropped the
|
||||
// overlay's first fade-in frame.
|
||||
var dlQr by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) }
|
||||
}
|
||||
dlQr?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
@@ -364,7 +381,7 @@ fun PartyScreen(
|
||||
"…or send the APK file directly",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||
@@ -473,8 +490,9 @@ fun PartyScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a QR payload as a bitmap (dark modules on white). */
|
||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
|
||||
* 240.dp display size at any density; 640 was a third more pixels for nothing. */
|
||||
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
|
||||
val matrix = QRCodeWriter().encode(
|
||||
payload,
|
||||
BarcodeFormat.QR_CODE,
|
||||
@@ -494,16 +512,23 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
}
|
||||
|
||||
/** Share this install's own APK via the system share sheet — a nearby friend
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth). */
|
||||
private fun shareCompanionApk(context: android.content.Context) {
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth).
|
||||
* The ~27 MB copy runs on IO — inline in the click handler it froze the UI
|
||||
* for seconds (ANR territory on slow flash). Copied once per install; the
|
||||
* cached file is reused while its size still matches the source. */
|
||||
private suspend fun shareCompanionApk(context: android.content.Context) {
|
||||
try {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
src.copyTo(out, overwrite = true)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
if (!out.exists() || out.length() != src.length()) {
|
||||
src.copyTo(out, overwrite = true)
|
||||
}
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
}
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
|
||||
@@ -86,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -108,6 +109,13 @@ fun ServerConnectScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
// Warm the mesh tunnel the moment the screen appears — starting it only
|
||||
// after the LAN probe failed put full tunnel bring-up + session discovery
|
||||
// inside the user's wait. By connect-tap time it's usually already up.
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||
}
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
@@ -173,30 +181,43 @@ fun ServerConnectScreen(
|
||||
errorMessage = null
|
||||
|
||||
scope.launch {
|
||||
var reachable = testConnection(server)
|
||||
|
||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||
// node. The scanned IP was only ever a dial hint; the node's real
|
||||
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain
|
||||
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
|
||||
// started (the off-LAN QR-pairing case, exactly where speed shows).
|
||||
// The scanned IP was only ever a dial hint; the node's real
|
||||
// identity is its npub and its ULA is reachable from anywhere over
|
||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||
if (!reachable && server.meshIp.isNotBlank()) {
|
||||
// the mesh. Mesh discovery + first session can take 15s+ through
|
||||
// the public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at the
|
||||
// same time — so the mesh side keeps probing inside its budget
|
||||
// while the tunnel (already started at screen entry, and kicked
|
||||
// again here) warms up underneath.
|
||||
val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
val meshServer = server.copy(
|
||||
address = server.meshIp,
|
||||
useHttps = false,
|
||||
port = "",
|
||||
)
|
||||
// Mesh discovery + first session can take 15s+ through the
|
||||
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at
|
||||
// the same time — so probe patiently inside a 60s budget with
|
||||
// per-attempt timeouts wide enough to ride out TCP
|
||||
// retransmit backoff. The VPN service pre-warms the session
|
||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
||||
if (!reachable) delay(3000)
|
||||
server.copy(address = it, useHttps = false, port = "")
|
||||
}
|
||||
val reachable = kotlinx.coroutines.coroutineScope {
|
||||
val lan = async { testConnection(server, timeoutMs = 4_000) }
|
||||
val mesh = async {
|
||||
if (meshServer == null) return@async false
|
||||
val deadline = System.currentTimeMillis() + 45_000
|
||||
var ok = false
|
||||
while (!ok && System.currentTimeMillis() < deadline) {
|
||||
ok = testConnection(meshServer, timeoutMs = 8_000)
|
||||
if (!ok) delay(2000)
|
||||
}
|
||||
ok
|
||||
}
|
||||
val first = kotlinx.coroutines.selects.select<Boolean> {
|
||||
lan.onAwait { it }
|
||||
mesh.onAwait { it }
|
||||
}
|
||||
if (first) {
|
||||
lan.cancel(); mesh.cancel()
|
||||
true
|
||||
} else {
|
||||
// One side gave up — the verdict is whatever the other says.
|
||||
if (lan.isCompleted) mesh.await() else lan.await()
|
||||
}
|
||||
}
|
||||
isConnecting = false
|
||||
@@ -686,6 +707,17 @@ private fun sanitizeAddress(input: String): String {
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
// Built once — the connect loop probed up to 20 times, and each attempt was
|
||||
// paying a fresh SSLContext + SecureRandom init.
|
||||
private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory
|
||||
}
|
||||
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
||||
@@ -697,14 +729,7 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000):
|
||||
|
||||
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
|
||||
if (connection is HttpsURLConnection) {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
connection.sslSocketFactory = sc.socketFactory
|
||||
connection.sslSocketFactory = trustAllSslFactory
|
||||
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,15 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
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.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -73,6 +80,7 @@ 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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -94,12 +102,14 @@ 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
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -121,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. */
|
||||
@@ -137,6 +286,10 @@ private fun isSameHost(url: String, base: String): Boolean {
|
||||
/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving
|
||||
* the kiosk and coming back reattaches the LIVE page — no reload, no
|
||||
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
|
||||
/** What the in-app overlay opens: the URL plus (when the web UI provides
|
||||
* them) the app's catalog icon and display name for the branded loader. */
|
||||
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
|
||||
|
||||
private object KioskWebView {
|
||||
var instance: WebView? = null
|
||||
var url: String? = null
|
||||
@@ -145,7 +298,7 @@ private object KioskWebView {
|
||||
// registered interface objects call through these, so reattaching the
|
||||
// retained view re-points them instead of leaving stale closures.
|
||||
var onRouteOutbound: (String) -> Unit = {}
|
||||
var onOpenInApp: (String) -> Unit = {}
|
||||
var onOpenInApp: (InAppLaunch) -> Unit = {}
|
||||
var onQrOpen: () -> Unit = {}
|
||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||
var onQrClose: () -> Unit = {}
|
||||
@@ -172,6 +325,11 @@ private fun injectSafeAreaVars(view: WebView) {
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
|
||||
// The insets listener fires on every pass (every IME show/hide); skip the
|
||||
// JS round-trip — and the Vue event it dispatches — when nothing changed.
|
||||
val stamp = "sa:$sat,$sab"
|
||||
if (view.tag == stamp) return
|
||||
view.tag = stamp
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
@@ -191,19 +349,28 @@ private fun injectSafeAreaVars(view: WebView) {
|
||||
)
|
||||
}
|
||||
|
||||
/** In-app browser pages (node apps + same-node links) don't consume the
|
||||
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
|
||||
* their content ran up under the status bar. Pad the document body down by
|
||||
* the status-bar height: the padded strip shows the page's OWN background
|
||||
* (padding is inside the element), so the bar keeps the page colour while
|
||||
* content starts below it — the pre-edge-to-edge look, without the black bar.
|
||||
/** Status-bar treatment for in-app pages, the contract being: the page's
|
||||
* BACKGROUND (colour or imagery) extends up into the status-bar area, while
|
||||
* page CONTENT always starts below it. The WebView draws edge-to-edge; this
|
||||
* injection makes any page honour that contract:
|
||||
*
|
||||
* Body padding only moves normal-flow content. fixed/sticky elements anchored
|
||||
* at the viewport top (IndeeHub's floating header) stayed glued under the
|
||||
* status bar, so we also push each of those down by the inset — once, marked
|
||||
* via data attribute — and keep a throttled MutationObserver running so
|
||||
* headers an SPA mounts after load get the same treatment.
|
||||
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
||||
* - `body{padding-top}` moves normal-flow content down; body background
|
||||
* (colour + background-image) keeps painting across the padded strip.
|
||||
* - The strip must show the page's EFFECTIVE background, not body's declared
|
||||
* one — LND declares a white body under a dark full-height root, which
|
||||
* painted a white bar. We sample the rendered background at the top of the
|
||||
* viewport (elementFromPoint, walking up to the first opaque colour) and
|
||||
* repaint body with it, only when body's own background is transparent or
|
||||
* disagrees with what's actually rendered.
|
||||
* - fixed/sticky bars anchored at the top (IndeeHub's floating header) don't
|
||||
* move with body padding — each is pushed down by the inset, once.
|
||||
* - full-height fixed overlays (modals) must NOT move down (that exposed the
|
||||
* page behind them in a strip) — they get padding-top instead, so their own
|
||||
* background covers the bar.
|
||||
* - a throttled MutationObserver re-applies the fixed/sticky treatment to
|
||||
* elements the SPA mounts after load.
|
||||
* Idempotent; runs on start (early, so content never flashes under the bar)
|
||||
* and finish (after the app rewrites head; layout exists for sampling). */
|
||||
private fun injectTopInset(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return
|
||||
val density = view.resources.displayMetrics.density
|
||||
@@ -213,45 +380,96 @@ private fun injectTopInset(view: WebView) {
|
||||
"""
|
||||
(function() {
|
||||
var SAT = $sat;
|
||||
var s = document.getElementById('archy-top-inset');
|
||||
if (!s) {
|
||||
s = document.createElement('style');
|
||||
s.id = 'archy-top-inset';
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
var CLEAR = 'rgba(0, 0, 0, 0)';
|
||||
function styleEl() {
|
||||
var s = document.getElementById('archy-top-inset');
|
||||
if (!s) {
|
||||
s = document.createElement('style');
|
||||
s.id = 'archy-top-inset';
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// Effective rendered background at the top of the viewport: from
|
||||
// the topmost element at a few probe points, walk up to the first
|
||||
// ancestor that actually paints a colour.
|
||||
function effectiveTopBg() {
|
||||
if (!document.body || !document.elementFromPoint) return '';
|
||||
var w = window.innerWidth;
|
||||
var probes = [[w >> 1, SAT + 2], [8, SAT + 2], [w - 8, SAT + 2]];
|
||||
for (var i = 0; i < probes.length; i++) {
|
||||
var el = document.elementFromPoint(probes[i][0], probes[i][1]);
|
||||
while (el && el !== document.documentElement) {
|
||||
var c = getComputedStyle(el).backgroundColor;
|
||||
if (c && c !== CLEAR && c !== 'transparent') return c;
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function apply() {
|
||||
var bodyBg = document.body
|
||||
? getComputedStyle(document.body).backgroundColor : '';
|
||||
var eff = effectiveTopBg();
|
||||
// Repaint body only when its declared background is not what
|
||||
// the page actually renders at the top (or paints nothing).
|
||||
var fix = eff && eff !== bodyBg
|
||||
? 'background-color:' + eff + ' !important;' : '';
|
||||
styleEl().textContent =
|
||||
'body{padding-top:' + SAT + 'px !important;' +
|
||||
'box-sizing:border-box !important;' + fix + '}';
|
||||
}
|
||||
s.textContent =
|
||||
'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
|
||||
function push(el) {
|
||||
if (el.dataset.archyInset) return;
|
||||
var cs = getComputedStyle(el);
|
||||
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
|
||||
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
|
||||
if (isNaN(top) || top >= SAT) return;
|
||||
el.style.setProperty('top', (top + SAT) + 'px', 'important');
|
||||
// offsetHeight ignores enter-animation transforms.
|
||||
if (el.offsetHeight >= window.innerHeight - SAT - 1) {
|
||||
var pt = parseFloat(cs.paddingTop) || 0;
|
||||
el.style.setProperty('padding-top', (pt + SAT) + 'px', 'important');
|
||||
el.style.setProperty('box-sizing', 'border-box', 'important');
|
||||
} else {
|
||||
el.style.setProperty('top', (top + SAT) + 'px', 'important');
|
||||
}
|
||||
el.dataset.archyInset = '1';
|
||||
}
|
||||
function sweep() {
|
||||
if (!document.body) return;
|
||||
// Fixed/sticky bars live shallow in the tree (portals mount on
|
||||
// body); depth cap keeps the computed-style pass off big lists.
|
||||
// Fixed/sticky bars live shallow (portals mount on body); the
|
||||
// depth cap keeps the computed-style pass off big lists.
|
||||
var els = document.body.querySelectorAll(
|
||||
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
|
||||
for (var i = 0; i < els.length; i++) push(els[i]);
|
||||
}
|
||||
apply();
|
||||
sweep();
|
||||
if (!window.__archyInsetObserver) {
|
||||
var queued = false, last = 0;
|
||||
window.__archyInsetObserver = new MutationObserver(function() {
|
||||
var queued = false, last = 0, idleSweeps = 0;
|
||||
var obs = new MutationObserver(function() {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
var wait = Math.max(0, 250 - (Date.now() - last));
|
||||
setTimeout(function() {
|
||||
queued = false;
|
||||
last = Date.now();
|
||||
var before = document.querySelectorAll('[data-archy-inset]').length;
|
||||
apply();
|
||||
sweep();
|
||||
var after = document.querySelectorAll('[data-archy-inset]').length;
|
||||
// Page has settled — stop paying the computed-style
|
||||
// tax on every SPA mutation. onPageStarted/Finished
|
||||
// re-run the injection, which reinstalls us.
|
||||
idleSweeps = (after === before) ? idleSweeps + 1 : 0;
|
||||
if (idleSweeps >= 12) {
|
||||
obs.disconnect();
|
||||
window.__archyInsetObserver = null;
|
||||
}
|
||||
}, wait);
|
||||
});
|
||||
window.__archyInsetObserver.observe(document.documentElement,
|
||||
window.__archyInsetObserver = obs;
|
||||
obs.observe(document.documentElement,
|
||||
{ childList: true, subtree: true });
|
||||
}
|
||||
})();
|
||||
@@ -272,18 +490,69 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
||||
false
|
||||
}
|
||||
|
||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||
* (patient — a cold session may still be establishing). If NEITHER answers,
|
||||
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
|
||||
* unreachable, and loading it just produced a confusing "can't reach
|
||||
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
|
||||
* instead means the load retries against the path that's actually coming up,
|
||||
* and any error shows the mesh address rather than a dead LAN IP. */
|
||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||
/** Last origin that actually answered, per LAN|mesh pair — so a relaunch
|
||||
* starts loading in milliseconds instead of re-running discovery probes.
|
||||
* Persisted: the in-memory map alone only survived the process, which made
|
||||
* exactly the launch that mattered (cold start next morning) the slow one. */
|
||||
private object StartUrlCache {
|
||||
private const val STORE = "start_url_cache"
|
||||
private val lastGood = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
fun get(context: android.content.Context, key: String): String? =
|
||||
lastGood[key] ?: context.getSharedPreferences(STORE, 0).getString(key, null)
|
||||
?.also { lastGood[key] = it }
|
||||
|
||||
fun put(context: android.content.Context, key: String, origin: String) {
|
||||
lastGood[key] = origin
|
||||
context.getSharedPreferences(STORE, 0).edit().putString(key, origin).apply()
|
||||
}
|
||||
|
||||
fun invalidate(context: android.content.Context, key: String) {
|
||||
lastGood.remove(key)
|
||||
context.getSharedPreferences(STORE, 0).edit().remove(key).apply()
|
||||
}
|
||||
|
||||
fun key(lanUrl: String, meshUrl: String?) = "$lanUrl|${meshUrl ?: ""}"
|
||||
}
|
||||
|
||||
/** Fastest answering origin. THREE probes race in parallel — the persisted
|
||||
* last-good origin (short timeout: it usually answers in ~10-50 ms on LAN or
|
||||
* a warm mesh session), the LAN URL, and the mesh ULA (patient — a cold
|
||||
* session may still be establishing). Racing the cached probe instead of
|
||||
* checking it first means a stale cache entry costs nothing: the other
|
||||
* racers are already running. Serial probing was the "lightning sometimes,
|
||||
* garbage other times" launch variance — 2.5 s of dead LAN wait before the
|
||||
* mesh probe even started.
|
||||
* If nothing answers, fall back to the mesh URL when we have one — off-LAN
|
||||
* the LAN IP is unreachable, and loading it just produced a confusing
|
||||
* "can't reach 192.168.x.x" error page (user-reported 2026-07-27). */
|
||||
private suspend fun pickStartUrl(
|
||||
context: android.content.Context,
|
||||
lanUrl: String,
|
||||
meshUrl: String?,
|
||||
): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
||||
meshUrl ?: lanUrl
|
||||
val key = StartUrlCache.key(lanUrl, meshUrl)
|
||||
val cached = StartUrlCache.get(context, key)
|
||||
val winner = kotlinx.coroutines.coroutineScope {
|
||||
val racers = buildList {
|
||||
cached?.let { c -> add(async { if (tcpAnswers(c, 1200)) c else null }) }
|
||||
add(async { if (tcpAnswers(lanUrl, 2500)) lanUrl else null })
|
||||
meshUrl?.let { m -> add(async { if (tcpAnswers(m, 8_000)) m else null }) }
|
||||
}
|
||||
var pending = racers.toMutableList()
|
||||
var result: String? = null
|
||||
while (result == null && pending.isNotEmpty()) {
|
||||
val (value, done) = kotlinx.coroutines.selects.select<Pair<String?, kotlinx.coroutines.Deferred<String?>>> {
|
||||
pending.forEach { d -> d.onAwait { it to d } }
|
||||
}
|
||||
pending.remove(done)
|
||||
result = value
|
||||
}
|
||||
racers.forEach { it.cancel() }
|
||||
result
|
||||
}
|
||||
winner?.also { StartUrlCache.put(context, key, it) } ?: meshUrl ?: lanUrl
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
@@ -357,6 +626,8 @@ fun WebViewScreen(
|
||||
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
|
||||
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
||||
var raceNonce by remember { mutableIntStateOf(0) }
|
||||
var kioskCanGoBack by remember { mutableStateOf(false) }
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
|
||||
// A retained live session exists — reattach instantly: no race, no
|
||||
// reload, no re-login (remote ⇄ dashboard round trip).
|
||||
@@ -365,7 +636,7 @@ fun WebViewScreen(
|
||||
startUrl = serverUrl
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
|
||||
val picked = pickStartUrl(appContext, serverUrl, meshFallbackUrl)
|
||||
// Starting on the mesh: don't bounce back to it on error (it IS it).
|
||||
if (picked != serverUrl) triedMeshFallback = true
|
||||
startUrl = picked
|
||||
@@ -388,7 +659,7 @@ fun WebViewScreen(
|
||||
// A node app that refused iframing, opened in a local WebView overlay.
|
||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||
var inAppLaunch by remember { mutableStateOf<InAppLaunch?>(null) }
|
||||
|
||||
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
|
||||
// the ULA while app links may carry the LAN IP (and vice versa) —
|
||||
@@ -439,7 +710,10 @@ fun WebViewScreen(
|
||||
pendingFileChooser = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
// canGoBack() is a live Chromium call, not snapshot state — read from
|
||||
// composition it was stale (navigation doesn't recompose). Tracked via
|
||||
// doUpdateVisitedHistory instead, like the in-app overlay already does.
|
||||
BackHandler(enabled = inAppLaunch == null && kioskCanGoBack) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
@@ -488,8 +762,13 @@ fun WebViewScreen(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
// Re-race LAN vs mesh — the network we're on may have
|
||||
// changed since the last pick. Drop the retained view:
|
||||
// changed since the last pick, so the remembered
|
||||
// origin is suspect too. Drop the retained view:
|
||||
// an errored session must genuinely reload.
|
||||
StartUrlCache.invalidate(
|
||||
webViewContext,
|
||||
StartUrlCache.key(serverUrl, meshFallbackUrl),
|
||||
)
|
||||
KioskWebView.drop()
|
||||
webView = null
|
||||
hasError = false
|
||||
@@ -570,7 +849,7 @@ fun WebViewScreen(
|
||||
// - different host → the phone's real browser
|
||||
fun routeOutbound(url: String) {
|
||||
if (isSameNode(url)) {
|
||||
inAppUrl = url
|
||||
inAppLaunch = InAppLaunch(url)
|
||||
} else {
|
||||
openExternalUrl(context, url)
|
||||
}
|
||||
@@ -586,7 +865,7 @@ fun WebViewScreen(
|
||||
// launch"). These assignments re-point the live
|
||||
// interface objects at THIS composition every attach.
|
||||
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
|
||||
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
|
||||
KioskWebView.onOpenInApp = { launch -> inAppLaunch = launch }
|
||||
KioskWebView.onQrOpen = {
|
||||
walletScannerStatus = null
|
||||
walletScannerVisible = true
|
||||
@@ -607,16 +886,38 @@ fun WebViewScreen(
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
webViewRef.post { KioskWebView.onOpenInApp(url) }
|
||||
webViewRef.post { KioskWebView.onOpenInApp(InAppLaunch(url)) }
|
||||
}
|
||||
|
||||
// Richer launch: the web UI passes the app's
|
||||
// catalog icon + display name for the branded
|
||||
// pulsating loader (never a site favicon).
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInAppEx(url: String, iconUrl: String, name: String) {
|
||||
webViewRef.post {
|
||||
KioskWebView.onOpenInApp(
|
||||
InAppLaunch(
|
||||
url,
|
||||
iconUrl.takeUnless { it.isBlank() },
|
||||
name.takeUnless { it.isBlank() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
"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(
|
||||
@@ -626,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) }
|
||||
@@ -643,13 +954,30 @@ fun WebViewScreen(
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
hasError = false
|
||||
// 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) {
|
||||
kioskCanGoBack = view?.canGoBack() == true
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
isLoading = false
|
||||
kioskCanGoBack = view?.canGoBack() == true
|
||||
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
|
||||
@@ -902,12 +1230,14 @@ fun WebViewScreen(
|
||||
|
||||
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||
inAppUrl?.let { target ->
|
||||
inAppLaunch?.let { target ->
|
||||
InAppBrowser(
|
||||
url = target,
|
||||
url = target.url,
|
||||
serverUrl = serverUrl,
|
||||
meshUrl = meshFallbackUrl,
|
||||
onClose = { inAppUrl = null },
|
||||
appIcon = target.icon,
|
||||
appName = target.name,
|
||||
onClose = { inAppLaunch = null },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -933,7 +1263,7 @@ fun WebViewScreen(
|
||||
// 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
|
||||
!isLoading && inAppLaunch == null
|
||||
) {
|
||||
GestureHintOverlay(
|
||||
onDismiss = {
|
||||
@@ -1008,22 +1338,23 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
||||
* can be shown on the loading screen before the WebView reports onReceivedIcon
|
||||
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
||||
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||
/** Catalog icons re-fetched over the mesh on every app open made the loader
|
||||
* show the fallback logo first — cache the handful of icons a node has. */
|
||||
private val iconCache = androidx.collection.LruCache<String, Bitmap>(24)
|
||||
|
||||
/** Best-effort fetch of an image URL (the app's catalog icon) for the branded
|
||||
* loading screen. Blocking — call on IO. */
|
||||
private fun fetchBitmap(imageUrl: String): Bitmap? {
|
||||
iconCache.get(imageUrl)?.let { return it }
|
||||
return try {
|
||||
val u = android.net.Uri.parse(pageUrl)
|
||||
val scheme = u.scheme ?: return null
|
||||
val host = u.host ?: return null
|
||||
val portPart = if (u.port > 0) ":${u.port}" else ""
|
||||
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
|
||||
val conn = (java.net.URL(imageUrl).openConnection()
|
||||
as java.net.HttpURLConnection).apply {
|
||||
connectTimeout = 4000
|
||||
readTimeout = 4000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
?.also { iconCache.put(imageUrl, it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -1043,6 +1374,8 @@ private fun InAppBrowser(
|
||||
url: String,
|
||||
serverUrl: String,
|
||||
meshUrl: String? = null,
|
||||
appIcon: String? = null,
|
||||
appName: String? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -1057,16 +1390,23 @@ private fun InAppBrowser(
|
||||
// real <title> (onReceivedTitle upgrades it).
|
||||
var title by remember {
|
||||
mutableStateOf(
|
||||
android.net.Uri.parse(url).host
|
||||
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
||||
appName
|
||||
?: android.net.Uri.parse(url).host
|
||||
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
||||
?: "Archipelago",
|
||||
)
|
||||
}
|
||||
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
// Loader icon: the app's CATALOG icon passed by the web UI — never the
|
||||
// site favicon (generic favicons made the loader look broken). Falls back
|
||||
// to the Archipelago pixel logo while absent.
|
||||
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
// Main-frame load failure — the branded offline screen renders instead of
|
||||
// Chromium's stock "Webpage not available" page (never show that).
|
||||
var loadError by remember { mutableStateOf(false) }
|
||||
|
||||
// Same camera bridge as the main WebView — node apps opened in the overlay
|
||||
// (e.g. anything with a QR scanner) get getUserMedia too.
|
||||
@@ -1080,13 +1420,12 @@ private fun InAppBrowser(
|
||||
pendingWebPermission = null
|
||||
}
|
||||
|
||||
// Seed the loading-screen icon immediately from a best-effort favicon
|
||||
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
||||
// loader shows an icon right away instead of staying blank until the page
|
||||
// parses its <head> (which is what made the loader look stuck).
|
||||
LaunchedEffect(url) {
|
||||
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
||||
if (fetched != null && favicon == null) favicon = fetched
|
||||
// Fetch the app's catalog icon for the loader (absolute URL from the web
|
||||
// UI). No favicon fallback — the pixel logo is the brand fallback.
|
||||
LaunchedEffect(appIcon) {
|
||||
if (appIcon != null) {
|
||||
loaderIcon = withContext(Dispatchers.IO) { fetchBitmap(appIcon) }
|
||||
}
|
||||
}
|
||||
|
||||
// Back: walk the in-app history first, then close the overlay.
|
||||
@@ -1109,13 +1448,13 @@ private fun InAppBrowser(
|
||||
onClick = {},
|
||||
)
|
||||
// Bottom inset handled by the touch-shield strip below the bar.
|
||||
// No TOP inset padding: the WebView draws edge-to-edge behind the
|
||||
// status bar so the app's own background fills it — the padded
|
||||
// version painted an opaque black bar there (user-rejected look).
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||
),
|
||||
) {
|
||||
// No native top strip: the WebView draws edge-to-edge so page
|
||||
// BACKGROUNDS extend into the status-bar area; injectTopInset keeps
|
||||
// page CONTENT below it (the design contract for every in-app page).
|
||||
// WebView + loading overlay fill the area above the bottom control bar.
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
AndroidView(
|
||||
@@ -1131,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) {
|
||||
@@ -1141,10 +1483,6 @@ private fun InAppBrowser(
|
||||
if (!t.isNullOrBlank()) title = t
|
||||
}
|
||||
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
if (icon != null) favicon = icon
|
||||
}
|
||||
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
||||
request.deny()
|
||||
@@ -1165,14 +1503,34 @@ private fun InAppBrowser(
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
view?.let { injectTopInset(it) }
|
||||
loadError = false
|
||||
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(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
// Sub-resource failures are the page's problem;
|
||||
// only a dead MAIN FRAME gets the offline screen.
|
||||
if (request?.isForMainFrame == true) {
|
||||
loadError = true
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||
@@ -1217,7 +1575,49 @@ private fun InAppBrowser(
|
||||
)
|
||||
|
||||
// Centered loading screen — app favicon (or spinner) + title + bar.
|
||||
if (loading) {
|
||||
if (loadError) {
|
||||
// Branded offline screen — Chromium's stock error page is
|
||||
// rendering underneath, and must never be visible.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = TextMuted,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.connection_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
GlassButton(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
loadError = false
|
||||
loading = true
|
||||
browser?.reload()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
)
|
||||
}
|
||||
} else if (loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -1225,19 +1625,53 @@ private fun InAppBrowser(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Pulsating app-icon card — mirrors the web UI's
|
||||
// AppLoadingScreen (84dp, r20, 1.8s scale/fade pulse).
|
||||
val pulse = rememberInfiniteTransition(label = "loaderPulse")
|
||||
val scale by pulse.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(900, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "loaderScale",
|
||||
)
|
||||
val fade by pulse.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.85f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(900, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "loaderFade",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
|
||||
modifier = Modifier
|
||||
.size(84.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
alpha = fade
|
||||
}
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.08f),
|
||||
RoundedCornerShape(20.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val fav = favicon
|
||||
if (fav != null) {
|
||||
val icon = loaderIcon
|
||||
if (icon != null) {
|
||||
Image(
|
||||
bitmap = fav.asImageBitmap(),
|
||||
bitmap = icon.asImageBitmap(),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
PixelArtLogo(Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
@@ -353,6 +353,10 @@ function openInNewTab() {
|
||||
// (so the tap silently no-ops). The native bridge is reliable; fall back to
|
||||
// window.open in a plain mobile browser.
|
||||
const native = (window as any).ArchipelagoNative
|
||||
if (native && typeof native.openInAppEx === 'function' && store.title) {
|
||||
native.openInAppEx(store.url, '', store.title)
|
||||
return
|
||||
}
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(store.url)
|
||||
return
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<div v-if="transactions.length === 0" class="flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
class="text-sm font-medium"
|
||||
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
||||
>
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats
|
||||
</span>
|
||||
<span
|
||||
v-if="isOnchain(tx)"
|
||||
@@ -88,6 +88,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
||||
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
|
||||
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,6 +167,18 @@ function isOnchain(tx: WalletTransaction): boolean {
|
||||
return !tx.kind || tx.kind === 'onchain'
|
||||
}
|
||||
|
||||
function feeFor(tx: WalletTransaction): number {
|
||||
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
|
||||
}
|
||||
|
||||
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
|
||||
* itself is broken out on its own tag. Incoming rows are untouched. */
|
||||
function displayAmount(tx: WalletTransaction): number {
|
||||
const gross = Math.abs(tx.amount_sats)
|
||||
const fee = feeFor(tx)
|
||||
return fee > 0 && gross > fee ? gross - fee : gross
|
||||
}
|
||||
|
||||
function kindLabel(tx: WalletTransaction): string {
|
||||
if (tx.kind === 'lightning') return '⚡ Lightning'
|
||||
if (tx.kind === 'cashu') return 'Cashu'
|
||||
|
||||
@@ -3,9 +3,10 @@ import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
|
||||
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
@@ -200,6 +201,17 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
/** App identity (catalog icon + display name) for the companion's native
|
||||
* branded loader. Undefined when the app isn't in package-data. */
|
||||
function launchMeta(appId: string): InAppLaunchMeta | undefined {
|
||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||
if (!pkg) return undefined
|
||||
return {
|
||||
iconUrl: resolveAppIcon(appId, pkg),
|
||||
name: pkg.manifest?.title || appId,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
@@ -225,7 +237,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||
if (launchUrl) {
|
||||
openInAppOrNewTab(launchUrl)
|
||||
openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -235,7 +247,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) {
|
||||
if (mobile) openInAppOrNewTab(ext)
|
||||
if (mobile) openInAppOrNewTab(ext, launchMeta(appId))
|
||||
else openExternal(ext)
|
||||
return
|
||||
}
|
||||
@@ -248,7 +260,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
||||
const launchUrl = directAppUrl(appId)
|
||||
if (launchUrl) {
|
||||
if (mobile) openInAppOrNewTab(launchUrl)
|
||||
if (mobile) openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||
else openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
@@ -327,7 +339,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// Companion app: never fall through to the iframe overlay — hand the URL
|
||||
// to the native in-app WebView instead (see openSession).
|
||||
if (!IS_DEMO && isCompanionApp()) {
|
||||
openInAppOrNewTab(launchUrl)
|
||||
openInAppOrNewTab(launchUrl, resolvedId ? launchMeta(resolvedId) : undefined)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1452,6 +1452,24 @@ html.kiosk-safe-area #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Horizontal filter-pill rail: single row, swipes sideways on narrow
|
||||
screens, no visible scrollbar — pills never wrap or squish. */
|
||||
.pill-rail {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.pill-rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.pill-rail > * {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for glass containers */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
interface ArchipelagoNativeBridge {
|
||||
openExternal?: (url: string) => void
|
||||
openInApp?: (url: string) => void
|
||||
/** Richer launch (companion ≥0.5.26): catalog icon + display name drive the
|
||||
* native branded loader instead of the site favicon. */
|
||||
openInAppEx?: (url: string, iconUrl: string, name: string) => void
|
||||
}
|
||||
|
||||
/** Optional app identity for the native loading screen. */
|
||||
export interface InAppLaunchMeta {
|
||||
iconUrl?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function nativeBridge(): ArchipelagoNativeBridge | undefined {
|
||||
@@ -46,9 +55,15 @@ export function openExternalUrl(url: string): void {
|
||||
* inside Archipelago with the native back/forward/reload/close controls.
|
||||
* - Plain mobile browser (PWA): open directly in a new browser tab.
|
||||
*/
|
||||
export function openInAppOrNewTab(url: string): void {
|
||||
export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void {
|
||||
if (!url) return
|
||||
const native = nativeBridge()
|
||||
if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) {
|
||||
// Absolutize the icon path so the native shell can fetch it directly.
|
||||
const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : ''
|
||||
native.openInAppEx(url, icon, meta.name ?? '')
|
||||
return
|
||||
}
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(url)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user