Merge pull request 'fix(wallet+companion): pre-demo blockers — Send modal TDZ crash, cold-start inset collapse, loader branding — 0.5.10' (#118) from fix/safe-area-insets into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m47s

This commit is contained in:
lfg2025 2026-07-24 08:05:30 +00:00
commit 9a66f22138
5 changed files with 79 additions and 48 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 29
versionName = "0.5.9"
versionCode = 30
versionName = "0.5.10"
vectorDrawables {
useSupportLibrary = true

View File

@ -1,6 +1,7 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -12,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -37,7 +39,22 @@ fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PixelArtLogo(Modifier.size(96.dp))
// The brand's circle-container logo (as on the connect screen /
// web login): pixel-art "a" centered in a black disc.
Box(
Modifier
.size(120.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color.Black)
.border(
1.dp,
Color.White.copy(alpha = 0.14f),
androidx.compose.foundation.shape.CircleShape,
),
contentAlignment = Alignment.Center,
) {
PixelArtLogo(Modifier.size(64.dp))
}
Spacer(Modifier.height(20.dp))
Text(
text = "F*CK IPs MESH",

View File

@ -150,6 +150,34 @@ private object KioskWebView {
}
}
/** Inject the safe-area CSS vars from the CURRENT window insets. Android
* WebView doesn't populate env(safe-area-inset-*); worse, on a cold start
* onPageFinished can run before the view is attached rootWindowInsets is
* null then, and injecting 0px collapsed the UI's top/bottom margins (and
* put the tab bar inside the gesture zone, killing its taps). Called from
* onPageFinished, from the window-insets listener (fires when real insets
* arrive), and on reattach. */
private fun injectSafeAreaVars(view: WebView) {
val insets = view.rootWindowInsets ?: return // listener re-fires when real
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()
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
})();
""".trimIndent(),
null,
)
}
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
val u = android.net.Uri.parse(base)
@ -429,6 +457,14 @@ fun WebViewScreen(
val webViewRef = this
// Re-inject the safe-area vars whenever REAL insets
// arrive — on cold start onPageFinished often beats
// window attachment and would otherwise bake in 0px.
setOnApplyWindowInsetsListener { v, insets ->
(v as? WebView)?.let { injectSafeAreaVars(it) }
v.onApplyWindowInsets(insets)
}
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
@ -515,34 +551,7 @@ fun WebViewScreen(
isLoading = false
if (view == null) return
// Convert physical pixels → CSS pixels
val density = view.resources.displayMetrics.density
val satPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.statusBars())
?.top ?: 0
val sabPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.navigationBars())
?.bottom ?: 0
val sat = (satPx / density).toInt()
val sab = (sabPx / density).toInt()
// Android WebView doesn't populate env(safe-area-inset-*).
// Set CSS custom properties the web UI can use as fallback:
// var(--safe-area-top, env(safe-area-inset-top, 0px))
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
})();
""".trimIndent(),
null,
)
injectSafeAreaVars(view)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages
@ -733,10 +742,12 @@ fun WebViewScreen(
} else {
// Reattached views keep stale measurements until an
// input event — that was the top/bottom UI being
// wrong until a tap. Force a fresh pass.
// wrong until a tap. Force a fresh pass, and re-sync
// the page's safe-area vars while we're at it.
post {
requestLayout()
invalidate()
injectSafeAreaVars(this)
}
}
}

View File

@ -193,22 +193,6 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
@ -231,6 +215,25 @@ const effectiveMethod = computed(() => {
return 'lightning'
})
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
// MUST come after effectiveMethod: watch() evaluates its source getter at
// setup, and reading a const still in its temporal dead zone crashed the
// whole modal at mount ("Cannot access 'R' before initialization").
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted.