enhancement: show contact on successful send
This commit is contained in:
@@ -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(
|
||||
|
||||
+41
-13
@@ -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,17 +559,17 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user