Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75364178ad | ||
|
|
b9dc0fb1bd | ||
|
|
f772620d17 | ||
|
|
372ff7aa65 | ||
|
|
f893a9e804 | ||
|
|
9628dd8bc2 | ||
|
|
4ea3fb2deb |
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 45
|
versionCode = 47
|
||||||
versionName = "0.5.25"
|
versionName = "0.5.27"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
package com.archipelago.app
|
package com.archipelago.app
|
||||||
|
|
||||||
import android.app.Application
|
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.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.archipelago.app.ui.navigation.AppNavHost
|
import com.archipelago.app.ui.navigation.AppNavHost
|
||||||
|
import com.archipelago.app.ui.screens.releaseKioskWebView
|
||||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
@@ -19,7 +20,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
pendingPairUri.value = intent?.dataString
|
pendingPairUri.value = intent?.dataString
|
||||||
@@ -29,6 +36,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
AppNavHost(
|
AppNavHost(
|
||||||
pairUri = pairUri,
|
pairUri = pairUri,
|
||||||
onPairUriConsumed = { pendingPairUri.value = null },
|
onPairUriConsumed = { pendingPairUri.value = null },
|
||||||
|
onReady = { navReady = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,4 +46,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
pendingPairUri.value = intent.dataString
|
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.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
|
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 introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||||
|
|
||||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
|
||||||
val address = prefs[activeAddressKey] ?: return@map null
|
val address = prefs[activeAddressKey] ?: return null
|
||||||
ServerEntry(
|
return ServerEntry(
|
||||||
address = address,
|
address = address,
|
||||||
useHttps = prefs[activeHttpsKey] ?: false,
|
useHttps = prefs[activeHttpsKey] ?: false,
|
||||||
port = prefs[activePortKey] ?: "",
|
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 savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
|
||||||
val raw = prefs[savedServersKey] ?: emptySet()
|
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 ->
|
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
prefs[introSeenKey] ?: false
|
prefs[introSeenKey] ?: false
|
||||||
}
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
prefs[gestureHintSeenKey] ?: false
|
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) {
|
suspend fun setActiveServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
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.Dns
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
import androidx.compose.material.icons.filled.SportsEsports
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -70,6 +71,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
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.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.SurfaceDark
|
import com.archipelago.app.ui.theme.SurfaceDark
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
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
|
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||||
// the settings button — not here.
|
// 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 -> {
|
HubPage.NODES -> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.archipelago.app.ui.components
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -16,6 +17,7 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -47,7 +51,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.BinaryBitmap
|
||||||
import com.google.zxing.DecodeHintType
|
import com.google.zxing.DecodeHintType
|
||||||
import com.google.zxing.MultiFormatReader
|
import com.google.zxing.MultiFormatReader
|
||||||
import com.google.zxing.NotFoundException
|
|
||||||
import com.google.zxing.PlanarYUVLuminanceSource
|
import com.google.zxing.PlanarYUVLuminanceSource
|
||||||
import com.google.zxing.common.HybridBinarizer
|
import com.google.zxing.common.HybridBinarizer
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -84,6 +90,7 @@ fun QrScannerOverlay(
|
|||||||
onServerScanned: (PairResult.Success) -> Unit,
|
onServerScanned: (PairResult.Success) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
@@ -92,6 +99,8 @@ fun QrScannerOverlay(
|
|||||||
}
|
}
|
||||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||||
var handled by remember { mutableStateOf(false) }
|
var handled by remember { mutableStateOf(false) }
|
||||||
|
var torchOn by remember { mutableStateOf(false) }
|
||||||
|
var hasTorch by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
@@ -105,6 +114,8 @@ fun QrScannerOverlay(
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
hasPermission = granted
|
hasPermission = granted
|
||||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
} else {
|
||||||
|
torchOn = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +141,10 @@ fun QrScannerOverlay(
|
|||||||
when (val result = ServerQrParser.parse(text)) {
|
when (val result = ServerQrParser.parse(text)) {
|
||||||
is PairResult.Success -> {
|
is PairResult.Success -> {
|
||||||
handled = true
|
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)
|
onServerScanned(result)
|
||||||
}
|
}
|
||||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||||
@@ -137,6 +152,8 @@ fun QrScannerOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
torchOn = torchOn,
|
||||||
|
onTorchAvailable = { hasTorch = it },
|
||||||
)
|
)
|
||||||
// Aim frame
|
// Aim frame
|
||||||
Box(
|
Box(
|
||||||
@@ -182,8 +199,21 @@ fun QrScannerOverlay(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
)
|
)
|
||||||
IconButton(onClick = onDismiss) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
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
|
@Composable
|
||||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
internal fun CameraQrPreview(
|
||||||
|
onDecoded: (String) -> Unit,
|
||||||
|
torchOn: Boolean = false,
|
||||||
|
onTorchAvailable: (Boolean) -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||||
|
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
|
||||||
|
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
|
||||||
val previewView = remember {
|
val previewView = remember {
|
||||||
PreviewView(context).apply {
|
PreviewView(context).apply {
|
||||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
@@ -232,6 +306,11 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
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) {
|
DisposableEffect(Unit) {
|
||||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||||
@@ -260,12 +339,20 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
.also {
|
.also {
|
||||||
it.setAnalyzer(
|
it.setAnalyzer(
|
||||||
analysisExecutor,
|
analysisExecutor,
|
||||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
QrCodeAnalyzer { text ->
|
||||||
|
lastDecodeAt.set(System.currentTimeMillis())
|
||||||
|
mainExecutor.execute { currentOnDecoded(text) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
p.unbindAll()
|
p.unbindAll()
|
||||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
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
|
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||||
// a static scene, so continuous-AF often never retriggers and the
|
// a static scene, so continuous-AF often never retriggers and the
|
||||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||||
@@ -276,8 +363,26 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
point,
|
point,
|
||||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||||
).disableAutoCancel().build()
|
).disableAutoCancel().build()
|
||||||
|
val maxZoom = cam.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f
|
||||||
|
var zoomedIn = false
|
||||||
focusScheduler.scheduleWithFixedDelay({
|
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)
|
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Camera unavailable — the user can dismiss and enter details manually.
|
// Camera unavailable — the user can dismiss and enter details manually.
|
||||||
@@ -286,17 +391,60 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
focusScheduler.shutdownNow()
|
focusScheduler.shutdownNow()
|
||||||
|
runCatching { camera?.cameraControl?.enableTorch(false) }
|
||||||
|
camera = null
|
||||||
provider?.unbindAll()
|
provider?.unbindAll()
|
||||||
analysisExecutor.shutdown()
|
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 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(
|
setHints(
|
||||||
mapOf(
|
mapOf(
|
||||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
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) {
|
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()
|
val now = System.currentTimeMillis()
|
||||||
if (now - lastAttempt < 140) {
|
if (now - lastFastAt < 55) {
|
||||||
image.close()
|
image.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastAttempt = now
|
lastFastAt = now
|
||||||
try {
|
try {
|
||||||
val plane = image.planes[0]
|
val plane = image.planes[0]
|
||||||
val buffer = plane.buffer
|
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.
|
// may be short of the full stride, so the tail stays zero-padded.
|
||||||
val data = ByteArray(plane.rowStride * image.height)
|
val data = ByteArray(plane.rowStride * image.height)
|
||||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
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,
|
data, plane.rowStride, image.height,
|
||||||
0, 0, image.width, image.height,
|
0, 0, image.width, image.height,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
val result = try {
|
val result = runCatching {
|
||||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
hardReader.decodeWithState(BinaryBitmap(HybridBinarizer(full)))
|
||||||
} catch (_: NotFoundException) {
|
}.getOrNull() ?: runCatching {
|
||||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||||
reader.reset()
|
hardReader.reset()
|
||||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
hardReader.decodeWithState(BinaryBitmap(HybridBinarizer(full.invert())))
|
||||||
}
|
}.getOrNull()
|
||||||
onDecoded(result.text)
|
if (result != null) onDecoded(result.text)
|
||||||
} catch (_: NotFoundException) {
|
|
||||||
// No QR in this frame — keep scanning.
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Malformed frame; skip it.
|
// Malformed frame; skip it.
|
||||||
} finally {
|
} finally {
|
||||||
reader.reset()
|
fastReader.reset()
|
||||||
|
hardReader.reset()
|
||||||
image.close()
|
image.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-8
@@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.widthIn
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -44,7 +46,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -80,12 +84,15 @@ fun WalletQrScannerModal(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
var torchOn by remember { mutableStateOf(false) }
|
||||||
|
var hasTorch by remember { mutableStateOf(false) }
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted -> hasPermission = granted }
|
) { granted -> hasPermission = granted }
|
||||||
@@ -114,6 +121,8 @@ fun WalletQrScannerModal(
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
hasPermission = granted
|
hasPermission = granted
|
||||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
} else {
|
||||||
|
torchOn = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||||
@@ -185,14 +194,24 @@ fun WalletQrScannerModal(
|
|||||||
// because each frame's text differs.
|
// because each frame's text differs.
|
||||||
var lastText by remember { mutableStateOf("") }
|
var lastText by remember { mutableStateOf("") }
|
||||||
var lastSentAt by remember { mutableStateOf(0L) }
|
var lastSentAt by remember { mutableStateOf(0L) }
|
||||||
CameraQrPreview(onDecoded = { text ->
|
CameraQrPreview(
|
||||||
val now = System.currentTimeMillis()
|
onDecoded = { text ->
|
||||||
if (text != lastText || now - lastSentAt > 250) {
|
val now = System.currentTimeMillis()
|
||||||
lastText = text
|
if (text != lastText || now - lastSentAt > 250) {
|
||||||
lastSentAt = now
|
// Buzz on the FIRST hit only: an animated QR
|
||||||
onDecoded(text)
|
// 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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize(0.62f)
|
.fillMaxSize(0.62f)
|
||||||
@@ -202,6 +221,26 @@ fun WalletQrScannerModal(
|
|||||||
RoundedCornerShape(16.dp),
|
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 {
|
} else {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = 24.dp),
|
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.RemoteInputScreen
|
||||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||||
import com.archipelago.app.ui.screens.WebViewScreen
|
import com.archipelago.app.ui.screens.WebViewScreen
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val INTRO = "intro"
|
const val INTRO = "intro"
|
||||||
@@ -43,14 +45,18 @@ object Routes {
|
|||||||
fun AppNavHost(
|
fun AppNavHost(
|
||||||
pairUri: String? = null,
|
pairUri: String? = null,
|
||||||
onPairUriConsumed: () -> Unit = {},
|
onPairUriConsumed: () -> Unit = {},
|
||||||
|
onReady: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
// One combined emission — introSeen and activeServer resolving in separate
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
// 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
|
// 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.
|
// 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.
|
// 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) {
|
LaunchedEffect(Unit) {
|
||||||
FipsManager.autoStartIfReady(context)
|
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (introSeen == null) return
|
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
|
// 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.
|
// 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 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()
|
val listState = rememberLazyListState()
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||||
@@ -305,7 +309,13 @@ private fun MessageBubble(msg: FlareMessage) {
|
|||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
if (msg.photoPath.isNotBlank()) {
|
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 {
|
bmp?.let {
|
||||||
Image(
|
Image(
|
||||||
bitmap = it.asImageBitmap(),
|
bitmap = it.asImageBitmap(),
|
||||||
@@ -336,6 +346,19 @@ private fun MessageBubble(msg: FlareMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
/** 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? =
|
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@@ -65,9 +66,10 @@ fun IntroScreen(
|
|||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
logoAlpha.animateTo(1f, animationSpec = tween(800))
|
// Content fades in WITH the logo, not after it — the serial
|
||||||
delay(300)
|
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
|
||||||
showContent = true
|
showContent = true
|
||||||
|
logoAlpha.animateTo(1f, animationSpec = tween(450))
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -111,7 +113,9 @@ fun IntroScreen(
|
|||||||
contentDescription = "Archipelago",
|
contentDescription = "Archipelago",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(160.dp)
|
.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))
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|||||||
@@ -123,9 +123,12 @@ fun PartyScreen(
|
|||||||
name = prefs.partyName()
|
name = prefs.partyName()
|
||||||
// The hotspot/WiFi address can change while this screen is open
|
// The hotspot/WiFi address can change while this screen is open
|
||||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
// (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) {
|
while (true) {
|
||||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
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,
|
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 {
|
BackHandler {
|
||||||
when {
|
when {
|
||||||
@@ -337,7 +349,12 @@ fun PartyScreen(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
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 ->
|
dlQr?.let { bmp ->
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -364,7 +381,7 @@ fun PartyScreen(
|
|||||||
"…or send the APK file directly",
|
"…or send the APK file directly",
|
||||||
color = BitcoinOrange,
|
color = BitcoinOrange,
|
||||||
fontSize = 13.sp,
|
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))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.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). */
|
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
|
||||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
* 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(
|
val matrix = QRCodeWriter().encode(
|
||||||
payload,
|
payload,
|
||||||
BarcodeFormat.QR_CODE,
|
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
|
/** 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). */
|
* gets the companion with no internet at all (Quick Share / Bluetooth).
|
||||||
private fun shareCompanionApk(context: android.content.Context) {
|
* 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 {
|
try {
|
||||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
val uri = withContext(Dispatchers.IO) {
|
||||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||||
src.copyTo(out, overwrite = true)
|
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
if (!out.exists() || out.length() != src.length()) {
|
||||||
context, "${context.packageName}.fileprovider", out,
|
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 {
|
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||||
type = "application/vnd.android.package-archive"
|
type = "application/vnd.android.package-archive"
|
||||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
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.TextPrimary
|
||||||
import com.archipelago.app.ui.theme.TextSecondary
|
import com.archipelago.app.ui.theme.TextSecondary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -108,6 +109,13 @@ fun ServerConnectScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
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 name by remember { mutableStateOf("") }
|
||||||
var address by remember { mutableStateOf("") }
|
var address by remember { mutableStateOf("") }
|
||||||
var port by remember { mutableStateOf("") }
|
var port by remember { mutableStateOf("") }
|
||||||
@@ -173,30 +181,43 @@ fun ServerConnectScreen(
|
|||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
var reachable = testConnection(server)
|
// 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
|
||||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
// started (the off-LAN QR-pairing case, exactly where speed shows).
|
||||||
// node. The scanned IP was only ever a dial hint; the node's real
|
// 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
|
// identity is its npub and its ULA is reachable from anywhere over
|
||||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
// the mesh. Mesh discovery + first session can take 15s+ through
|
||||||
if (!reachable && server.meshIp.isNotBlank()) {
|
// 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)
|
FipsManager.autoStartIfReady(context)
|
||||||
val meshServer = server.copy(
|
server.copy(address = it, useHttps = false, port = "")
|
||||||
address = server.meshIp,
|
}
|
||||||
useHttps = false,
|
val reachable = kotlinx.coroutines.coroutineScope {
|
||||||
port = "",
|
val lan = async { testConnection(server, timeoutMs = 4_000) }
|
||||||
)
|
val mesh = async {
|
||||||
// Mesh discovery + first session can take 15s+ through the
|
if (meshServer == null) return@async false
|
||||||
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
val deadline = System.currentTimeMillis() + 45_000
|
||||||
// first-ever pairing the VPN consent dialog is on screen at
|
var ok = false
|
||||||
// the same time — so probe patiently inside a 60s budget with
|
while (!ok && System.currentTimeMillis() < deadline) {
|
||||||
// per-attempt timeouts wide enough to ride out TCP
|
ok = testConnection(meshServer, timeoutMs = 8_000)
|
||||||
// retransmit backoff. The VPN service pre-warms the session
|
if (!ok) delay(2000)
|
||||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
}
|
||||||
val deadline = System.currentTimeMillis() + 60_000
|
ok
|
||||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
}
|
||||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
val first = kotlinx.coroutines.selects.select<Boolean> {
|
||||||
if (!reachable) delay(3000)
|
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
|
isConnecting = false
|
||||||
@@ -686,6 +707,17 @@ private fun sanitizeAddress(input: String): String {
|
|||||||
.trimEnd('/')
|
.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.
|
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
* 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)
|
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
|
||||||
if (connection is HttpsURLConnection) {
|
if (connection is HttpsURLConnection) {
|
||||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
connection.sslSocketFactory = trustAllSslFactory
|
||||||
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.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,15 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -73,6 +80,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.NESMenu
|
||||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
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.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ErrorRed
|
import com.archipelago.app.ui.theme.ErrorRed
|
||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -121,6 +131,145 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
|
|||||||
} catch (_: Exception) {}
|
} 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
|
/** 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 —
|
* (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. */
|
* 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
|
/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving
|
||||||
* the kiosk and coming back reattaches the LIVE page — no reload, no
|
* the kiosk and coming back reattaches the LIVE page — no reload, no
|
||||||
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
|
* 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 {
|
private object KioskWebView {
|
||||||
var instance: WebView? = null
|
var instance: WebView? = null
|
||||||
var url: String? = null
|
var url: String? = null
|
||||||
@@ -145,7 +298,7 @@ private object KioskWebView {
|
|||||||
// registered interface objects call through these, so reattaching the
|
// registered interface objects call through these, so reattaching the
|
||||||
// retained view re-points them instead of leaving stale closures.
|
// retained view re-points them instead of leaving stale closures.
|
||||||
var onRouteOutbound: (String) -> Unit = {}
|
var onRouteOutbound: (String) -> Unit = {}
|
||||||
var onOpenInApp: (String) -> Unit = {}
|
var onOpenInApp: (InAppLaunch) -> Unit = {}
|
||||||
var onQrOpen: () -> Unit = {}
|
var onQrOpen: () -> Unit = {}
|
||||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||||
var onQrClose: () -> Unit = {}
|
var onQrClose: () -> Unit = {}
|
||||||
@@ -172,6 +325,11 @@ private fun injectSafeAreaVars(view: WebView) {
|
|||||||
val density = view.resources.displayMetrics.density
|
val density = view.resources.displayMetrics.density
|
||||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||||
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / 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(
|
view.evaluateJavascript(
|
||||||
"""
|
"""
|
||||||
(function() {
|
(function() {
|
||||||
@@ -191,19 +349,28 @@ private fun injectSafeAreaVars(view: WebView) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In-app browser pages (node apps + same-node links) don't consume the
|
/** Status-bar treatment for in-app pages, the contract being: the page's
|
||||||
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
|
* BACKGROUND (colour or imagery) extends up into the status-bar area, while
|
||||||
* their content ran up under the status bar. Pad the document body down by
|
* page CONTENT always starts below it. The WebView draws edge-to-edge; this
|
||||||
* the status-bar height: the padded strip shows the page's OWN background
|
* injection makes any page honour that contract:
|
||||||
* (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.
|
|
||||||
*
|
*
|
||||||
* Body padding only moves normal-flow content. fixed/sticky elements anchored
|
* - `body{padding-top}` moves normal-flow content down; body background
|
||||||
* at the viewport top (IndeeHub's floating header) stayed glued under the
|
* (colour + background-image) keeps painting across the padded strip.
|
||||||
* status bar, so we also push each of those down by the inset — once, marked
|
* - The strip must show the page's EFFECTIVE background, not body's declared
|
||||||
* via data attribute — and keep a throttled MutationObserver running so
|
* one — LND declares a white body under a dark full-height root, which
|
||||||
* headers an SPA mounts after load get the same treatment.
|
* painted a white bar. We sample the rendered background at the top of the
|
||||||
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
* 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) {
|
private fun injectTopInset(view: WebView) {
|
||||||
val insets = view.rootWindowInsets ?: return
|
val insets = view.rootWindowInsets ?: return
|
||||||
val density = view.resources.displayMetrics.density
|
val density = view.resources.displayMetrics.density
|
||||||
@@ -213,45 +380,96 @@ private fun injectTopInset(view: WebView) {
|
|||||||
"""
|
"""
|
||||||
(function() {
|
(function() {
|
||||||
var SAT = $sat;
|
var SAT = $sat;
|
||||||
var s = document.getElementById('archy-top-inset');
|
var CLEAR = 'rgba(0, 0, 0, 0)';
|
||||||
if (!s) {
|
function styleEl() {
|
||||||
s = document.createElement('style');
|
var s = document.getElementById('archy-top-inset');
|
||||||
s.id = 'archy-top-inset';
|
if (!s) {
|
||||||
(document.head || document.documentElement).appendChild(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) {
|
function push(el) {
|
||||||
if (el.dataset.archyInset) return;
|
if (el.dataset.archyInset) return;
|
||||||
var cs = getComputedStyle(el);
|
var cs = getComputedStyle(el);
|
||||||
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
|
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
|
||||||
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
|
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
|
||||||
if (isNaN(top) || top >= SAT) return;
|
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';
|
el.dataset.archyInset = '1';
|
||||||
}
|
}
|
||||||
function sweep() {
|
function sweep() {
|
||||||
if (!document.body) return;
|
if (!document.body) return;
|
||||||
// Fixed/sticky bars live shallow in the tree (portals mount on
|
// Fixed/sticky bars live shallow (portals mount on body); the
|
||||||
// body); depth cap keeps the computed-style pass off big lists.
|
// depth cap keeps the computed-style pass off big lists.
|
||||||
var els = document.body.querySelectorAll(
|
var els = document.body.querySelectorAll(
|
||||||
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
|
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
|
||||||
for (var i = 0; i < els.length; i++) push(els[i]);
|
for (var i = 0; i < els.length; i++) push(els[i]);
|
||||||
}
|
}
|
||||||
|
apply();
|
||||||
sweep();
|
sweep();
|
||||||
if (!window.__archyInsetObserver) {
|
if (!window.__archyInsetObserver) {
|
||||||
var queued = false, last = 0;
|
var queued = false, last = 0, idleSweeps = 0;
|
||||||
window.__archyInsetObserver = new MutationObserver(function() {
|
var obs = new MutationObserver(function() {
|
||||||
if (queued) return;
|
if (queued) return;
|
||||||
queued = true;
|
queued = true;
|
||||||
var wait = Math.max(0, 250 - (Date.now() - last));
|
var wait = Math.max(0, 250 - (Date.now() - last));
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
queued = false;
|
queued = false;
|
||||||
last = Date.now();
|
last = Date.now();
|
||||||
|
var before = document.querySelectorAll('[data-archy-inset]').length;
|
||||||
|
apply();
|
||||||
sweep();
|
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);
|
}, wait);
|
||||||
});
|
});
|
||||||
window.__archyInsetObserver.observe(document.documentElement,
|
window.__archyInsetObserver = obs;
|
||||||
|
obs.observe(document.documentElement,
|
||||||
{ childList: true, subtree: true });
|
{ childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -272,18 +490,69 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
/** Last origin that actually answered, per LAN|mesh pair — so a relaunch
|
||||||
* (patient — a cold session may still be establishing). If NEITHER answers,
|
* starts loading in milliseconds instead of re-running discovery probes.
|
||||||
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
|
* Persisted: the in-memory map alone only survived the process, which made
|
||||||
* unreachable, and loading it just produced a confusing "can't reach
|
* exactly the launch that mattered (cold start next morning) the slow one. */
|
||||||
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
|
private object StartUrlCache {
|
||||||
* instead means the load retries against the path that's actually coming up,
|
private const val STORE = "start_url_cache"
|
||||||
* and any error shows the mesh address rather than a dead LAN IP. */
|
private val lastGood = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: 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) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
val key = StartUrlCache.key(lanUrl, meshUrl)
|
||||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
val cached = StartUrlCache.get(context, key)
|
||||||
meshUrl ?: lanUrl
|
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.
|
/** 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.
|
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
|
||||||
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
||||||
var raceNonce by remember { mutableIntStateOf(0) }
|
var raceNonce by remember { mutableIntStateOf(0) }
|
||||||
|
var kioskCanGoBack by remember { mutableStateOf(false) }
|
||||||
|
val appContext = LocalContext.current.applicationContext
|
||||||
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
|
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
|
||||||
// A retained live session exists — reattach instantly: no race, no
|
// A retained live session exists — reattach instantly: no race, no
|
||||||
// reload, no re-login (remote ⇄ dashboard round trip).
|
// reload, no re-login (remote ⇄ dashboard round trip).
|
||||||
@@ -365,7 +636,7 @@ fun WebViewScreen(
|
|||||||
startUrl = serverUrl
|
startUrl = serverUrl
|
||||||
return@LaunchedEffect
|
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).
|
// Starting on the mesh: don't bounce back to it on error (it IS it).
|
||||||
if (picked != serverUrl) triedMeshFallback = true
|
if (picked != serverUrl) triedMeshFallback = true
|
||||||
startUrl = picked
|
startUrl = picked
|
||||||
@@ -388,7 +659,7 @@ fun WebViewScreen(
|
|||||||
// A node app that refused iframing, opened in a local WebView overlay.
|
// A node app that refused iframing, opened in a local WebView overlay.
|
||||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||||
// while this is shown, so closing it returns instantly with no reload.
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
var inAppLaunch by remember { mutableStateOf<InAppLaunch?>(null) }
|
||||||
|
|
||||||
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
|
// 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) —
|
// the ULA while app links may carry the LAN IP (and vice versa) —
|
||||||
@@ -439,7 +710,10 @@ fun WebViewScreen(
|
|||||||
pendingFileChooser = null
|
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()
|
webView?.goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,8 +762,13 @@ fun WebViewScreen(
|
|||||||
text = stringResource(R.string.retry),
|
text = stringResource(R.string.retry),
|
||||||
onClick = {
|
onClick = {
|
||||||
// Re-race LAN vs mesh — the network we're on may have
|
// 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.
|
// an errored session must genuinely reload.
|
||||||
|
StartUrlCache.invalidate(
|
||||||
|
webViewContext,
|
||||||
|
StartUrlCache.key(serverUrl, meshFallbackUrl),
|
||||||
|
)
|
||||||
KioskWebView.drop()
|
KioskWebView.drop()
|
||||||
webView = null
|
webView = null
|
||||||
hasError = false
|
hasError = false
|
||||||
@@ -570,7 +849,7 @@ fun WebViewScreen(
|
|||||||
// - different host → the phone's real browser
|
// - different host → the phone's real browser
|
||||||
fun routeOutbound(url: String) {
|
fun routeOutbound(url: String) {
|
||||||
if (isSameNode(url)) {
|
if (isSameNode(url)) {
|
||||||
inAppUrl = url
|
inAppLaunch = InAppLaunch(url)
|
||||||
} else {
|
} else {
|
||||||
openExternalUrl(context, url)
|
openExternalUrl(context, url)
|
||||||
}
|
}
|
||||||
@@ -586,7 +865,7 @@ fun WebViewScreen(
|
|||||||
// launch"). These assignments re-point the live
|
// launch"). These assignments re-point the live
|
||||||
// interface objects at THIS composition every attach.
|
// interface objects at THIS composition every attach.
|
||||||
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
|
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
|
||||||
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
|
KioskWebView.onOpenInApp = { launch -> inAppLaunch = launch }
|
||||||
KioskWebView.onQrOpen = {
|
KioskWebView.onQrOpen = {
|
||||||
walletScannerStatus = null
|
walletScannerStatus = null
|
||||||
walletScannerVisible = true
|
walletScannerVisible = true
|
||||||
@@ -607,16 +886,38 @@ fun WebViewScreen(
|
|||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun openInApp(url: String) {
|
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",
|
"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:
|
// Wallet QR bridge. The web scan modal calls:
|
||||||
// window.ArchipelagoQr.open() — show the native scanner
|
// window.ArchipelagoQr.open() — show the native scanner
|
||||||
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
||||||
// window.ArchipelagoQr.close() — code accepted, tear down
|
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||||
|
// window.ArchipelagoQr.prewarm() — scan is likely; warm the camera
|
||||||
// Decodes flow back through window.__archyQrResult(text);
|
// Decodes flow back through window.__archyQrResult(text);
|
||||||
// a user cancel calls window.__archyQrCancelled().
|
// a user cancel calls window.__archyQrCancelled().
|
||||||
if (reused == null) addJavascriptInterface(
|
if (reused == null) addJavascriptInterface(
|
||||||
@@ -626,6 +927,16 @@ fun WebViewScreen(
|
|||||||
webViewRef.post { KioskWebView.onQrOpen() }
|
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
|
@android.webkit.JavascriptInterface
|
||||||
fun setStatus(message: String, isError: Boolean) {
|
fun setStatus(message: String, isError: Boolean) {
|
||||||
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
|
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
|
||||||
@@ -643,13 +954,30 @@ fun WebViewScreen(
|
|||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
hasError = false
|
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?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
kioskCanGoBack = view?.canGoBack() == true
|
||||||
if (view == null) return
|
if (view == null) return
|
||||||
|
|
||||||
injectSafeAreaVars(view)
|
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 /
|
// Auto-login with the stored password (QR pairing /
|
||||||
// saved server) — only on our own server's pages
|
// 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
|
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||||
inAppUrl?.let { target ->
|
inAppLaunch?.let { target ->
|
||||||
InAppBrowser(
|
InAppBrowser(
|
||||||
url = target,
|
url = target.url,
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
meshUrl = meshFallbackUrl,
|
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
|
// First-launch teaching overlay for the three-finger hold — armed
|
||||||
// ~2 minutes after login so it never fights the splash/first look.
|
// ~2 minutes after login so it never fights the splash/first look.
|
||||||
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
||||||
!isLoading && inAppUrl == null
|
!isLoading && inAppLaunch == null
|
||||||
) {
|
) {
|
||||||
GestureHintOverlay(
|
GestureHintOverlay(
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
@@ -1008,22 +1338,23 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
/** Catalog icons re-fetched over the mesh on every app open made the loader
|
||||||
* can be shown on the loading screen before the WebView reports onReceivedIcon
|
* show the fallback logo first — cache the handful of icons a node has. */
|
||||||
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
private val iconCache = androidx.collection.LruCache<String, Bitmap>(24)
|
||||||
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
|
||||||
|
/** 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 {
|
return try {
|
||||||
val u = android.net.Uri.parse(pageUrl)
|
val conn = (java.net.URL(imageUrl).openConnection()
|
||||||
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()
|
|
||||||
as java.net.HttpURLConnection).apply {
|
as java.net.HttpURLConnection).apply {
|
||||||
connectTimeout = 4000
|
connectTimeout = 4000
|
||||||
readTimeout = 4000
|
readTimeout = 4000
|
||||||
instanceFollowRedirects = true
|
instanceFollowRedirects = true
|
||||||
}
|
}
|
||||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||||
|
?.also { iconCache.put(imageUrl, it) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1374,8 @@ private fun InAppBrowser(
|
|||||||
url: String,
|
url: String,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
meshUrl: String? = null,
|
meshUrl: String? = null,
|
||||||
|
appIcon: String? = null,
|
||||||
|
appName: String? = null,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -1057,16 +1390,23 @@ private fun InAppBrowser(
|
|||||||
// real <title> (onReceivedTitle upgrades it).
|
// real <title> (onReceivedTitle upgrades it).
|
||||||
var title by remember {
|
var title by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
android.net.Uri.parse(url).host
|
appName
|
||||||
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
?: android.net.Uri.parse(url).host
|
||||||
|
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
||||||
?: "Archipelago",
|
?: "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 progress by remember { mutableIntStateOf(0) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var canGoBack by remember { mutableStateOf(false) }
|
var canGoBack by remember { mutableStateOf(false) }
|
||||||
var canGoForward 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
|
// Same camera bridge as the main WebView — node apps opened in the overlay
|
||||||
// (e.g. anything with a QR scanner) get getUserMedia too.
|
// (e.g. anything with a QR scanner) get getUserMedia too.
|
||||||
@@ -1080,13 +1420,12 @@ private fun InAppBrowser(
|
|||||||
pendingWebPermission = null
|
pendingWebPermission = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the loading-screen icon immediately from a best-effort favicon
|
// Fetch the app's catalog icon for the loader (absolute URL from the web
|
||||||
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
// UI). No favicon fallback — the pixel logo is the brand fallback.
|
||||||
// loader shows an icon right away instead of staying blank until the page
|
LaunchedEffect(appIcon) {
|
||||||
// parses its <head> (which is what made the loader look stuck).
|
if (appIcon != null) {
|
||||||
LaunchedEffect(url) {
|
loaderIcon = withContext(Dispatchers.IO) { fetchBitmap(appIcon) }
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
}
|
||||||
if (fetched != null && favicon == null) favicon = fetched
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back: walk the in-app history first, then close the overlay.
|
// Back: walk the in-app history first, then close the overlay.
|
||||||
@@ -1109,13 +1448,13 @@ private fun InAppBrowser(
|
|||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
// Bottom inset handled by the touch-shield strip below the bar.
|
// 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(
|
.windowInsetsPadding(
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
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.
|
// WebView + loading overlay fill the area above the bottom control bar.
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
@@ -1131,6 +1470,9 @@ private fun InAppBrowser(
|
|||||||
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
|
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||||
|
// served over plain HTTP too — same dead-clipboard trap.
|
||||||
|
addClipboardBridge()
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
webChromeClient = object : WebChromeClient() {
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
@@ -1141,10 +1483,6 @@ private fun InAppBrowser(
|
|||||||
if (!t.isNullOrBlank()) title = t
|
if (!t.isNullOrBlank()) title = t
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
|
||||||
if (icon != null) favicon = icon
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPermissionRequest(request: PermissionRequest) {
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
||||||
request.deny()
|
request.deny()
|
||||||
@@ -1165,14 +1503,34 @@ private fun InAppBrowser(
|
|||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||||
loading = true
|
loading = true
|
||||||
view?.let { injectTopInset(it) }
|
loadError = false
|
||||||
|
view?.let {
|
||||||
|
injectTopInset(it)
|
||||||
|
injectClipboardShim(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, u: String?) {
|
override fun onPageFinished(view: WebView?, u: String?) {
|
||||||
loading = false
|
loading = false
|
||||||
canGoBack = view?.canGoBack() == true
|
canGoBack = view?.canGoBack() == true
|
||||||
canGoForward = view?.canGoForward() == 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) {
|
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.
|
// 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -1225,19 +1625,53 @@ private fun InAppBrowser(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
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(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val fav = favicon
|
val icon = loaderIcon
|
||||||
if (fav != null) {
|
if (icon != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = fav.asImageBitmap(),
|
bitmap = icon.asImageBitmap(),
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CircularProgressIndicator(color = BitcoinOrange)
|
PixelArtLogo(Modifier.size(48.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(18.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="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="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="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>
|
</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
|
// (so the tap silently no-ops). The native bridge is reliable; fall back to
|
||||||
// window.open in a plain mobile browser.
|
// window.open in a plain mobile browser.
|
||||||
const native = (window as any).ArchipelagoNative
|
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') {
|
if (native && typeof native.openInApp === 'function') {
|
||||||
native.openInApp(store.url)
|
native.openInApp(store.url)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
||||||
</div>
|
</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>
|
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
class="text-sm font-medium"
|
class="text-sm font-medium"
|
||||||
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
: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>
|
||||||
<span
|
<span
|
||||||
v-if="isOnchain(tx)"
|
v-if="isOnchain(tx)"
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 mt-0.5">
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
<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>
|
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,6 +167,18 @@ function isOnchain(tx: WalletTransaction): boolean {
|
|||||||
return !tx.kind || tx.kind === 'onchain'
|
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 {
|
function kindLabel(tx: WalletTransaction): string {
|
||||||
if (tx.kind === 'lightning') return '⚡ Lightning'
|
if (tx.kind === 'lightning') return '⚡ Lightning'
|
||||||
if (tx.kind === 'cashu') return 'Cashu'
|
if (tx.kind === 'cashu') return 'Cashu'
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { ref, watch } from 'vue'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { recordAppLaunch } from '@/utils/appUsage'
|
import { recordAppLaunch } from '@/utils/appUsage'
|
||||||
import { requestExternalOpen } from '@/api/remote-relay'
|
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 { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,6 +201,17 @@ export interface NostrConsentRequest {
|
|||||||
reject: () => void
|
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', () => {
|
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const url = ref('')
|
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 runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||||
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||||
if (launchUrl) {
|
if (launchUrl) {
|
||||||
openInAppOrNewTab(launchUrl)
|
openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +247,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
if (IS_DEMO && isDemoExternal(appId)) {
|
if (IS_DEMO && isDemoExternal(appId)) {
|
||||||
const ext = demoAppUrl(appId)
|
const ext = demoAppUrl(appId)
|
||||||
if (ext) {
|
if (ext) {
|
||||||
if (mobile) openInAppOrNewTab(ext)
|
if (mobile) openInAppOrNewTab(ext, launchMeta(appId))
|
||||||
else openExternal(ext)
|
else openExternal(ext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -248,7 +260,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
||||||
const launchUrl = directAppUrl(appId)
|
const launchUrl = directAppUrl(appId)
|
||||||
if (launchUrl) {
|
if (launchUrl) {
|
||||||
if (mobile) openInAppOrNewTab(launchUrl)
|
if (mobile) openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||||
else openExternal(launchUrl)
|
else openExternal(launchUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -327,7 +339,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// Companion app: never fall through to the iframe overlay — hand the URL
|
// Companion app: never fall through to the iframe overlay — hand the URL
|
||||||
// to the native in-app WebView instead (see openSession).
|
// to the native in-app WebView instead (see openSession).
|
||||||
if (!IS_DEMO && isCompanionApp()) {
|
if (!IS_DEMO && isCompanionApp()) {
|
||||||
openInAppOrNewTab(launchUrl)
|
openInAppOrNewTab(launchUrl, resolvedId ? launchMeta(resolvedId) : undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1452,6 +1452,24 @@ html.kiosk-safe-area #app {
|
|||||||
overflow: hidden;
|
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 for glass containers */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
interface ArchipelagoNativeBridge {
|
interface ArchipelagoNativeBridge {
|
||||||
openExternal?: (url: string) => void
|
openExternal?: (url: string) => void
|
||||||
openInApp?: (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 {
|
function nativeBridge(): ArchipelagoNativeBridge | undefined {
|
||||||
@@ -46,9 +55,15 @@ export function openExternalUrl(url: string): void {
|
|||||||
* inside Archipelago with the native back/forward/reload/close controls.
|
* inside Archipelago with the native back/forward/reload/close controls.
|
||||||
* - Plain mobile browser (PWA): open directly in a new browser tab.
|
* - 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
|
if (!url) return
|
||||||
const native = nativeBridge()
|
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') {
|
if (native && typeof native.openInApp === 'function') {
|
||||||
native.openInApp(url)
|
native.openInApp(url)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user