fix(wallet+companion): pre-demo blockers — Send modal TDZ crash, cold-start inset collapse, loader branding — 0.5.10

- SendBitcoinModal crashed AT MOUNT on device ('Cannot access R before
  initialization'): the invoice-first block's watch() evaluates its
  computed source at setup, which read effectiveMethod while still in
  its temporal dead zone (declared 20 lines later). Send button did
  nothing and the failure poisoned sibling modal data. Block moved below
  effectiveMethod, with a comment so it can't drift back.
- Cold start injected 0px safe-area vars (onPageFinished can beat window
  attachment; rootWindowInsets null) — UI lost top/bottom margins and
  the tab bar sat in the gesture zone eating taps. Injection extracted
  to injectSafeAreaVars(), re-fired from an OnApplyWindowInsetsListener
  when real insets arrive, on page finish, and on reattach.
- MeshLoadingScreen wears the proper brand: pixel 'a' in the black
  circle-container + F*CK IPS MESH wordmark.

Served APK: 0.5.10 (vc30). Reproduced+diagnosed live on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-24 09:03:43 +01:00
co-authored by Claude Fable 5
parent 8af2ca4ac2
commit 504944fd08
5 changed files with 79 additions and 48 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 29 versionCode = 30
versionName = "0.5.9" versionName = "0.5.10"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -1,6 +1,7 @@
package com.archipelago.app.ui.components package com.archipelago.app.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -37,7 +39,22 @@ fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { 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)) Spacer(Modifier.height(20.dp))
Text( Text(
text = "F*CK IPs MESH", text = "F*CK IPs MESH",
@@ -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]. */ /** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
val u = android.net.Uri.parse(base) val u = android.net.Uri.parse(base)
@@ -429,6 +457,14 @@ fun WebViewScreen(
val webViewRef = this 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: // Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay // - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the // (this is the "open in browser" target for apps the
@@ -515,34 +551,7 @@ fun WebViewScreen(
isLoading = false isLoading = false
if (view == null) return if (view == null) return
// Convert physical pixels → CSS pixels injectSafeAreaVars(view)
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,
)
// Auto-login with the stored password (QR pairing / // Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages // saved server) — only on our own server's pages
@@ -733,10 +742,12 @@ fun WebViewScreen(
} else { } else {
// Reattached views keep stale measurements until an // Reattached views keep stale measurements until an
// input event — that was the top/bottom UI being // 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 { post {
requestLayout() requestLayout()
invalidate() invalidate()
injectSafeAreaVars(this)
} }
} }
} }
Binary file not shown.
+19 -16
View File
@@ -193,22 +193,6 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere // Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false }) 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 // 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. // button where it can't work the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
@@ -231,6 +215,25 @@ const effectiveMethod = computed(() => {
return 'lightning' 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 // --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted. // --- balance reduction before anything is sent or any token is minted.