Compare commits

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