enhancement: show contact on successful send
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user