refactor: extract PaymentPoller

This commit is contained in:
2026-06-06 16:21:21 +02:00
parent edbcd95a47
commit ca2273f3e4
4 changed files with 77 additions and 64 deletions
@@ -17,10 +17,10 @@ import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel 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.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.LnurlPayer import com.bitcointxoko.gudariwallet.ui.send.LnurlPayer
import com.bitcointxoko.gudariwallet.ui.send.LnurlScanner import com.bitcointxoko.gudariwallet.ui.send.LnurlScanner
import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { 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 lnurlCache = LnurlCacheRepository(app)
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache) val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val fiatDataStore = FiatDataStore(app) val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
@@ -49,7 +50,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
receiveVm.handleWithdrawResponse(raw, lnurl) receiveVm.handleWithdrawResponse(raw, lnurl)
}, },
scanner = lnurlScanner, scanner = lnurlScanner,
payer = lnurlPayer payer = lnurlPayer,
poller = poller,
) )
val clipboardVm = ClipboardViewModel( val clipboardVm = ClipboardViewModel(
@@ -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" }
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.verticalScroll
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.ContentPaste import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.outlined.HourglassTop import androidx.compose.material.icons.outlined.HourglassTop
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* 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.SendInputDetector
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.fiatLabel 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -20,7 +20,6 @@ import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
import com.bitcointxoko.gudariwallet.util.parseApiError import com.bitcointxoko.gudariwallet.util.parseApiError
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,6 +55,7 @@ class SendViewModel(
private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit, private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit,
private val scanner : LnurlScanner, private val scanner : LnurlScanner,
private val payer : LnurlPayer, private val payer : LnurlPayer,
private val poller : PaymentPoller,
) : ViewModel() { ) : ViewModel() {
private val _sendState = MutableStateFlow<SendState>(SendState.Idle) private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
@@ -121,58 +121,44 @@ class SendViewModel(
alias : String?, alias : String?,
strings : SendStrings, strings : SendStrings,
) { ) {
val detail = runCatching { val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
repo.getPaymentDetail(paymentHash)
}.getOrNull()
when { when {
// ── Settled ────────────────────────────────────────────────────────── // ── Settled immediately
detail?.paid == true -> { detail?.paid == true -> {
val feeSats = detail.details?.feeSat emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias)
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats,
paymentHash = paymentHash,
pendingRecord = buildPendingRecord(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias)
)
balanceVm.refreshBalance()
} }
// ── Hold invoice — still pending ────────────────────────────────── // ── Hold invoice — poll
detail?.details?.status == "pending" || detail?.details?.pending == true -> { detail?.details?.status == "pending" || detail?.details?.pending == true -> {
// Stay in SendState.Paying and start polling (see Part 2) when (val result = poller.poll(paymentHash)) {
pollUntilSettled(paymentHash, amountSats, bolt11, memo, pubkey, alias, strings) 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 -> { else -> {
_sendState.value = SendState.Error(strings.paymentFailed) _sendState.value = SendState.Error(strings.paymentFailed)
} }
} }
} }
private suspend fun pollUntilSettled( private suspend fun emitPaymentSent(
paymentHash : String, paymentHash : String,
amountSats : Long, amountSats : Long,
feeSats : Long?,
bolt11 : String?, bolt11 : String?,
memo : String?, memo : String?,
pubkey : String?, pubkey : String?,
alias : 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( _sendState.value = SendState.PaymentSent(
amountSats = amountSats, amountSats = amountSats,
feeSats = feeSats, feeSats = feeSats,
@@ -180,27 +166,8 @@ class SendViewModel(
pendingRecord = buildPendingRecord(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias) pendingRecord = buildPendingRecord(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias)
) )
balanceVm.refreshBalance() 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")
}
fun payLnurl( fun payLnurl(
rawRes : LnurlScanResponse, rawRes : LnurlScanResponse,