From e6c24c761bfc2b43b5c949b6e22d011d5ae50836 Mon Sep 17 00:00:00 2001 From: rasputin Date: Mon, 1 Jun 2026 21:13:04 +0200 Subject: [PATCH] refactor: WalletViewModel extract ReceiveViewModel --- .../gudariwallet/ui/WalletViewModel.kt | 144 ++++-------------- .../gudariwallet/ui/WalletViewModelFactory.kt | 7 +- .../ui/receive/ReceiveViewModel.kt | 136 +++++++++++++++++ 3 files changed, 170 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt 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 10c424b..b167f66 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -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 get() = receiveVm.receiveState + val navigateToReceive: SharedFlow 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.Idle) - val receiveState: StateFlow = _receiveState private val _sendState = MutableStateFlow(SendState.Idle) val sendState: StateFlow = _sendState - private val _navigateToReceive = MutableSharedFlow(extraBufferCapacity = 1) - val navigateToReceive: SharedFlow = _navigateToReceive private val _lightningAddress = MutableStateFlow(balancePrefs.lightningAddress) val lightningAddress: StateFlow = _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( } } } - } \ No newline at end of file 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 5c47556..15c6ca2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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 create(modelClass: Class): 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 } } \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt new file mode 100644 index 0000000..b6c092e --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt @@ -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.Idle) + val receiveState: StateFlow = _receiveState + + private val _navigateToReceive = MutableSharedFlow(extraBufferCapacity = 1) + val navigateToReceive: SharedFlow = _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")) + } + } + } +} \ No newline at end of file