Files
gudari/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt
T

868 lines
39 KiB
Kotlin

package com.bitcointxoko.gudariwallet.ui
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.satsToFiat
import com.bitcointxoko.gudariwallet.util.parseApiError
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel"
class WalletViewModel(
val repo : WalletRepository,
val aliasRepo : NodeAliasRepository,
val lnurlCache: LnurlCacheRepository,
val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore,
val balanceVm : BalanceViewModel,
) : ViewModel() {
// ── Balance state ─────────────────────────────────────────────────────────
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
// ── Balance visible state ─────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden
fun toggleBalanceVisibility() {
val next = !_balanceHidden.value
_balanceHidden.value = next
balancePrefs.setBalanceHidden(next)
}
// ── Fiat cache ────────────────────────────────────────────────────────────
private val fiatCache = FiatRateCache(balancePrefs)
val fiatRate: Double?
get() = fiatCache.currentRate
val fiatCurrency: String? get() = _selectedCurrency.value
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
.mapNotNull { state ->
(state as? BalanceState.Success)?.let { s ->
if (s.fiatAmount != null && s.sats > 0)
s.sats.toDouble() / s.fiatAmount
else null
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)
private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency)
val selectedCurrency: StateFlow<String?> = _selectedCurrency
fun setSelectedCurrency(currency: String?) {
if (currency == _selectedCurrency.value) return
_selectedCurrency.value = currency
balancePrefs.setSelectedCurrency(currency)
fiatCache.invalidateRate()
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() }
} else {
balanceVm.applyFiat(null, null) // ← fix
}
}
companion object {
private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes
}
// ── Other states ──────────────────────────────────────────────────────────
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
val sendState: StateFlow<SendState> = _sendState
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToReceive: SharedFlow<Unit> = _navigateToReceive
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
val lightningAddress: StateFlow<String?> = _lightningAddress
// ── Init ──────────────────────────────────────────────────────────────────
init {
observePaymentEvents()
fetchLightningAddress()
}
// ── Fiat rate fetch + apply ───────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return
val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
?: run {
runCatching { repo.getFiatRate(currency) }
.getOrElse { e ->
Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}")
Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display")
return
}
.also { fresh -> fiatCache.putRate(currency, fresh) }
}
// ← fix: delegate to balanceVm instead of writing to _balanceState
val currentSats = (balanceVm.balanceState.value as? BalanceState.Success)?.sats ?: return
val fiat = satsToFiat(currentSats, rate)
Log.d(TAG, "FIAT [APPLY ] $currentSats sats ÷ $rate sats/$currency = $fiat $currency")
balanceVm.applyFiat(fiat, currency)
}
// ── Currency list ────────────────────────────────────────────────────────
suspend fun getSupportedCurrencies(): List<String> {
fiatCache.getCurrencies()?.let { return it }
// Cache miss (memory + disk) — fetch from network
return fetchAndCacheCurrencies()
}
private suspend fun fetchAndCacheCurrencies(): List<String> {
return runCatching { repo.getSupportedCurrencies() }
.getOrElse { e ->
Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}")
emptyList()
}
.also { list -> fiatCache.putCurrencies(list) }
}
// ── WebSocket event collection ────────────────────────────────────────────
private fun observePaymentEvents() {
viewModelScope.launch {
balanceVm.incomingPayment.collect { event ->
val rs = _receiveState.value
if (rs is ReceiveState.InvoiceReady) {
Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received")
_receiveState.value = ReceiveState.PaymentReceived(
amountSats = event.amountSats,
memo = event.memo,
checkingId = rs.paymentHash
)
}
}
}
}
private fun fetchLightningAddress() {
// If we already have a persisted address, show it immediately and
// still refresh in the background (address could have changed).
viewModelScope.launch {
runCatching { repo.getLightningAddress() }
.onSuccess { address ->
if (address != null) {
Log.d(TAG, "LNADDR [FETCH OK ] $address")
_lightningAddress.value = address
balancePrefs.setLightningAddress(address)
} else {
Log.d(TAG, "LNADDR [FETCH OK ] no pay link / username found")
}
}
.onFailure { e ->
Log.w(TAG, "LNADDR [FETCH ERR] ${e.message} — keeping cached value")
// Keep whatever was loaded from prefs; don't clear it on network error
}
}
}
// ── Receive ───────────────────────────────────────────────────────────────
fun createInvoice(amountSats: Long, memo: String) {
viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice
runCatching { repo.createInvoice(amountSats, memo) }
.onSuccess { invoice ->
val fallbackExpiry = System.currentTimeMillis() / 1000L + 600L
val expiresAt = invoice.expiry?.let { isoString ->
runCatching {
java.time.LocalDateTime
.parse(isoString, java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.toEpochSecond(java.time.ZoneOffset.UTC)
}.getOrNull()
} ?: fallbackExpiry
_receiveState.value = ReceiveState.InvoiceReady(
bolt11 = invoice.bolt11,
paymentHash = invoice.paymentHash,
amountSats = amountSats,
memo = memo,
expiresAt = expiresAt
)
}
.onFailure { e ->
Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice"))
}
}
}
fun resetReceiveState() { _receiveState.value = ReceiveState.Idle }
// ── Send ──────────────────────────────────────────────────────────────────
fun scan(rawInput: String) {
val normalized = SendInputDetector.normalize(rawInput)
val type = SendInputDetector.detect(rawInput)
Log.d(TAG, "SCAN [DETECT] input='$rawInput' → type=$type")
when (type) {
SendInputType.Unknown -> {
val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) {
"Lightning Login (LNURL-auth) is not yet supported"
} else {
"Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
}
_sendState.value = SendState.Error(msg)
}
SendInputType.Bip21 -> handleBip21Scan(rawInput)
SendInputType.Lud17Url -> handleLud17Scan(rawInput)
SendInputType.Bolt11 -> handleBolt11Scan(normalized)
else -> handleLnurlScan(normalized)
}
}
private fun handleWithdrawResponse(raw: LnurlScanResponse, lnurl: String) {
val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
_receiveState.value = ReceiveState.LnurlWithdrawReady(
k1 = raw.k1 ?: "",
callback = raw.callback ?: "",
defaultDescription = raw.defaultDescription ?: "",
minSats = minSats,
maxSats = maxSats,
domain = LnurlMetadataParser.extractDomain(raw.callback),
lnurl = lnurl
)
_sendState.value = SendState.Idle // clear scanning spinner
_navigateToReceive.tryEmit(Unit)
Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive")
}
fun executeWithdraw(
k1 : String,
callback : String,
amountSats: Long,
memo : String
) {
viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice
val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) }
if (invoiceResult.isFailure) {
val e = invoiceResult.exceptionOrNull() ?: Exception("Failed to create invoice")
Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice"))
return@launch
}
val invoice = invoiceResult.getOrThrow()
Log.d(TAG, "LNURL-WITHDRAW [INVOICE] ${invoice.paymentHash} — submitting to callback")
runCatching { repo.executeWithdraw(callback, k1, invoice.bolt11) }
.onSuccess { response ->
if (response.status == "OK") {
Log.d(TAG, "LNURL-WITHDRAW [CALLBACK OK] waiting for push payment")
_receiveState.value = ReceiveState.InvoiceReady(
bolt11 = invoice.bolt11,
paymentHash = invoice.paymentHash,
amountSats = amountSats,
memo = memo,
expiresAt = System.currentTimeMillis() / 1000L + 600L
)
} else {
val reason = response.reason
?.ifBlank { null }
?: "Withdraw service rejected the invoice"
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason")
_receiveState.value = ReceiveState.Error(reason)
}
}
.onFailure { e ->
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Withdraw failed"))
}
}
}
fun payBolt11(bolt11: String) {
viewModelScope.launch {
val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L
_sendState.value = SendState.Paying
runCatching { repo.payBolt11(bolt11) }
.onSuccess { response ->
val feeSats = runCatching {
repo.getPaymentDetail(response.paymentHash)
.details
?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats
)
refreshBalance()
}
.onFailure { e ->
Log.e(TAG, "SEND [PAY BOLT11 ERROR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Payment failed"))
}
}
}
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?
) {
viewModelScope.launch {
_sendState.value = SendState.Paying
// ── 1. Initial attempt (direct → proxy) ───────────────────────────────
val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL")
if (initial.isSuccess) {
recordPaymentSuccess(initial.getOrThrow(), amountMsat)
return@launch
}
// ── 2. Both paths failed — check if retrying is worthwhile ────────────
val initialError = initial.exceptionOrNull() ?: Exception("Payment failed")
if (isTerminalPayError(initialError)) {
Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying")
_sendState.value = SendState.Error(
parseApiError(initialError, "Payment failed")
)
return@launch
}
Log.w(TAG, "LNURL [PAY RETRY ] — invalidating cache and retrying with fresh scan")
lnurlCache.invalidate(lnurl)
// ── 3. Re-fetch metadata ──────────────────────────────────────────────
val freshRaw = rescanLnurl(lnurl) ?: return@launch // error already set
val scanProtocolError = lnurlProtocolError(freshRaw)
if (scanProtocolError != null) {
Log.w(TAG, "LNURL [RESCAN PROTO] $scanProtocolError")
_sendState.value = SendState.Error(scanProtocolError)
return@launch
}
lnurlCache.put(lnurl, freshRaw)
// ── 4. Final attempt (direct → proxy) with fresh metadata ─────────────
val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY")
if (retry.isSuccess) {
recordPaymentSuccess(retry.getOrThrow(), amountMsat)
return@launch
}
// ── 5. Final failure ──────────────────────────────────────────────────
val retryError = retry.exceptionOrNull() ?: Exception("Payment failed")
Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}")
_sendState.value = SendState.Error(
parseApiError(retryError, "Payment failed")
)
}
}
private fun isTerminalPayError(e: Throwable?): Boolean {
if (e == null) return false
val msg = e.message?.lowercase() ?: return false
return msg.contains("insufficient balance") ||
msg.contains("unauthorized") ||
msg.contains("amount") && msg.contains("bound")
}
// ── Step 1: fetch the LNURL callback invoice, decode it, analyse route hints
// Called after the user taps "Pay" in LnurlPayForm and passes biometric auth.
// Emits LnurlInvoiceReady (with routeRisk populated if fees are elevated).
fun fetchLnurlInvoice(
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat: Long,
comment : String?
) {
viewModelScope.launch {
_sendState.value = SendState.Scanning
// ── 1. Hit the callback URL — returns the raw bolt11 string
val bolt11 = runCatching {
repo.fetchLnurlCallbackInvoice(rawRes, amountMsat, comment)
}.getOrElse { e ->
Log.e(TAG, "LNURL [FETCH INVOICE ERR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, "Could not fetch invoice from recipient")
)
return@launch
}
// ── 2. Decode the bolt11 to get memo and route hints
val decoded = runCatching {
repo.decodeBolt11(bolt11)
}.getOrElse { e ->
Log.e(TAG, "LNURL [DECODE ERR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice"))
return@launch
}
// ── 3. Run route-hint fee analysis (same logic as the Bolt11 path)
val routeRisk = RouteHintAnalyzer.analyze(
amountMsat = amountMsat,
hints = decoded.routeHints
)
Log.d(TAG, "LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}")
// ── 4. Emit LnurlInvoiceReady — UI shows warning card if risk != null
_sendState.value = SendState.LnurlInvoiceReady(
bolt11 = bolt11,
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
memo = decoded.memo,
domain = domain,
lnurl = lnurl,
rawRes = rawRes,
amountMsat = amountMsat,
comment = comment,
payee = decoded.payee,
nodeAlias = null,
routeRisk = routeRisk,
allRouteHints = decoded.routeHints,
routeHintAliases = emptyMap()
)
resolveAliasesInBackground(
payee = decoded.payee,
routeHints = decoded.routeHints,
readState = { _sendState.value },
copyAlias = { state, alias ->
(state as? SendState.LnurlInvoiceReady)
?.takeIf { it.payee == decoded.payee }
?.copy(nodeAlias = alias)
},
copyHint = { state, pubkey, alias ->
(state as? SendState.LnurlInvoiceReady)
?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias))
}
)
}
}
// ── Step 2: pay the bolt11 that was fetched in fetchLnurlInvoice
// Called after the user confirms in LnurlInvoiceConfirmCard.
// Tries payBolt11 directly first; falls back to the full payLnurl path
// (which includes rescan/retry logic) if the direct attempt fails.
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) {
viewModelScope.launch {
_sendState.value = SendState.Paying
// ── 1. Direct bolt11 payment — fastest path, no server round-trip
val directResult = runCatching { repo.payBolt11(state.bolt11) }
if (directResult.isSuccess) {
val feeSats = runCatching {
repo.getPaymentDetail(directResult.getOrThrow().paymentHash)
.details?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = state.amountSats,
feeSats = feeSats
)
refreshBalance()
return@launch
}
Log.w(TAG, "LNURL [PAY INVOICE DIRECT FAIL] " +
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl")
// ── 2. Direct failed — delegate to payLnurl which has full
// rescan/retry logic and handles both direct + proxy paths
payLnurl(
rawRes = state.rawRes,
lnurl = state.lnurl,
amountMsat = state.amountMsat,
comment = state.comment
)
}
}
/**
* Gate for payment confirmation. Shows a biometric/device-credential prompt
* if the device supports it; calls [pay] directly if it does not.
*
* On a hard auth failure the send state is set to [SendState.Error] so the
* UI surfaces the message without any extra wiring.
*/
fun requestPayment(
activity: FragmentActivity,
subtitle: String,
pay : () -> Unit
) {
if (!BiometricHelper.canAuthenticate(activity)) {
// Device has no enrolled biometric or credential — pay directly.
pay()
return
}
BiometricHelper.prompt(
activity = activity,
subtitle = subtitle,
onSuccess = pay,
onError = { _, msg ->
_sendState.value = SendState.Error("Authentication failed: $msg")
}
)
}
fun resetSendState() { _sendState.value = SendState.Idle }
// ── Clipboard offer ───────────────────────────────────────────────────────
private var lastOfferedClipboard: String? = null
private val _clipboardOfferState = MutableStateFlow<ClipboardOfferState>(ClipboardOfferState.None)
val clipboardOfferState: StateFlow<ClipboardOfferState> = _clipboardOfferState
fun checkClipboard(text: String?) {
Log.d("Clipboard", "checkClipboard called with: $text")
val trimmed = text?.trim().takeIf { !it.isNullOrBlank() } ?: return
// ── Deduplicate: don't re-offer the same string twice ─────────────────
if (trimmed == lastOfferedClipboard) return
// ── Guard: ignore our own invoice ─────────────────────────────────────
val ownInvoice = (_receiveState.value as? ReceiveState.InvoiceReady)?.bolt11
if (ownInvoice != null && trimmed.equals(ownInvoice, ignoreCase = true)) {
Log.d("Clipboard", "Skipping own invoice")
lastOfferedClipboard = trimmed // still mark as seen so focus re-checks don't re-trigger
return
}
// ── Guard: ignore our own lightning address ────────────────────────────
val ownAddress = _lightningAddress.value
if (ownAddress != null && trimmed.equals(ownAddress, ignoreCase = true)) {
Log.d("Clipboard", "Skipping own lightning address")
lastOfferedClipboard = trimmed
return
}
val type = SendInputDetector.detect(trimmed)
if (type == SendInputType.Unknown) return
lastOfferedClipboard = trimmed
_clipboardOfferState.value = ClipboardOfferState.Detected(raw = trimmed, inputType = type)
}
fun dismissClipboardOffer() {
_clipboardOfferState.value = ClipboardOfferState.None
}
fun refreshBalance() = balanceVm.refreshBalance()
// ── Private helpers ───────────────────────────────────────────────────────
private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady {
return SendState.LnurlReady(
callback = raw.callback ?: "",
description = LnurlMetadataParser.parseDescription(raw.metadata),
domain = LnurlMetadataParser.extractDomain(raw.callback),
minSats = (raw.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
maxSats = (raw.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
commentAllowed = raw.commentAllowed ?: 0,
lnurl = lnurl,
rawRes = raw
)
}
/**
* Records a successful payment: fetches the fee, updates send state,
* and refreshes the balance. Centralises the 4 identical success blocks.
*/
private suspend fun recordPaymentSuccess(paymentHash: String, amountMsat: Long) {
val feeSats = runCatching {
repo.getPaymentDetail(paymentHash).details?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
feeSats = feeSats
)
refreshBalance()
}
/**
* Attempts a client-side LNURL payment, then falls back to the server proxy.
* Returns [Result.success] with the payment hash, or [Result.failure] with the
* last exception (from the proxy attempt) so the caller can inspect it without
* a second network round-trip.
*/
private suspend fun tryPayLnurlWithFallback(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
label : String
): Result<String> {
// 1. Client-side direct
val direct = runCatching { repo.payLnurlDirect(rawRes, amountMsat, comment) }
if (direct.isSuccess) {
Log.d(TAG, "LNURL [PAY $label DIRECT] success")
return Result.success(direct.getOrThrow().paymentHash)
}
Log.w(TAG, "LNURL [PAY $label DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy")
// 2. Server proxy — its exception becomes the Result.failure
val proxy = runCatching { repo.payLnurl(rawRes, lnurl, amountMsat, comment) }
if (proxy.isSuccess) {
Log.d(TAG, "LNURL [PAY $label PROXY] success")
return Result.success(proxy.getOrThrow().paymentHash)
}
Log.w(TAG, "LNURL [PAY $label PROXY FAIL] ${proxy.exceptionOrNull()?.message}")
return Result.failure(proxy.exceptionOrNull() ?: Exception("Payment failed"))
}
/**
* Re-fetches LNURL metadata: tries client-side first, then server proxy.
* Returns the fresh [LnurlScanResponse], or null if both fail (in which case
* [_sendState] is already set to [SendState.Error]).
*/
private suspend fun rescanLnurl(lnurl: String): LnurlScanResponse? {
val direct = runCatching { repo.scanLnurlDirect(lnurl) }
if (direct.isSuccess) {
Log.d(TAG, "LNURL [RESCAN DIRECT] success")
return direct.getOrThrow().rawRes
}
Log.w(TAG, "LNURL [RESCAN DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy rescan")
val proxy = runCatching { repo.scanLnurl(lnurl) }
if (proxy.isSuccess) {
return proxy.getOrThrow().rawRes
}
Log.e(TAG, "LNURL [RESCAN ERR] ${proxy.exceptionOrNull()?.message}")
_sendState.value = SendState.Error(
parseApiError(
proxy.exceptionOrNull() ?: Exception(),
"Payment failed — could not re-fetch address"
)
)
return null
}
/**
* Fires background coroutines to resolve node aliases for [payee] and each
* pubkey in [routeHints].
*
* [readState] — returns the current SendState to check it's still relevant.
* [copyAlias] — called with the resolved payee alias; return the new state or
* null to skip the update.
* [copyHint] — called with (pubkey, alias); return the new state or null to
* skip the update.
*/
private fun resolveAliasesInBackground(
payee : String?,
routeHints : List<RouteHintHop>,
readState : () -> SendState,
copyAlias : (SendState, String) -> SendState?,
copyHint : (SendState, String, String) -> SendState?
) {
payee?.let { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val current = readState()
copyAlias(current, alias)?.let { _sendState.value = it }
}
}
routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val current = readState()
copyHint(current, pubkey, alias)?.let { _sendState.value = it }
}
}
}
/**
* Parses the query string of a BIP-21 URI into a key→value map.
* Handles URL-encoding and `+`-as-space.
*/
private fun parseBip21Params(raw: String): Map<String, String> {
val query = raw.trim().substringAfter("?", missingDelimiterValue = "")
if (query.isBlank()) return emptyMap()
return query.split("&").mapNotNull { pair ->
val key = pair.substringBefore("=").takeIf { it.isNotBlank() } ?: return@mapNotNull null
val value = runCatching {
java.net.URLDecoder.decode(
pair.substringAfter("=", "").replace("+", " "), "UTF-8"
)
}.getOrDefault("")
key to value
}.toMap()
}
private fun handleBip21Scan(rawInput: String) {
val params = parseBip21Params(rawInput)
val bolt11 = params["lightning"]
val lnurl = params["lnurl"] ?: params["lnurlp"]
when {
bolt11 != null -> scan(bolt11)
lnurl != null -> scan(lnurl)
else -> _sendState.value = SendState.Error(
"On-chain Bitcoin payments are not supported — " +
"share a BIP-21 URI with a lightning= parameter"
)
}
}
private fun handleLud17Scan(rawInput: String) {
val scheme = rawInput.trim().lowercase().substringBefore("://")
if (scheme == "lnurlc") {
_sendState.value = SendState.Error("Channel requests (lnurlc) are not supported")
return
}
val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
Log.d(TAG, "LUD17 [FETCH] $httpsUrl")
_sendState.value = SendState.Scanning
viewModelScope.launch {
runCatching { repo.fetchLnurlDirect(httpsUrl) }
.onSuccess { raw ->
Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}")
when (raw.tag) {
"withdrawRequest" -> handleWithdrawResponse(raw, httpsUrl)
"payRequest" -> {
lnurlCache.put(httpsUrl, raw)
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
}
else -> _sendState.value = SendState.Error(
"Unsupported LNURL type: ${raw.tag ?: "unknown"}"
)
}
}
.onFailure { e ->
Log.e(TAG, "LUD17 [ERROR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Could not resolve address"))
}
}
}
private fun handleBolt11Scan(normalized: String) {
viewModelScope.launch {
_sendState.value = SendState.Scanning
runCatching { repo.decodeBolt11(normalized) }
.onSuccess { decoded ->
val routeRisk = RouteHintAnalyzer.analyze(
amountMsat = decoded.amountSats * 1_000L,
hints = decoded.routeHints
)
_sendState.value = SendState.Bolt11Decoded(
bolt11 = normalized,
amountSats = decoded.amountSats,
memo = decoded.memo,
payee = decoded.payee,
nodeAlias = null,
routeRisk = routeRisk,
allRouteHints = decoded.routeHints,
routeHintAliases = emptyMap()
)
resolveAliasesInBackground(
payee = decoded.payee,
routeHints = decoded.routeHints,
readState = { _sendState.value },
copyAlias = { state, alias ->
(state as? SendState.Bolt11Decoded)
?.takeIf { it.payee == decoded.payee }
?.copy(nodeAlias = alias)
},
copyHint = { state, pubkey, alias ->
(state as? SendState.Bolt11Decoded)
?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias))
}
)
}
.onFailure { e ->
Log.e(TAG, "SEND [DECODE ERROR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice"))
}
}
}
private fun handleLnurlScan(normalized: String) {
viewModelScope.launch {
_sendState.value = SendState.Scanning
// 1. Cache hit
val cached = lnurlCache.get(normalized)
if (cached != null) {
Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call")
when (cached.tag) {
"withdrawRequest" -> handleWithdrawResponse(cached, normalized)
else -> _sendState.value = buildLnurlReadyState(cached, normalized)
}
return@launch
}
Log.d(TAG, "LNURL [SCAN START ] normalized=$normalized")
// 2. Client-side direct
val directResult = runCatching { repo.scanLnurlDirect(normalized) }
if (directResult.isSuccess) {
val scanned = directResult.getOrThrow()
Log.d(TAG, "LNURL [SCAN DIRECT] tag=${scanned.tag} for $normalized")
val protocolError = lnurlProtocolError(scanned.rawRes)
if (protocolError != null) {
Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError")
_sendState.value = SendState.Error(protocolError)
return@launch
}
lnurlCache.put(normalized, scanned.rawRes)
when (scanned.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized)
"withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized)
null -> _sendState.value = SendState.Error("Empty response from server")
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
}
return@launch
}
Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to proxy")
// 3. Server proxy fallback
runCatching { repo.scanLnurl(normalized) }
.onSuccess { scanned ->
Log.d(TAG, "LNURL [SCAN PROXY ] tag=${scanned.tag} for $normalized")
val protocolError = lnurlProtocolError(scanned.rawRes)
if (protocolError != null) {
Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError")
_sendState.value = SendState.Error(protocolError)
return@onSuccess
}
lnurlCache.put(normalized, scanned.rawRes)
when (scanned.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized)
"withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized)
null -> _sendState.value = SendState.Error("Empty response from server")
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
}
}
.onFailure { e ->
_sendState.value = SendState.Error(parseApiError(e, "Could not resolve address"))
}
}
}
}