From ca2273f3e49df5c3f8134c5f1f1f36ee2fbe80da Mon Sep 17 00:00:00 2001 From: rasputin Date: Sat, 6 Jun 2026 16:21:21 +0200 Subject: [PATCH] refactor: extract PaymentPoller --- .../gudariwallet/ui/WalletViewModelFactory.kt | 6 +- .../gudariwallet/ui/send/PaymentPoller.kt | 47 ++++++++++ .../gudariwallet/ui/send/SendScreen.kt | 3 - .../gudariwallet/ui/send/SendViewModel.kt | 85 ++++++------------- 4 files changed, 77 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/PaymentPoller.kt 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 d81bc3b..d17e9c5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -17,10 +17,10 @@ import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel -import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel import com.bitcointxoko.gudariwallet.ui.send.LnurlPayer import com.bitcointxoko.gudariwallet.ui.send.LnurlScanner +import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller import com.bitcointxoko.gudariwallet.ui.send.SendViewModel class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { @@ -33,6 +33,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F val lnurlCache = LnurlCacheRepository(app) val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache) + val poller = PaymentPoller(repo) val paymentCache = PaymentCacheRepository(app) val fiatDataStore = FiatDataStore(app) val balancePrefs = BalancePrefsStore(app) @@ -49,7 +50,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F receiveVm.handleWithdrawResponse(raw, lnurl) }, scanner = lnurlScanner, - payer = lnurlPayer + payer = lnurlPayer, + poller = poller, ) val clipboardVm = ClipboardViewModel( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/PaymentPoller.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/PaymentPoller.kt new file mode 100644 index 0000000..1d0dc7d --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/PaymentPoller.kt @@ -0,0 +1,47 @@ +package com.bitcointxoko.gudariwallet.ui.send + +import android.util.Log +import com.bitcointxoko.gudariwallet.data.WalletRepository +import kotlinx.coroutines.delay + + +class PaymentPoller(private val repo: WalletRepository) { + + sealed class PollResult { + data class Settled(val feeSats: Long?) : PollResult() + object Failed : PollResult() + object TimedOut : PollResult() + } + + suspend fun poll( + paymentHash : String, + intervalMs : Long = 5_000L, + maxAttempts : Int = 120, + ): PollResult { + repeat(maxAttempts) { attempt -> + delay(intervalMs) + + val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull() + + when { + detail?.paid == true -> { + Log.d(TAG, "HOLD INVOICE [POLL $attempt] settled — $paymentHash") + return PollResult.Settled(detail.details?.feeSat) + } + detail?.details?.status == "pending" || detail?.details?.pending == true -> { + Log.d(TAG, "HOLD INVOICE [POLL $attempt] still pending — $paymentHash") + // continue + } + else -> { + Log.w(TAG, "HOLD INVOICE [POLL $attempt] payment failed — $paymentHash") + return PollResult.Failed + } + } + } + + Log.w(TAG, "HOLD INVOICE [POLL TIMEOUT] $paymentHash") + return PollResult.TimedOut + } + + companion object { private const val TAG = "PaymentPoller" } +} 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 d19f07a..7cfd504 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 @@ -8,7 +8,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.outlined.HourglassTop import androidx.compose.material3.* import androidx.compose.runtime.* @@ -34,8 +33,6 @@ import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.fiatLabel -import com.bitcointxoko.gudariwallet.util.formatFiat -import com.bitcointxoko.gudariwallet.util.satsToFiat import kotlinx.coroutines.delay import kotlinx.coroutines.launch 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 f011efd..985b655 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 @@ -20,7 +20,6 @@ import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.lnurlProtocolError import com.bitcointxoko.gudariwallet.util.parseApiError -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -56,6 +55,7 @@ class SendViewModel( private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit, private val scanner : LnurlScanner, private val payer : LnurlPayer, + private val poller : PaymentPoller, ) : ViewModel() { private val _sendState = MutableStateFlow(SendState.Idle) @@ -121,87 +121,54 @@ class SendViewModel( alias : String?, strings : SendStrings, ) { - val detail = runCatching { - repo.getPaymentDetail(paymentHash) - }.getOrNull() + val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull() when { - // ── Settled ────────────────────────────────────────────────────────── + // ── Settled immediately detail?.paid == true -> { - val feeSats = detail.details?.feeSat - _sendState.value = SendState.PaymentSent( - amountSats = amountSats, - feeSats = feeSats, - paymentHash = paymentHash, - pendingRecord = buildPendingRecord(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias) - ) - balanceVm.refreshBalance() + emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias) } - // ── Hold invoice — still pending ────────────────────────────────── + // ── Hold invoice — poll detail?.details?.status == "pending" || detail?.details?.pending == true -> { - // Stay in SendState.Paying and start polling (see Part 2) - pollUntilSettled(paymentHash, amountSats, bolt11, memo, pubkey, alias, strings) + when (val result = poller.poll(paymentHash)) { + is PaymentPoller.PollResult.Settled -> + emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias) + + is PaymentPoller.PollResult.Failed -> + _sendState.value = SendState.Error(strings.paymentFailed) + + is PaymentPoller.PollResult.TimedOut -> + _sendState.value = SendState.Error(strings.paymentFailed) + } } - // ── Failed or unknown ───────────────────────────────────────────── + // ── Failed or unknown else -> { _sendState.value = SendState.Error(strings.paymentFailed) } } } - private suspend fun pollUntilSettled( + private suspend fun emitPaymentSent( paymentHash : String, amountSats : Long, + feeSats : Long?, bolt11 : String?, memo : String?, pubkey : String?, alias : String?, - strings : SendStrings, ) { - // SendState.Paying is already set — the UI already shows the - // hourglass/slow-payment message after 10 s, so no UI change needed here. - val pollIntervalMs = 5_000L - val maxAttempts = 120 // 10 minutes total - - repeat(maxAttempts) { attempt -> - delay(pollIntervalMs) - - val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull() - - when { - detail?.paid == true -> { - val feeSats = detail.details?.feeSat - _sendState.value = SendState.PaymentSent( - amountSats = amountSats, - feeSats = feeSats, - paymentHash = paymentHash, - pendingRecord = buildPendingRecord(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias) - ) - balanceVm.refreshBalance() - return // done - } - - // Still pending — keep polling - detail?.details?.status == "pending" || detail?.details?.pending == true -> { - Log.d(TAG, "HOLD INVOICE [POLL $attempt] still pending — $paymentHash") - } - - // Failed (status changed to "failed" or detail is gone) - else -> { - Log.w(TAG, "HOLD INVOICE [POLL $attempt] payment failed — $paymentHash") - _sendState.value = SendState.Error(strings.paymentFailed) - return - } - } - } - - // Timed out — leave in Paying so the user can dismiss manually - // (the existing 10-second slow-payment UI already handles this gracefully) - Log.w(TAG, "HOLD INVOICE [POLL TIMEOUT] $paymentHash — leaving in Paying state") + _sendState.value = SendState.PaymentSent( + amountSats = amountSats, + feeSats = feeSats, + paymentHash = paymentHash, + pendingRecord = buildPendingRecord(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias) + ) + balanceVm.refreshBalance() } + fun payLnurl( rawRes : LnurlScanResponse, lnurl : String,