diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt index 7869393..02e0f83 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt @@ -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) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt index 4369b7d..e9932c0 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt @@ -85,7 +85,8 @@ sealed class SendState { override val routeHintAliases : Map = 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() } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index ca8c187..7ee2716 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -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) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index 450f0d5..d9d41ca 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt index 211092b..cb3e2d3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt @@ -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") + } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt index 7425853..e8d3754 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt @@ -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 ) } }, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt index 4eab373..54c3dc7 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt @@ -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 ) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt index 795e607..47874d0 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -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(extraBufferCapacity = 1) val events: SharedFlow = _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, 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 } } } } -} \ No newline at end of file +}