enhancement: show contact on successful send

This commit is contained in:
2026-06-13 20:01:34 +02:00
parent 2e11cecbeb
commit ebeac511b7
8 changed files with 311 additions and 197 deletions
@@ -115,6 +115,8 @@ class ContactRepository(app: Application) {
// --- Lookup (for send flow) ---
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
dao.findByLnAddress(lnAddress)
@@ -85,7 +85,8 @@ sealed class SendState {
override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String,
override val createdAt : Instant,
override val expiresAt : Instant
override val expiresAt : Instant,
val contactId : String? = null
) : SendState(), DecodedInvoice
data object Paying : SendState()
data class PaymentSent(
@@ -93,7 +94,9 @@ sealed class SendState {
val feeSats : Long? = null,
val paymentHash: String,
val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null
val lnurl : String? = null,
val contactId : String? = null,
val contactName : String? = null
) : SendState()
data class Error(val message: String) : SendState()
}
@@ -105,7 +105,8 @@ class WalletViewModel(
amountMsat: Long,
comment : String?,
strings : SendStrings,
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings)
contactId : String?
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings, contactId)
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
sendVm.payLnurlInvoice(state, strings)
@@ -52,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
scanner = lnurlScanner,
payer = lnurlPayer,
poller = poller,
contactRepo = contactRepo,
paymentCacheRepo = paymentCache
)
val clipboardVm = ClipboardViewModel(
@@ -3,6 +3,7 @@ package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -11,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
@@ -40,6 +42,7 @@ fun PaymentSuccessContent(
onSecondary : () -> Unit,
lnurl : String? = null,
onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null,
contactName : String? = null
) {
var showSaveSheet by remember { mutableStateOf(false) }
@@ -68,19 +71,44 @@ fun PaymentSuccessContent(
}
// ── Save contact affordance (send-side only) ─────────────────────────
if (lnurl != null && onSaveContact != null) {
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = { showSaveSheet = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Save contact")
when {
contactName != null -> {
// Contact already known — show their name
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = contactName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
lnurl != null && onSaveContact != null -> {
// Unknown sender — offer to save
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = { showSaveSheet = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Save contact")
}
}
}
@@ -164,7 +164,8 @@ internal fun LnurlPayForm(
domain = state.domain,
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
comment = comment.takeIf { it.isNotBlank() },
strings = sendStrings
strings = sendStrings,
contactId = existingContact?.id
)
}
},
@@ -307,7 +307,8 @@ fun SendScreen(
secondaryLabel = strings.sendAnother,
onSecondary = { vm.resetSendState(); input = "" },
lnurl = state.lnurl, // null for plain Bolt11
onSaveContact = vm::saveContactFromSend
onSaveContact = vm::saveContactFromSend,
contactName = state.contactName
)
}
@@ -6,9 +6,12 @@ import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.ContactRepository
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.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.util.Bip21Parser
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
@@ -35,35 +38,32 @@ sealed class SendEvent {
object PaymentCompleted : SendEvent()
}
// ── String bundle injected at the call site ───────────────────────────────────
/**
* All user-visible strings produced by [SendViewModel].
* Populate from [LocalAppStrings.current] in the owning Composable and pass
* to [SendViewModel.scan], [SendViewModel.payBolt11], etc.
*/
data class SendStrings(
val lnurlAuthNotSupported : String,
val unrecognisedInput : String,
val paymentFailed : String,
val couldNotFetchInvoice : String,
val couldNotDecodeInvoice : String,
val onChainNotSupported : String,
val channelRequestNotSupported : String,
val couldNotResolveAddress : String,
val emptyResponse : String,
val unsupportedType : (tag: String) -> String,
val paymentFailedRescan : String,
val authFailed : (msg: String) -> String,
val lnurlAuthNotSupported : String,
val unrecognisedInput : String,
val paymentFailed : String,
val couldNotFetchInvoice : String,
val couldNotDecodeInvoice : String,
val onChainNotSupported : String,
val channelRequestNotSupported : String,
val couldNotResolveAddress : String,
val emptyResponse : String,
val unsupportedType : (tag: String) -> String,
val paymentFailedRescan : String,
val authFailed : (msg: String) -> String,
)
class SendViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val lnurlCache : LnurlCacheRepository,
private val scanner : LnurlScanner,
private val payer : LnurlPayer,
private val poller : PaymentPoller,
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val lnurlCache : LnurlCacheRepository,
private val scanner : LnurlScanner,
private val payer : LnurlPayer,
private val poller : PaymentPoller,
private val contactRepo : ContactRepository, // ← new (Step 5)
private val paymentCacheRepo : PaymentCacheRepository, // ← new (Step 8)
) : ViewModel() {
private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1)
val events: SharedFlow<SendEvent> = _events
@@ -75,17 +75,18 @@ class SendViewModel(
}
// ── Entry point ──────────────────────────────────────────────────────────
fun scan(rawInput: String, strings: SendStrings) {
val normalized = SendInputDetector.normalize(rawInput)
val type = SendInputDetector.detect(rawInput)
val type = SendInputDetector.detect(rawInput)
Timber.d("SCAN [DETECT] input='$rawInput' → type=$type")
when (type) {
SendInputType.Unknown -> {
val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported"
strings.lnurlAuthNotSupported
else
strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
strings.unrecognisedInput
_sendState.value = SendState.Error(msg)
}
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
@@ -95,10 +96,11 @@ class SendViewModel(
}
}
// ── Payment ──────────────────────────────────────────────────────────────
// ── Payment — Bolt11 ─────────────────────────────────────────────────────
fun payBolt11(bolt11: String, strings: SendStrings) {
viewModelScope.launch {
val decoded = _sendState.value as? SendState.Bolt11Decoded
val decoded = _sendState.value as? SendState.Bolt11Decoded
if (decoded == null) {
Timber.w("SEND [PAY BOLT11] called in unexpected state: ${_sendState.value::class.simpleName}")
return@launch
@@ -110,142 +112,38 @@ class SendViewModel(
handlePaymentResult(
paymentHash = response.paymentHash,
amountSats = decoded.amountSats,
bolt11 = decoded.bolt11,
memo = decoded.memo,
pubkey = decoded.payee,
alias = decoded.nodeAlias,
strings
bolt11 = decoded.bolt11,
memo = decoded.memo,
pubkey = decoded.payee,
alias = decoded.nodeAlias,
strings = strings,
contactId = null, // plain bolt11 path — no contact context
)
}
.onFailure { e ->
Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.paymentFailed) // ← "Payment failed"
parseApiError(e, strings.paymentFailed)
)
}
}
}
private suspend fun handlePaymentResult(
paymentHash : String,
amountSats : Long,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
strings : SendStrings,
lnurl : String? = null
) {
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
when {
// ── Settled immediately
detail?.paid == true -> {
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias, lnurl)
}
// ── Hold invoice — poll
detail?.details?.status == "pending" || detail?.details?.pending == true -> {
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl)
is PaymentPoller.PollResult.Failed ->
_sendState.value = SendState.Error(strings.paymentFailed)
is PaymentPoller.PollResult.TimedOut ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// hardening against a null detail
detail == null -> {
// Detail fetch failed entirely — treat as pending and poll once
Timber.w("SEND [DETAIL NULL] polling as fallback")
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl)
else ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Failed or unknown
else -> {
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
}
private fun emitPaymentSent(
paymentHash : String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
lnurl : String? = null
) {
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats,
paymentHash = paymentHash,
pendingRecord = PaymentRecord.fromPayment(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias),
lnurl = lnurl
)
_events.tryEmit(SendEvent.PaymentCompleted)
}
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) {
viewModelScope.launch {
_sendState.value = SendState.Paying
executeLnurlPayment(rawRes, lnurl, amountMsat, comment, strings)
}
}
private suspend fun executeLnurlPayment(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) {
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
if (result.isSuccess) {
handlePaymentResult(
paymentHash = result.getOrThrow(),
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
bolt11 = null,
memo = null,
pubkey = null,
alias = null,
strings = strings,
lnurl = lnurl
)
} else {
_sendState.value = SendState.Error(
parseApiError(
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
strings.paymentFailed
)
)
}
}
// ── Payment — LNURL (amount form → confirmation card) ────────────────────
/**
* Called from [LnurlPayForm] when the user taps Pay.
* Fetches the BOLT-11 from the LNURL callback and moves to [SendState.LnurlInvoiceReady].
* [contactId] is the Room PK of the contact associated with this address, or null if unknown.
*/
fun fetchLnurlInvoice(
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat : Long,
comment : String?,
strings : SendStrings,
contactId : String?, // ← new (Step 3)
) {
viewModelScope.launch {
_sendState.value = SendState.Scanning
@@ -255,7 +153,7 @@ class SendViewModel(
}.getOrElse { e ->
Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient"
parseApiError(e, strings.couldNotFetchInvoice)
)
return@launch
}
@@ -265,7 +163,7 @@ class SendViewModel(
}.getOrElse { e ->
Timber.e("LNURL [DECODE ERR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
parseApiError(e, strings.couldNotDecodeInvoice)
)
return@launch
}
@@ -275,8 +173,11 @@ class SendViewModel(
hints = decoded.routeHints
)
Timber.d("LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}")
Timber.d(
"LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size} " +
"contactId=$contactId"
)
_sendState.value = SendState.LnurlInvoiceReady(
bolt11 = bolt11,
@@ -294,7 +195,8 @@ class SendViewModel(
routeHintAliases = emptyMap(),
paymentHash = decoded.paymentHash,
createdAt = decoded.createdAt,
expiresAt = decoded.expiresAt
expiresAt = decoded.expiresAt,
contactId = contactId, // ← new (Step 3)
)
resolveAliasesInBackground(
@@ -313,6 +215,10 @@ class SendViewModel(
}
}
/**
* Called from the confirmation card. Tries direct bolt11 pay first; falls back to
* the LNURL-pay protocol. In both cases [contactId] is threaded through to [emitPaymentSent].
*/
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
viewModelScope.launch {
_sendState.value = SendState.Paying
@@ -327,18 +233,192 @@ class SendViewModel(
pubkey = state.payee,
alias = state.nodeAlias,
strings = strings,
lnurl = state.lnurl
contactId = state.contactId, // ← new (Step 5)
)
return@launch
}
Timber.w("LNURL [PAY INVOICE DIRECT FAIL] " +
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl")
executeLnurlPayment(state.rawRes, state.lnurl, state.amountMsat, state.comment, strings)
Timber.w(
"LNURL [PAY INVOICE DIRECT FAIL] " +
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl"
)
executeLnurlPayment(
rawRes = state.rawRes,
lnurl = state.lnurl,
amountMsat = state.amountMsat,
comment = state.comment,
strings = strings,
contactId = state.contactId, // ← new (Step 5)
)
}
}
/**
* Direct LNURL-pay path (no pre-fetched invoice). Used by [payLnurl] which is called
* from the amount form when the user bypasses the confirmation card.
*/
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) {
viewModelScope.launch {
_sendState.value = SendState.Paying
// No contactId available on this path (legacy/manual scan)
executeLnurlPayment(rawRes, lnurl, amountMsat, comment, strings, contactId = null)
}
}
fun resetSendState() { _sendState.value = SendState.Idle }
// ── Private: payment execution ───────────────────────────────────────────
private suspend fun executeLnurlPayment(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
contactId : String?, // ← new (Step 5)
) {
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
if (result.isSuccess) {
handlePaymentResult(
paymentHash = result.getOrThrow(),
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
bolt11 = null,
memo = null,
pubkey = null,
alias = null,
strings = strings,
contactId = contactId, // ← new (Step 5)
)
} else {
_sendState.value = SendState.Error(
parseApiError(
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
strings.paymentFailed
)
)
}
}
private suspend fun handlePaymentResult(
paymentHash : String,
amountSats : Long,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
strings : SendStrings,
contactId : String?, // ← new (Step 5)
) {
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
when {
// ── Settled immediately
detail?.paid == true -> {
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias, contactId)
}
// ── Hold invoice — poll
detail?.details?.status == "pending" || detail?.details?.pending == true -> {
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, contactId)
is PaymentPoller.PollResult.Failed ->
_sendState.value = SendState.Error(strings.paymentFailed)
is PaymentPoller.PollResult.TimedOut ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Detail fetch failed entirely — treat as pending and poll once
detail == null -> {
Timber.w("SEND [DETAIL NULL] polling as fallback")
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, contactId)
else ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Failed or unknown
else -> {
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
}
/**
* Terminal success path. Order of operations is deliberate:
*
* 1. Resolve contact name (read-only, no ordering constraint)
* 2. Upsert the payment row — guarantees the FK exists in the payments table
* 3. Write the TxContactCrossRef — FK now satisfied, cannot dangle
* 4. Emit [SendState.PaymentSent] — UI unblocks only after all DB writes are committed
*/
private suspend fun emitPaymentSent(
paymentHash : String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
contactId : String?, // ← new (Step 5/8)
) {
val pendingRecord = PaymentRecord.fromPayment(
paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias
)
var contactName: String? = null
if (contactId != null) {
// ── 1. Resolve display name ──────────────────────────────────────
val contact: ContactEntity? = try {
contactRepo.getById(contactId)
} catch (e: Exception) {
Timber.w("SEND [CONTACT LOOKUP] could not resolve contact $contactId: ${e.message}")
null
}
contactName = contact?.localAlias
?: contact?.displayName
?: contact?.name
// ── 2. Guarantee the payment row exists before writing the FK ────
// upsertPayment uses INSERT OR REPLACE — idempotent if the
// background sync already wrote this row.
runCatching { paymentCacheRepo.upsertPayment(pendingRecord) }
.onFailure { Timber.e("SEND [UPSERT ERR] $paymentHash: ${it.message}") }
// ── 3. Link payment → contact (FK now guaranteed to exist) ───────
runCatching {
contactRepo.linkPaymentToContact(
checkingId = paymentHash,
contactId = contactId,
role = "recipient",
)
}.onFailure { Timber.e("SEND [LINK ERR] $paymentHash$contactId: ${it.message}") }
}
// ── 4. Unblock UI — only after all DB writes complete ────────────────
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats,
paymentHash = paymentHash,
pendingRecord = pendingRecord,
contactId = contactId,
contactName = contactName,
)
_events.tryEmit(SendEvent.PaymentCompleted)
}
// ── Scan handlers ────────────────────────────────────────────────────────
private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
@@ -348,18 +428,14 @@ class SendViewModel(
when {
bolt11 != null -> scan(bolt11, strings)
lnurl != null -> scan(lnurl, strings)
else -> _sendState.value = SendState.Error(
strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…"
)
else -> _sendState.value = SendState.Error(strings.onChainNotSupported)
}
}
private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
val scheme = rawInput.trim().lowercase().substringBefore("://")
if (scheme == "lnurlc") {
_sendState.value = SendState.Error(
strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported"
)
_sendState.value = SendState.Error(strings.channelRequestNotSupported)
return
}
val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
@@ -370,20 +446,20 @@ class SendViewModel(
.onSuccess { raw ->
Timber.d("LUD17 [RESPONSE] tag=${raw.tag}")
when (raw.tag) {
"withdrawRequest" -> {emitWithdrawScanned(raw, httpsUrl)}
"withdrawRequest" -> emitWithdrawScanned(raw, httpsUrl)
"payRequest" -> {
lnurlCache.put(httpsUrl, raw)
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
}
else -> _sendState.value = SendState.Error(
strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag"
strings.unsupportedType(raw.tag ?: "unknown")
)
}
}
.onFailure { e ->
Timber.e("LUD17 [ERROR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address"
parseApiError(e, strings.couldNotResolveAddress)
)
}
}
@@ -428,7 +504,7 @@ class SendViewModel(
.onFailure { e ->
Timber.e("SEND [DECODE ERROR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
parseApiError(e, strings.couldNotDecodeInvoice)
)
}
}
@@ -452,10 +528,10 @@ class SendViewModel(
if (!fromCache) lnurlCache.put(normalized, raw)
when (raw.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
"withdrawRequest" -> emitWithdrawScanned(raw, normalized)
null -> _sendState.value = SendState.Error(strings.emptyResponse)
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
null -> _sendState.value = SendState.Error(strings.emptyResponse)
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
}
}
}
@@ -483,19 +559,19 @@ class SendViewModel(
payee : String?,
routeHints : List<RouteHintHop>,
copyAlias : (SendState, String) -> SendState?,
copyHint : (SendState, String, String) -> SendState?
copyHint : (SendState, String, String) -> SendState?,
) {
payee?.let { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val alias = aliasRepo.resolve(pubkey) ?: return@launch
_sendState.update { copyAlias(it, alias) ?: it }
}
}
routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val alias = aliasRepo.resolve(pubkey) ?: return@launch
_sendState.update { copyHint(it, pubkey, alias) ?: it }
}
}
}
}
}