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>
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
versionCode = 46
|
||||
versionName = "0.5.26"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = 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
|
||||
@@ -100,6 +108,7 @@ 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
|
||||
@@ -137,6 +146,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 +158,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 = {}
|
||||
@@ -191,19 +204,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,31 +235,70 @@ 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;
|
||||
@@ -248,6 +309,7 @@ private fun injectTopInset(view: WebView) {
|
||||
setTimeout(function() {
|
||||
queued = false;
|
||||
last = Date.now();
|
||||
apply();
|
||||
sweep();
|
||||
}, wait);
|
||||
});
|
||||
@@ -272,18 +334,46 @@ 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. */
|
||||
/** Last origin that actually answered, per LAN|mesh pair — so a relaunch
|
||||
* starts loading in milliseconds instead of re-running discovery probes.
|
||||
* Wrong guesses (network changed) are caught by a fast revalidation probe. */
|
||||
private object StartUrlCache {
|
||||
val lastGood = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
}
|
||||
|
||||
/** Fastest answering origin, decided the fast way:
|
||||
* 1. The origin that worked last time, revalidated with a short probe —
|
||||
* the warm path costs one TCP connect (~10 ms on LAN or a warm mesh
|
||||
* session), which is why launches should NEVER feel slow twice.
|
||||
* 2. Otherwise LAN and mesh probes race IN PARALLEL (they were serial:
|
||||
* 2.5 s of dead LAN wait before the mesh probe even started — the
|
||||
* "lightning sometimes, garbage other times" launch variance).
|
||||
* 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). */
|
||||
private suspend fun pickStartUrl(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 = "$lanUrl|${meshUrl ?: ""}"
|
||||
StartUrlCache.lastGood[key]?.let { cached ->
|
||||
if (tcpAnswers(cached, 1500)) return@withContext cached
|
||||
}
|
||||
val winner = kotlinx.coroutines.coroutineScope {
|
||||
val lan = async { if (tcpAnswers(lanUrl, 2500)) lanUrl else null }
|
||||
val mesh = async {
|
||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) meshUrl else null
|
||||
}
|
||||
val (first, other) = kotlinx.coroutines.selects.select<Pair<String?, kotlinx.coroutines.Deferred<String?>>> {
|
||||
lan.onAwait { it to mesh }
|
||||
mesh.onAwait { it to lan }
|
||||
}
|
||||
if (first != null) {
|
||||
other.cancel()
|
||||
first
|
||||
} else {
|
||||
other.await()
|
||||
}
|
||||
}
|
||||
winner?.also { StartUrlCache.lastGood[key] = it } ?: meshUrl ?: lanUrl
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
@@ -388,7 +478,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 +529,7 @@ fun WebViewScreen(
|
||||
pendingFileChooser = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
BackHandler(enabled = inAppLaunch == null && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
@@ -570,7 +660,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 +676,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,7 +697,23 @@ 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",
|
||||
@@ -902,12 +1008,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 +1041,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,16 +1116,11 @@ 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? {
|
||||
/** 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? {
|
||||
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
|
||||
@@ -1043,6 +1146,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 +1162,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 +1192,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 +1220,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(
|
||||
@@ -1141,10 +1252,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,6 +1272,7 @@ private fun InAppBrowser(
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
loadError = false
|
||||
view?.let { injectTopInset(it) }
|
||||
}
|
||||
|
||||
@@ -1175,6 +1283,19 @@ private fun InAppBrowser(
|
||||
view?.let { injectTopInset(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) {
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
@@ -1217,7 +1338,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 +1388,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))
|
||||
|
||||
Reference in New Issue
Block a user