refactor: WalletViewModel extract ReceiveViewModel

This commit is contained in:
2026-06-01 21:13:04 +02:00
parent bba41033e5
commit e6c24c761b
3 changed files with 170 additions and 117 deletions
@@ -14,7 +14,6 @@ import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.parseApiError import com.bitcointxoko.gudariwallet.util.parseApiError
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -25,6 +24,7 @@ import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.util.BiometricHelper import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
@@ -36,7 +36,8 @@ class WalletViewModel(
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
val fiatVm : FiatViewModel val fiatVm : FiatViewModel,
val receiveVm : ReceiveViewModel,
) : ViewModel() { ) : ViewModel() {
// ── Balance state ───────────────────────────────────────────────────────── // ── Balance state ─────────────────────────────────────────────────────────
@@ -49,15 +50,17 @@ class WalletViewModel(
suspend fun getSupportedCurrencies() = fiatVm.getSupportedCurrencies() suspend fun getSupportedCurrencies() = fiatVm.getSupportedCurrencies()
val fiatRate: Double? get() = fiatVm.fiatRate val fiatRate: Double? get() = fiatVm.fiatRate
val fiatCurrency: String? get() = fiatVm.fiatCurrency val fiatCurrency: String? get() = fiatVm.fiatCurrency
val receiveState: StateFlow<ReceiveState> get() = receiveVm.receiveState
val navigateToReceive: SharedFlow<Unit> get() = receiveVm.navigateToReceive
fun createInvoice(amountSats: Long, memo: String) = receiveVm.createInvoice(amountSats, memo)
fun resetReceiveState() = receiveVm.resetReceiveState()
fun executeWithdraw(k1: String, callback: String, amountSats: Long, memo: String) =
receiveVm.executeWithdraw(k1, callback, amountSats, memo)
// ── Other states ────────────────────────────────────────────────────────── // ── Other states ──────────────────────────────────────────────────────────
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState
private val _sendState = MutableStateFlow<SendState>(SendState.Idle) private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
val sendState: StateFlow<SendState> = _sendState val sendState: StateFlow<SendState> = _sendState
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToReceive: SharedFlow<Unit> = _navigateToReceive
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress) private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
val lightningAddress: StateFlow<String?> = _lightningAddress val lightningAddress: StateFlow<String?> = _lightningAddress
@@ -72,18 +75,11 @@ class WalletViewModel(
private fun observePaymentEvents() { private fun observePaymentEvents() {
viewModelScope.launch { viewModelScope.launch {
balanceVm.incomingPayment.collect { event -> balanceVm.incomingPayment.collect { event ->
val rs = _receiveState.value receiveVm.onIncomingPayment(event)
if (rs is ReceiveState.InvoiceReady) {
Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received")
_receiveState.value = ReceiveState.PaymentReceived(
amountSats = event.amountSats,
memo = event.memo,
checkingId = rs.paymentHash
)
}
} }
} }
} }
private fun fetchLightningAddress() { private fun fetchLightningAddress() {
// If we already have a persisted address, show it immediately and // If we already have a persisted address, show it immediately and
// still refresh in the background (address could have changed). // still refresh in the background (address could have changed).
@@ -105,38 +101,6 @@ class WalletViewModel(
} }
} }
// ── Receive ───────────────────────────────────────────────────────────────
fun createInvoice(amountSats: Long, memo: String) {
viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice
runCatching { repo.createInvoice(amountSats, memo) }
.onSuccess { invoice ->
val fallbackExpiry = System.currentTimeMillis() / 1000L + 600L
val expiresAt = invoice.expiry?.let { isoString ->
runCatching {
java.time.LocalDateTime
.parse(isoString, java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.toEpochSecond(java.time.ZoneOffset.UTC)
}.getOrNull()
} ?: fallbackExpiry
_receiveState.value = ReceiveState.InvoiceReady(
bolt11 = invoice.bolt11,
paymentHash = invoice.paymentHash,
amountSats = amountSats,
memo = memo,
expiresAt = expiresAt
)
}
.onFailure { e ->
Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice"))
}
}
}
fun resetReceiveState() { _receiveState.value = ReceiveState.Idle }
// ── Send ────────────────────────────────────────────────────────────────── // ── Send ──────────────────────────────────────────────────────────────────
fun scan(rawInput: String) { fun scan(rawInput: String) {
@@ -160,69 +124,6 @@ class WalletViewModel(
} }
} }
private fun handleWithdrawResponse(raw: LnurlScanResponse, lnurl: String) {
val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
_receiveState.value = ReceiveState.LnurlWithdrawReady(
k1 = raw.k1 ?: "",
callback = raw.callback ?: "",
defaultDescription = raw.defaultDescription ?: "",
minSats = minSats,
maxSats = maxSats,
domain = LnurlMetadataParser.extractDomain(raw.callback),
lnurl = lnurl
)
_sendState.value = SendState.Idle // clear scanning spinner
_navigateToReceive.tryEmit(Unit)
Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive")
}
fun executeWithdraw(
k1 : String,
callback : String,
amountSats: Long,
memo : String
) {
viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice
val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) }
if (invoiceResult.isFailure) {
val e = invoiceResult.exceptionOrNull() ?: Exception("Failed to create invoice")
Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice"))
return@launch
}
val invoice = invoiceResult.getOrThrow()
Log.d(TAG, "LNURL-WITHDRAW [INVOICE] ${invoice.paymentHash} — submitting to callback")
runCatching { repo.executeWithdraw(callback, k1, invoice.bolt11) }
.onSuccess { response ->
if (response.status == "OK") {
Log.d(TAG, "LNURL-WITHDRAW [CALLBACK OK] waiting for push payment")
_receiveState.value = ReceiveState.InvoiceReady(
bolt11 = invoice.bolt11,
paymentHash = invoice.paymentHash,
amountSats = amountSats,
memo = memo,
expiresAt = System.currentTimeMillis() / 1000L + 600L
)
} else {
val reason = response.reason
?.ifBlank { null }
?: "Withdraw service rejected the invoice"
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason")
_receiveState.value = ReceiveState.Error(reason)
}
}
.onFailure { e ->
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Withdraw failed"))
}
}
}
fun payBolt11(bolt11: String) { fun payBolt11(bolt11: String) {
viewModelScope.launch { viewModelScope.launch {
val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L
@@ -469,7 +370,7 @@ class WalletViewModel(
if (trimmed == lastOfferedClipboard) return if (trimmed == lastOfferedClipboard) return
// ── Guard: ignore our own invoice ───────────────────────────────────── // ── Guard: ignore our own invoice ─────────────────────────────────────
val ownInvoice = (_receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 val ownInvoice = (receiveState.value as? ReceiveState.InvoiceReady)?.bolt11
if (ownInvoice != null && trimmed.equals(ownInvoice, ignoreCase = true)) { if (ownInvoice != null && trimmed.equals(ownInvoice, ignoreCase = true)) {
Log.d("Clipboard", "Skipping own invoice") Log.d("Clipboard", "Skipping own invoice")
lastOfferedClipboard = trimmed // still mark as seen so focus re-checks don't re-trigger lastOfferedClipboard = trimmed // still mark as seen so focus re-checks don't re-trigger
@@ -664,7 +565,10 @@ class WalletViewModel(
.onSuccess { raw -> .onSuccess { raw ->
Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}") Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}")
when (raw.tag) { when (raw.tag) {
"withdrawRequest" -> handleWithdrawResponse(raw, httpsUrl) "withdrawRequest" -> {
_sendState.value = SendState.Idle
receiveVm.handleWithdrawResponse(raw, httpsUrl)
}
"payRequest" -> { "payRequest" -> {
lnurlCache.put(httpsUrl, raw) lnurlCache.put(httpsUrl, raw)
_sendState.value = buildLnurlReadyState(raw, httpsUrl) _sendState.value = buildLnurlReadyState(raw, httpsUrl)
@@ -729,7 +633,10 @@ class WalletViewModel(
if (cached != null) { if (cached != null) {
Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call") Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call")
when (cached.tag) { when (cached.tag) {
"withdrawRequest" -> handleWithdrawResponse(cached, normalized) "withdrawRequest" -> {
_sendState.value = SendState.Idle
receiveVm.handleWithdrawResponse(cached, normalized)
}
else -> _sendState.value = buildLnurlReadyState(cached, normalized) else -> _sendState.value = buildLnurlReadyState(cached, normalized)
} }
return@launch return@launch
@@ -750,7 +657,10 @@ class WalletViewModel(
lnurlCache.put(normalized, scanned.rawRes) lnurlCache.put(normalized, scanned.rawRes)
when (scanned.tag) { when (scanned.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized)
"withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized) "withdrawRequest" -> {
_sendState.value = SendState.Idle
receiveVm.handleWithdrawResponse(scanned.rawRes, normalized)
}
null -> _sendState.value = SendState.Error("Empty response from server") null -> _sendState.value = SendState.Error("Empty response from server")
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
} }
@@ -771,7 +681,10 @@ class WalletViewModel(
lnurlCache.put(normalized, scanned.rawRes) lnurlCache.put(normalized, scanned.rawRes)
when (scanned.tag) { when (scanned.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized)
"withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized) "withdrawRequest" -> {
_sendState.value = SendState.Idle
receiveVm.handleWithdrawResponse(scanned.rawRes, normalized)
}
null -> _sendState.value = SendState.Error("Empty response from server") null -> _sendState.value = SendState.Error("Empty response from server")
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
} }
@@ -781,5 +694,4 @@ class WalletViewModel(
} }
} }
} }
} }
@@ -13,6 +13,7 @@ import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService 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.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@@ -25,7 +26,10 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs) val balanceVm = BalanceViewModel(repo, balancePrefs)
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm) val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
val receiveVm = ReceiveViewModel(repo)
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() } balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -36,7 +40,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
paymentCache, paymentCache,
balancePrefs, balancePrefs,
balanceVm, balanceVm,
fiatVm fiatVm,
receiveVm
) as T ) as T
} }
} }
@@ -0,0 +1,136 @@
package com.bitcointxoko.gudariwallet.ui.receive
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.balance.IncomingPaymentEvent
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.parseApiError
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "ReceiveViewModel"
class ReceiveViewModel(
private val repo: WalletRepository,
) : ViewModel() {
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToReceive: SharedFlow<Unit> = _navigateToReceive
// ── Called by WalletViewModel when BalanceViewModel emits an incoming payment
fun onIncomingPayment(event: IncomingPaymentEvent) {
val rs = _receiveState.value
if (rs is ReceiveState.InvoiceReady) {
Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received")
_receiveState.value = ReceiveState.PaymentReceived(
amountSats = event.amountSats,
memo = event.memo,
checkingId = rs.paymentHash
)
}
}
// ── Invoice creation ─────────────────────────────────────────────────────
fun createInvoice(amountSats: Long, memo: String) {
viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice
runCatching { repo.createInvoice(amountSats, memo) }
.onSuccess { invoice ->
val fallbackExpiry = System.currentTimeMillis() / 1000L + 600L
val expiresAt = invoice.expiry?.let { isoString ->
runCatching {
java.time.LocalDateTime
.parse(isoString, java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.toEpochSecond(java.time.ZoneOffset.UTC)
}.getOrNull()
} ?: fallbackExpiry
_receiveState.value = ReceiveState.InvoiceReady(
bolt11 = invoice.bolt11,
paymentHash = invoice.paymentHash,
amountSats = amountSats,
memo = memo,
expiresAt = expiresAt
)
}
.onFailure { e ->
Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice"))
}
}
}
fun resetReceiveState() { _receiveState.value = ReceiveState.Idle }
// ── LNURL-withdraw ───────────────────────────────────────────────────────
// Called from WalletViewModel's scan handlers when a withdrawRequest is detected.
fun handleWithdrawResponse(raw: com.bitcointxoko.gudariwallet.api.LnurlScanResponse, lnurl: String) {
val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
_receiveState.value = ReceiveState.LnurlWithdrawReady(
k1 = raw.k1 ?: "",
callback = raw.callback ?: "",
defaultDescription = raw.defaultDescription ?: "",
minSats = minSats,
maxSats = maxSats,
domain = LnurlMetadataParser.extractDomain(raw.callback),
lnurl = lnurl
)
_navigateToReceive.tryEmit(Unit)
Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive")
}
fun executeWithdraw(
k1 : String,
callback : String,
amountSats: Long,
memo : String
) {
viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice
val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) }
if (invoiceResult.isFailure) {
val e = invoiceResult.exceptionOrNull() ?: Exception("Failed to create invoice")
Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice"))
return@launch
}
val invoice = invoiceResult.getOrThrow()
Log.d(TAG, "LNURL-WITHDRAW [INVOICE] ${invoice.paymentHash} — submitting to callback")
runCatching { repo.executeWithdraw(callback, k1, invoice.bolt11) }
.onSuccess { response ->
if (response.status == "OK") {
Log.d(TAG, "LNURL-WITHDRAW [CALLBACK OK] waiting for push payment")
_receiveState.value = ReceiveState.InvoiceReady(
bolt11 = invoice.bolt11,
paymentHash = invoice.paymentHash,
amountSats = amountSats,
memo = memo,
expiresAt = System.currentTimeMillis() / 1000L + 600L
)
} else {
val reason = response.reason?.ifBlank { null }
?: "Withdraw service rejected the invoice"
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason")
_receiveState.value = ReceiveState.Error(reason)
}
}
.onFailure { e ->
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Withdraw failed"))
}
}
}
}