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) --- // --- Lookup (for send flow) ---
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
suspend fun findByLnAddress(lnAddress: String): ContactEntity? = suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
dao.findByLnAddress(lnAddress) dao.findByLnAddress(lnAddress)
@@ -85,7 +85,8 @@ sealed class SendState {
override val routeHintAliases : Map<String, String> = emptyMap(), override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String, override val paymentHash : String,
override val createdAt : Instant, override val createdAt : Instant,
override val expiresAt : Instant override val expiresAt : Instant,
val contactId : String? = null
) : SendState(), DecodedInvoice ) : SendState(), DecodedInvoice
data object Paying : SendState() data object Paying : SendState()
data class PaymentSent( data class PaymentSent(
@@ -93,7 +94,9 @@ sealed class SendState {
val feeSats : Long? = null, val feeSats : Long? = null,
val paymentHash: String, val paymentHash: String,
val pendingRecord: PaymentRecord? = null, val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null val lnurl : String? = null,
val contactId : String? = null,
val contactName : String? = null
) : SendState() ) : SendState()
data class Error(val message: String) : SendState() data class Error(val message: String) : SendState()
} }
@@ -105,7 +105,8 @@ class WalletViewModel(
amountMsat: Long, amountMsat: Long,
comment : String?, comment : String?,
strings : SendStrings, 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) = fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
sendVm.payLnurlInvoice(state, strings) sendVm.payLnurlInvoice(state, strings)
@@ -52,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
scanner = lnurlScanner, scanner = lnurlScanner,
payer = lnurlPayer, payer = lnurlPayer,
poller = poller, poller = poller,
contactRepo = contactRepo,
paymentCacheRepo = paymentCache
) )
val clipboardVm = ClipboardViewModel( val clipboardVm = ClipboardViewModel(
@@ -3,6 +3,7 @@ package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -11,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
@@ -40,6 +42,7 @@ fun PaymentSuccessContent(
onSecondary : () -> Unit, onSecondary : () -> Unit,
lnurl : String? = null, lnurl : String? = null,
onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null, onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null,
contactName : String? = null
) { ) {
var showSaveSheet by remember { mutableStateOf(false) } var showSaveSheet by remember { mutableStateOf(false) }
@@ -68,7 +71,31 @@ fun PaymentSuccessContent(
} }
// ── Save contact affordance (send-side only) ───────────────────────── // ── Save contact affordance (send-side only) ─────────────────────────
if (lnurl != null && onSaveContact != null) { 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)) Spacer(Modifier.height(16.dp))
OutlinedButton( OutlinedButton(
onClick = { showSaveSheet = true }, onClick = { showSaveSheet = true },
@@ -83,6 +110,7 @@ fun PaymentSuccessContent(
Text("Save contact") Text("Save contact")
} }
} }
}
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Button( Button(
@@ -164,7 +164,8 @@ internal fun LnurlPayForm(
domain = state.domain, domain = state.domain,
amountMsat = sats * WalletConstants.MSAT_PER_SAT, amountMsat = sats * WalletConstants.MSAT_PER_SAT,
comment = comment.takeIf { it.isNotBlank() }, comment = comment.takeIf { it.isNotBlank() },
strings = sendStrings strings = sendStrings,
contactId = existingContact?.id
) )
} }
}, },
@@ -307,7 +307,8 @@ fun SendScreen(
secondaryLabel = strings.sendAnother, secondaryLabel = strings.sendAnother,
onSecondary = { vm.resetSendState(); input = "" }, onSecondary = { vm.resetSendState(); input = "" },
lnurl = state.lnurl, // null for plain Bolt11 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.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.util.Bip21Parser import com.bitcointxoko.gudariwallet.util.Bip21Parser
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
@@ -35,12 +38,6 @@ sealed class SendEvent {
object PaymentCompleted : 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( data class SendStrings(
val lnurlAuthNotSupported : String, val lnurlAuthNotSupported : String,
val unrecognisedInput : String, val unrecognisedInput : String,
@@ -63,7 +60,10 @@ class SendViewModel(
private val scanner : LnurlScanner, private val scanner : LnurlScanner,
private val payer : LnurlPayer, private val payer : LnurlPayer,
private val poller : PaymentPoller, private val poller : PaymentPoller,
private val contactRepo : ContactRepository, // ← new (Step 5)
private val paymentCacheRepo : PaymentCacheRepository, // ← new (Step 8)
) : ViewModel() { ) : ViewModel() {
private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1) private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1)
val events: SharedFlow<SendEvent> = _events val events: SharedFlow<SendEvent> = _events
@@ -75,6 +75,7 @@ class SendViewModel(
} }
// ── Entry point ────────────────────────────────────────────────────────── // ── Entry point ──────────────────────────────────────────────────────────
fun scan(rawInput: String, strings: SendStrings) { fun scan(rawInput: String, strings: SendStrings) {
val normalized = SendInputDetector.normalize(rawInput) val normalized = SendInputDetector.normalize(rawInput)
val type = SendInputDetector.detect(rawInput) val type = SendInputDetector.detect(rawInput)
@@ -83,9 +84,9 @@ class SendViewModel(
when (type) { when (type) {
SendInputType.Unknown -> { SendInputType.Unknown -> {
val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported" strings.lnurlAuthNotSupported
else else
strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" strings.unrecognisedInput
_sendState.value = SendState.Error(msg) _sendState.value = SendState.Error(msg)
} }
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings) SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
@@ -95,7 +96,8 @@ class SendViewModel(
} }
} }
// ── Payment ────────────────────────────────────────────────────────────── // ── Payment — Bolt11 ─────────────────────────────────────────────────────
fun payBolt11(bolt11: String, strings: SendStrings) { fun payBolt11(bolt11: String, strings: SendStrings) {
viewModelScope.launch { viewModelScope.launch {
val decoded = _sendState.value as? SendState.Bolt11Decoded val decoded = _sendState.value as? SendState.Bolt11Decoded
@@ -114,131 +116,26 @@ class SendViewModel(
memo = decoded.memo, memo = decoded.memo,
pubkey = decoded.payee, pubkey = decoded.payee,
alias = decoded.nodeAlias, alias = decoded.nodeAlias,
strings strings = strings,
contactId = null, // plain bolt11 path — no contact context
) )
} }
.onFailure { e -> .onFailure { e ->
Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}") Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.paymentFailed) // ← "Payment failed" parseApiError(e, strings.paymentFailed)
) )
} }
} }
} }
private suspend fun handlePaymentResult( // ── Payment — LNURL (amount form → confirmation card) ────────────────────
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
)
)
}
}
/**
* 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( fun fetchLnurlInvoice(
rawRes : LnurlScanResponse, rawRes : LnurlScanResponse,
lnurl : String, lnurl : String,
@@ -246,6 +143,7 @@ class SendViewModel(
amountMsat : Long, amountMsat : Long,
comment : String?, comment : String?,
strings : SendStrings, strings : SendStrings,
contactId : String?, // ← new (Step 3)
) { ) {
viewModelScope.launch { viewModelScope.launch {
_sendState.value = SendState.Scanning _sendState.value = SendState.Scanning
@@ -255,7 +153,7 @@ class SendViewModel(
}.getOrElse { e -> }.getOrElse { e ->
Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}") Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient" parseApiError(e, strings.couldNotFetchInvoice)
) )
return@launch return@launch
} }
@@ -265,7 +163,7 @@ class SendViewModel(
}.getOrElse { e -> }.getOrElse { e ->
Timber.e("LNURL [DECODE ERR] ${e.message}") Timber.e("LNURL [DECODE ERR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice" parseApiError(e, strings.couldNotDecodeInvoice)
) )
return@launch return@launch
} }
@@ -275,8 +173,11 @@ class SendViewModel(
hints = decoded.routeHints hints = decoded.routeHints
) )
Timber.d("LNURL [INVOICE READY] amountMsat=$amountMsat " + Timber.d(
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}") "LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size} " +
"contactId=$contactId"
)
_sendState.value = SendState.LnurlInvoiceReady( _sendState.value = SendState.LnurlInvoiceReady(
bolt11 = bolt11, bolt11 = bolt11,
@@ -294,7 +195,8 @@ class SendViewModel(
routeHintAliases = emptyMap(), routeHintAliases = emptyMap(),
paymentHash = decoded.paymentHash, paymentHash = decoded.paymentHash,
createdAt = decoded.createdAt, createdAt = decoded.createdAt,
expiresAt = decoded.expiresAt expiresAt = decoded.expiresAt,
contactId = contactId, // ← new (Step 3)
) )
resolveAliasesInBackground( 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) { fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
viewModelScope.launch { viewModelScope.launch {
_sendState.value = SendState.Paying _sendState.value = SendState.Paying
@@ -327,18 +233,192 @@ class SendViewModel(
pubkey = state.payee, pubkey = state.payee,
alias = state.nodeAlias, alias = state.nodeAlias,
strings = strings, strings = strings,
lnurl = state.lnurl contactId = state.contactId, // ← new (Step 5)
) )
return@launch return@launch
} }
Timber.w("LNURL [PAY INVOICE DIRECT FAIL] " + Timber.w(
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl") "LNURL [PAY INVOICE DIRECT FAIL] " +
executeLnurlPayment(state.rawRes, state.lnurl, state.amountMsat, state.comment, strings) "${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 } 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 ──────────────────────────────────────────────────────── // ── Scan handlers ────────────────────────────────────────────────────────
private fun handleBip21Scan(rawInput: String, strings: SendStrings) { private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
@@ -348,18 +428,14 @@ class SendViewModel(
when { when {
bolt11 != null -> scan(bolt11, strings) bolt11 != null -> scan(bolt11, strings)
lnurl != null -> scan(lnurl, strings) lnurl != null -> scan(lnurl, strings)
else -> _sendState.value = SendState.Error( else -> _sendState.value = SendState.Error(strings.onChainNotSupported)
strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…"
)
} }
} }
private fun handleLud17Scan(rawInput: String, strings: SendStrings) { private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
val scheme = rawInput.trim().lowercase().substringBefore("://") val scheme = rawInput.trim().lowercase().substringBefore("://")
if (scheme == "lnurlc") { if (scheme == "lnurlc") {
_sendState.value = SendState.Error( _sendState.value = SendState.Error(strings.channelRequestNotSupported)
strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported"
)
return return
} }
val httpsUrl = "https://" + rawInput.trim().substringAfter("://") val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
@@ -370,20 +446,20 @@ class SendViewModel(
.onSuccess { raw -> .onSuccess { raw ->
Timber.d("LUD17 [RESPONSE] tag=${raw.tag}") Timber.d("LUD17 [RESPONSE] tag=${raw.tag}")
when (raw.tag) { when (raw.tag) {
"withdrawRequest" -> {emitWithdrawScanned(raw, httpsUrl)} "withdrawRequest" -> emitWithdrawScanned(raw, httpsUrl)
"payRequest" -> { "payRequest" -> {
lnurlCache.put(httpsUrl, raw) lnurlCache.put(httpsUrl, raw)
_sendState.value = buildLnurlReadyState(raw, httpsUrl) _sendState.value = buildLnurlReadyState(raw, httpsUrl)
} }
else -> _sendState.value = SendState.Error( else -> _sendState.value = SendState.Error(
strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag" strings.unsupportedType(raw.tag ?: "unknown")
) )
} }
} }
.onFailure { e -> .onFailure { e ->
Timber.e("LUD17 [ERROR] ${e.message}") Timber.e("LUD17 [ERROR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address" parseApiError(e, strings.couldNotResolveAddress)
) )
} }
} }
@@ -428,7 +504,7 @@ class SendViewModel(
.onFailure { e -> .onFailure { e ->
Timber.e("SEND [DECODE ERROR] ${e.message}") Timber.e("SEND [DECODE ERROR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice" parseApiError(e, strings.couldNotDecodeInvoice)
) )
} }
} }
@@ -483,7 +559,7 @@ class SendViewModel(
payee : String?, payee : String?,
routeHints : List<RouteHintHop>, routeHints : List<RouteHintHop>,
copyAlias : (SendState, String) -> SendState?, copyAlias : (SendState, String) -> SendState?,
copyHint : (SendState, String, String) -> SendState? copyHint : (SendState, String, String) -> SendState?,
) { ) {
payee?.let { pubkey -> payee?.let { pubkey ->
viewModelScope.launch { viewModelScope.launch {