refactor: WalletViewModel extract ReceiveViewModel
This commit is contained in:
@@ -14,7 +14,6 @@ import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
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
|
||||
@@ -25,6 +24,7 @@ import com.bitcointxoko.gudariwallet.api.RouteHintHop
|
||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
||||
import com.bitcointxoko.gudariwallet.util.BiometricHelper
|
||||
|
||||
private const val TAG = "WalletViewModel"
|
||||
@@ -36,7 +36,8 @@ class WalletViewModel(
|
||||
val paymentCache: PaymentCacheRepository,
|
||||
private val balancePrefs: BalancePrefsStore,
|
||||
val balanceVm : BalanceViewModel,
|
||||
val fiatVm : FiatViewModel
|
||||
val fiatVm : FiatViewModel,
|
||||
val receiveVm : ReceiveViewModel,
|
||||
) : ViewModel() {
|
||||
|
||||
// ── Balance state ─────────────────────────────────────────────────────────
|
||||
@@ -49,15 +50,17 @@ class WalletViewModel(
|
||||
suspend fun getSupportedCurrencies() = fiatVm.getSupportedCurrencies()
|
||||
val fiatRate: Double? get() = fiatVm.fiatRate
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
|
||||
val receiveState: StateFlow<ReceiveState> = _receiveState
|
||||
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
|
||||
val sendState: StateFlow<SendState> = _sendState
|
||||
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val navigateToReceive: SharedFlow<Unit> = _navigateToReceive
|
||||
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
|
||||
val lightningAddress: StateFlow<String?> = _lightningAddress
|
||||
|
||||
@@ -72,18 +75,11 @@ class WalletViewModel(
|
||||
private fun observePaymentEvents() {
|
||||
viewModelScope.launch {
|
||||
balanceVm.incomingPayment.collect { event ->
|
||||
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
|
||||
)
|
||||
}
|
||||
receiveVm.onIncomingPayment(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchLightningAddress() {
|
||||
// If we already have a persisted address, show it immediately and
|
||||
// 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L
|
||||
@@ -469,7 +370,7 @@ class WalletViewModel(
|
||||
if (trimmed == lastOfferedClipboard) return
|
||||
|
||||
// ── 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)) {
|
||||
Log.d("Clipboard", "Skipping own invoice")
|
||||
lastOfferedClipboard = trimmed // still mark as seen so focus re-checks don't re-trigger
|
||||
@@ -664,7 +565,10 @@ class WalletViewModel(
|
||||
.onSuccess { raw ->
|
||||
Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}")
|
||||
when (raw.tag) {
|
||||
"withdrawRequest" -> handleWithdrawResponse(raw, httpsUrl)
|
||||
"withdrawRequest" -> {
|
||||
_sendState.value = SendState.Idle
|
||||
receiveVm.handleWithdrawResponse(raw, httpsUrl)
|
||||
}
|
||||
"payRequest" -> {
|
||||
lnurlCache.put(httpsUrl, raw)
|
||||
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
|
||||
@@ -729,7 +633,10 @@ class WalletViewModel(
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call")
|
||||
when (cached.tag) {
|
||||
"withdrawRequest" -> handleWithdrawResponse(cached, normalized)
|
||||
"withdrawRequest" -> {
|
||||
_sendState.value = SendState.Idle
|
||||
receiveVm.handleWithdrawResponse(cached, normalized)
|
||||
}
|
||||
else -> _sendState.value = buildLnurlReadyState(cached, normalized)
|
||||
}
|
||||
return@launch
|
||||
@@ -750,7 +657,10 @@ class WalletViewModel(
|
||||
lnurlCache.put(normalized, scanned.rawRes)
|
||||
when (scanned.tag) {
|
||||
"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")
|
||||
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
|
||||
}
|
||||
@@ -771,7 +681,10 @@ class WalletViewModel(
|
||||
lnurlCache.put(normalized, scanned.rawRes)
|
||||
when (scanned.tag) {
|
||||
"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")
|
||||
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.ui.balance.BalanceViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
||||
|
||||
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||
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 balanceVm = BalanceViewModel(repo, balancePrefs)
|
||||
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
|
||||
val receiveVm = ReceiveViewModel(repo)
|
||||
|
||||
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
|
||||
|
||||
val lnurlCache = LnurlCacheRepository(app)
|
||||
val paymentCache = PaymentCacheRepository(app)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -36,7 +40,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
||||
paymentCache,
|
||||
balancePrefs,
|
||||
balanceVm,
|
||||
fiatVm
|
||||
fiatVm,
|
||||
receiveVm
|
||||
) 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user