From 08df82a2845a4e1ae9f675ded41458193f22c37e Mon Sep 17 00:00:00 2001 From: rasputin Date: Sat, 6 Jun 2026 16:05:51 +0200 Subject: [PATCH] refactor: extract LnurlScanner --- .../gudariwallet/ui/WalletViewModelFactory.kt | 11 +- .../gudariwallet/ui/send/LnurlScanner.kt | 62 +++++++ .../gudariwallet/ui/send/SendViewModel.kt | 156 ++++++------------ 3 files changed, 118 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlScanner.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 f1632cd..3498bc0 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -19,6 +19,7 @@ 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.LnurlScanner import com.bitcointxoko.gudariwallet.ui.send.SendViewModel class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { @@ -26,11 +27,12 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F val secrets = EncryptedSecretStore(app) val api = LNbitsClient.create(secrets) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) - val lnurlCache = LnurlCacheRepository(app) - val paymentCache = PaymentCacheRepository(app) - val fiatDataStore = FiatDataStore(app) val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc) val repo = LNbitsWalletRepository(api = api, secrets = secrets) + val lnurlCache = LnurlCacheRepository(app) + val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) + val paymentCache = PaymentCacheRepository(app) + val fiatDataStore = FiatDataStore(app) val balancePrefs = BalancePrefsStore(app) val lnAddressStore = LightningAddressStore(app) val balanceVm = BalanceViewModel(repo, balancePrefs) @@ -43,7 +45,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F balanceVm = balanceVm, onWithdrawScanned = { raw, lnurl -> receiveVm.handleWithdrawResponse(raw, lnurl) - } + }, + scanner = lnurlScanner ) val clipboardVm = ClipboardViewModel( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlScanner.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlScanner.kt new file mode 100644 index 0000000..d31bfe9 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlScanner.kt @@ -0,0 +1,62 @@ +package com.bitcointxoko.gudariwallet.ui.send + +import android.util.Log +import com.bitcointxoko.gudariwallet.api.LnurlScanResponse +import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository +import com.bitcointxoko.gudariwallet.data.WalletRepository + +class LnurlScanner( + private val repo : WalletRepository, + private val lnurlCache : LnurlCacheRepository, +) { + /** + * Cache → direct → proxy. Returns the raw response, or a failure with + * a descriptive exception. Does NOT write to cache on success — caller + * decides whether to cache (scan does, rescan does after validation). + */ + suspend fun scan(lnurl: String): Result { + // 1. Cache hit + val cached = lnurlCache.get(lnurl) + if (cached != null) { + Log.d(TAG, "LNURL [SCAN CACHE ] $lnurl") + return Result.success(LnurlScanResult(cached, fromCache = true)) + } + + // 2. Direct + val direct = runCatching { repo.scanLnurlDirect(lnurl) } + if (direct.isSuccess) { + Log.d(TAG, "LNURL [SCAN DIRECT] tag=${direct.getOrThrow().tag}") + return Result.success(LnurlScanResult(direct.getOrThrow().rawRes, fromCache = false)) + } + Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy") + + // 3. Proxy fallback + val proxy = runCatching { repo.scanLnurl(lnurl) } + if (proxy.isSuccess) { + Log.d(TAG, "LNURL [SCAN PROXY ] tag=${proxy.getOrThrow().tag}") + return Result.success(LnurlScanResult(proxy.getOrThrow().rawRes, fromCache = false)) + } + Log.e(TAG, "LNURL [SCAN PROXY FAIL] ${proxy.exceptionOrNull()?.message}") + return Result.failure(proxy.exceptionOrNull() ?: Exception("Scan failed")) + } + + /** Same as [scan] but always skips the cache (used after cache invalidation). */ + suspend fun rescan(lnurl: String): Result { + val direct = runCatching { repo.scanLnurlDirect(lnurl) } + if (direct.isSuccess) { + Log.d(TAG, "LNURL [RESCAN DIRECT] success") + return Result.success(direct.getOrThrow().rawRes) + } + Log.w(TAG, "LNURL [RESCAN DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy") + + val proxy = runCatching { repo.scanLnurl(lnurl) } + if (proxy.isSuccess) return Result.success(proxy.getOrThrow().rawRes) + + Log.e(TAG, "LNURL [RESCAN ERR] ${proxy.exceptionOrNull()?.message}") + return Result.failure(proxy.exceptionOrNull() ?: Exception("Rescan failed")) + } + + data class LnurlScanResult(val raw: LnurlScanResponse, val fromCache: Boolean) + + companion object { private const val TAG = "LnurlScanner" } +} \ No newline at end of file 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 3771576..0ebc8c8 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 @@ -54,6 +54,7 @@ class SendViewModel( private val lnurlCache : LnurlCacheRepository, private val balanceVm : BalanceViewModel, private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit, + private val scanner : LnurlScanner, ) : ViewModel() { private val _sendState = MutableStateFlow(SendState.Idle) @@ -214,7 +215,7 @@ class SendViewModel( // ── 1. Initial attempt (direct → proxy) val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL", strings) if (initial.isSuccess) { - recordPaymentSuccess(initial.getOrThrow(), amountMsat) + recordPaymentSuccess(initial.getOrThrow(), amountMsat, strings = strings) return@launch } @@ -245,7 +246,7 @@ class SendViewModel( // ── 4. Final attempt (direct → proxy) with fresh metadata val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY", strings) if (retry.isSuccess) { - recordPaymentSuccess(retry.getOrThrow(), amountMsat) + recordPaymentSuccess(retry.getOrThrow(), amountMsat, strings = strings) return@launch } @@ -341,24 +342,15 @@ class SendViewModel( if (directResult.isSuccess) { val paymentHash = directResult.getOrThrow().paymentHash - val feeSats = runCatching { - repo.getPaymentDetail(paymentHash).details?.feeSat - }.getOrNull() - _sendState.value = SendState.PaymentSent( - amountSats = state.amountSats, - feeSats = feeSats, - paymentHash = paymentHash, - pendingRecord = buildPendingRecord( - paymentHash = paymentHash, - amountSats = state.amountSats, - feeSats = feeSats, - bolt11 = state.bolt11, - memo = state.memo, - pubkey = state.payee, - alias = state.nodeAlias - ) + handlePaymentResult( + paymentHash = paymentHash, + amountSats = state.amountSats, + bolt11 = state.bolt11, + memo = state.memo, + pubkey = state.payee, + alias = state.nodeAlias, + strings = strings, ) - balanceVm.refreshBalance() return@launch } @@ -375,6 +367,7 @@ class SendViewModel( } } + /** * Biometric gate. Shows prompt if device supports it; calls [pay] directly if not. */ @@ -504,72 +497,33 @@ class SendViewModel( private fun handleLnurlScan(normalized: String, strings: SendStrings) { _sendState.value = SendState.Scanning viewModelScope.launch { - // 1. Cache hit - val cached = lnurlCache.get(normalized) - if (cached != null) { - Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call") - when (cached.tag) { - "withdrawRequest" -> { - _sendState.value = SendState.Idle - onWithdrawScanned(cached, normalized) - } - else -> _sendState.value = buildLnurlReadyState(cached, normalized) - } + val result = scanner.scan(normalized) + if (result.isFailure) { + _sendState.value = SendState.Error( + parseApiError( + result.exceptionOrNull() ?: Exception("Scan failed"), + strings.couldNotResolveAddress + ) ) return@launch } - Log.d(TAG, "LNURL [SCAN START ] normalized=$normalized") - // 2. Client-side direct - val directResult = runCatching { repo.scanLnurlDirect(normalized) } - if (directResult.isSuccess) { - val scanned = directResult.getOrThrow() - Log.d(TAG, "LNURL [SCAN DIRECT] tag=${scanned.tag} for $normalized") - val protocolError = lnurlProtocolError(scanned.rawRes) - if (protocolError != null) { - Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") - _sendState.value = SendState.Error(protocolError) - return@launch - } - lnurlCache.put(normalized, scanned.rawRes) - when (scanned.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) - "withdrawRequest" -> { - _sendState.value = SendState.Idle - onWithdrawScanned(scanned.rawRes, normalized) - } - null -> _sendState.value = SendState.Error(strings.emptyResponse) // ← "Empty response from server" - else -> _sendState.value = SendState.Error(strings.unsupportedType(scanned.tag)) // ← "Unsupported type: $tag" - } + val (raw, fromCache) = result.getOrThrow() + + val protocolError = lnurlProtocolError(raw) + if (protocolError != null) { + Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") + _sendState.value = SendState.Error(protocolError) return@launch } - Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to proxy") - // 3. Server proxy fallback - runCatching { repo.scanLnurl(normalized) } - .onSuccess { scanned -> - Log.d(TAG, "LNURL [SCAN PROXY ] tag=${scanned.tag} for $normalized") - val protocolError = lnurlProtocolError(scanned.rawRes) - if (protocolError != null) { - Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") - _sendState.value = SendState.Error(protocolError) - return@onSuccess - } - lnurlCache.put(normalized, scanned.rawRes) - when (scanned.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) - "withdrawRequest" -> { - _sendState.value = SendState.Idle - onWithdrawScanned(scanned.rawRes, normalized) - } - null -> _sendState.value = SendState.Error(strings.emptyResponse) // ← "Empty response from server" - else -> _sendState.value = SendState.Error(strings.unsupportedType(scanned.tag)) // ← "Unsupported type: $tag" - } - } - .onFailure { e -> - _sendState.value = SendState.Error( - parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address" - ) - } + if (!fromCache) lnurlCache.put(normalized, raw) + + when (raw.tag) { + "payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized) + "withdrawRequest" -> { _sendState.value = SendState.Idle; onWithdrawScanned(raw, normalized) } + null -> _sendState.value = SendState.Error(strings.emptyResponse) + else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag)) + } } } @@ -594,27 +548,22 @@ class SendViewModel( bolt11 : String? = null, memo : String? = null, pubkey : String? = null, - alias : String? = null + alias : String? = null, + strings : SendStrings, ) { - val feeSats = runCatching { repo.getPaymentDetail(paymentHash).details?.feeSat }.getOrNull() val amountSats = amountMsat / WalletConstants.MSAT_PER_SAT - _sendState.value = SendState.PaymentSent( - amountSats = amountSats, - feeSats = feeSats, - paymentHash = paymentHash, - pendingRecord = buildPendingRecord( - paymentHash = paymentHash, - amountSats = amountSats, - feeSats = feeSats, - bolt11 = bolt11, - memo = memo, - pubkey = pubkey, - alias = alias - ) + handlePaymentResult( + paymentHash = paymentHash, + amountSats = amountSats, + bolt11 = bolt11, + memo = memo, + pubkey = pubkey, + alias = alias, + strings = strings, ) - balanceVm.refreshBalance() } + private suspend fun tryPayLnurlWithFallback( rawRes : LnurlScanResponse, lnurl : String, @@ -642,21 +591,14 @@ class SendViewModel( } private suspend fun rescanLnurl(lnurl: String, strings: SendStrings): LnurlScanResponse? { - val direct = runCatching { repo.scanLnurlDirect(lnurl) } - if (direct.isSuccess) { - Log.d(TAG, "LNURL [RESCAN DIRECT] success") - return direct.getOrThrow().rawRes - } - Log.w(TAG, "LNURL [RESCAN DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy rescan") + val result = scanner.rescan(lnurl) + if (result.isSuccess) return result.getOrThrow() - val proxy = runCatching { repo.scanLnurl(lnurl) } - if (proxy.isSuccess) return proxy.getOrThrow().rawRes - - Log.e(TAG, "LNURL [RESCAN ERR] ${proxy.exceptionOrNull()?.message}") + Log.e(TAG, "LNURL [RESCAN ERR] ${result.exceptionOrNull()?.message}") _sendState.value = SendState.Error( parseApiError( - proxy.exceptionOrNull() ?: Exception(), - strings.paymentFailedRescan // ← "Payment failed — could not re-fetch address" + result.exceptionOrNull() ?: Exception("Rescan failed"), + strings.paymentFailedRescan ) ) return null