Compare commits

...
Author SHA1 Message Date
DorianandClaude Opus 5 75364178ad chore(android): update companion apk download
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:46:56 +01:00
DorianandClaude Opus 5 b9dc0fb1bd feat(companion): native clipboard bridge, faster QR scanner, restart control — 0.5.27
- Clipboard: the node UI is plain HTTP, so the WebView withholds
  navigator.clipboard and the page's own polyfill can only fake a write —
  readText() resolved to '' and every Paste button in the wallet silently did
  nothing. New ArchipelagoClipboard JS bridge (copy/paste over the Android
  clipboard, answering through window.__archyClipboardResult) plus an injected
  shim that points navigator.clipboard at it, so existing pages get working
  copy AND paste with no web-side change. Registered on the kiosk WebView and
  the in-app browser (node apps are plain HTTP too).
- QR scanner: two-tier decode — a cheap centre-crop pass (no TRY_HARDER) at
  ~18/s catches the well-framed case immediately, while the old full-frame
  TRY_HARDER + inverted pass still runs at ~5/s for dense, off-centre and
  light-on-dark codes. Adds camera/decoder prewarm (on first node page load and
  via ArchipelagoQr.prewarm()), a torch toggle in both scanners, tap-to-focus,
  a 1x/1.5x zoom hunt after 3s of no decode, and a success haptic.
- Restart: the retained kiosk WebView is a process-scoped static and the FIPS
  service keeps the process alive after a recents swipe, so closing the app
  stopped restarting it. MainActivity releases it when the task is finishing,
  and the hub menu gains a Restart card (mesh service keeps running).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:46:24 +01:00
DorianandClaude Fable 5 f772620d17 feat(companion): version chip at the hub menu's foot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:12:24 +01:00
DorianandClaude Fable 5 372ff7aa65 perf(companion): cold-start and interaction overhaul from the launch audit
- Splash held until launch state resolves; introSeen+activeServer now one
  combined DataStore emission (no black frame, no Connect-screen flash).
- Application warmups: fips core dlopen, DataStore first-read, Chromium
  provider load — all off the first-paint window.
- Start-origin cache persisted across process death; cached/LAN/mesh
  probes race three-wide (cold relaunch starts in milliseconds).
- ServerConnect: tunnel warms at screen entry; LAN/mesh race in parallel
  (was 5s dead LAN wait, then tunnel start, then 15s probes); SSLContext
  built once, not per attempt.
- Party: QR encode off-main + debounced (was per keystroke), share-APK
  copy off-main + cached; Flare: sampled off-main photo decode,
  derived message filter; Intro: graphicsLayer alpha, parallel fade.
- Kiosk: safe-area JS injection deduped per insets value, inset observer
  disconnects once the page settles, BackHandler off live Chromium
  calls, loader icons LRU-cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:09:15 +01:00
DorianandClaude Fable 5 f893a9e804 chore(android): update companion apk download
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:09:15 +01:00
DorianandClaude Fable 5 9628dd8bc2 feat(web): app-identity launch bridge + transactions scroll/pills/fee fixes
- openInAppEx(url, icon, name): companion's native loader gets the app's
  catalog icon + display name instead of a favicon.
- TransactionsModal: BaseModal's scroller owns scrolling (nested scroll
  container broke touch scroll on phones); filter pills move to a
  swipeable single-row rail (.pill-rail) that never wraps or squishes;
  outgoing rows show recipient-received amount with fee broken out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:09:15 +01:00
DorianandClaude Fable 5 4ea3fb2deb fix(companion): status-bar safe-area contract, branded loader+error screens, parallel launch probes — 0.5.26
- Safe-area v3: backgrounds extend under the status bar, content never
  does. Body padding + effective top background sampling (fixes white
  strip on light-bodied apps), fixed/sticky headers pushed below the bar,
  full-height overlays padded instead of shifted (no see-through strip).
- Loader: pulsating app-icon card (catalog icon via openInAppEx bridge,
  pixel-logo fallback) — site favicons removed.
- Branded CloudOff error screen with Retry; Chromium's stock error page
  can no longer be seen in app tabs.
- pickStartUrl: LAN/mesh probes race in parallel and the last-good origin
  is cached — kills the 2.5s serial dead-LAN wait behind launch variance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:07:39 +01:00
20 changed files with 1090 additions and 191 deletions
+2 -2
View File
@@ -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()
} }
} }
@@ -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
+16 -3
View File
@@ -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'
+17 -5
View File
@@ -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
} }
+18
View File
@@ -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;
+16 -1
View File
@@ -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