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 7cc9dfa..dc09c83 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -319,187 +319,25 @@ class WalletViewModel( fun scan(rawInput: String) { val normalized = SendInputDetector.normalize(rawInput) val type = SendInputDetector.detect(rawInput) - Log.d("SCAN_DEBUG", "input='$rawInput' → type=$type") + Log.d(TAG, "SCAN [DETECT] input='$rawInput' → type=$type") - if (type == SendInputType.Unknown) { - val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) { - "Lightning Login (LNURL-auth) is not yet supported" - } else { - "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" - } - _sendState.value = SendState.Error(msg) - return - } - if (type == SendInputType.Bip21) { - val raw = rawInput.trim() - val query = raw.substringAfter("?", missingDelimiterValue = "") - val params = query.split("&").associate { pair -> - val key = pair.substringBefore("=") - val value = pair.substringAfter("=", missingDelimiterValue = "") - .replace("+", " ") - .let { java.net.URLDecoder.decode(it, "UTF-8") } - key to value - } - val bolt11 = params["lightning"] - val lnurl = params["lnurl"] ?: params["lnurlp"] - - return when { - bolt11 != null -> scan(bolt11) - lnurl != null -> scan(lnurl) - else -> { - _sendState.value = SendState.Error( - "On-chain Bitcoin payments are not supported — " + - "share a BIP-21 URI with a lightning= parameter" - ) + when (type) { + SendInputType.Unknown -> { + val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) { + "Lightning Login (LNURL-auth) is not yet supported" + } else { + "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" } + _sendState.value = SendState.Error(msg) } - } - if (type == SendInputType.Lud17Url) { - val scheme = rawInput.trim().lowercase().substringBefore("://") - if (scheme == "lnurlc") { - _sendState.value = SendState.Error("Channel requests (lnurlc) are not supported") - return - } - val httpsUrl = "https://" + rawInput.trim().substringAfter("://") - Log.d(TAG, "LUD17 [FETCH] $httpsUrl") - _sendState.value = SendState.Scanning - viewModelScope.launch { - runCatching { repo.fetchLnurlDirect(httpsUrl) } - .onSuccess { raw -> - Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}") - when (raw.tag) { - "withdrawRequest" -> handleWithdrawResponse(raw, httpsUrl) - "payRequest" -> { - lnurlCache.put(httpsUrl, raw) - _sendState.value = SendState.LnurlReady( - callback = raw.callback ?: "", - description = LnurlMetadataParser.parseDescription(raw.metadata), - domain = LnurlMetadataParser.extractDomain(raw.callback), - minSats = (raw.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, - maxSats = (raw.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, - commentAllowed = raw.commentAllowed ?: 0, - lnurl = httpsUrl, - rawRes = raw - ) - } - else -> _sendState.value = SendState.Error( - "Unsupported LNURL type: ${raw.tag ?: "unknown"}" - ) - } - } - .onFailure { e -> - Log.e(TAG, "LUD17 [ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) - } - } - return - } - viewModelScope.launch { - _sendState.value = SendState.Scanning - - if (type == SendInputType.Bolt11) { - runCatching { repo.decodeBolt11(normalized) } - .onSuccess { decoded -> - val routeRisk = RouteHintAnalyzer.analyze( - amountMsat = decoded.amountSats * 1_000L, - hints = decoded.routeHints - ) - _sendState.value = SendState.Bolt11Decoded( - bolt11 = normalized, - amountSats = decoded.amountSats, - memo = decoded.memo, - payee = decoded.payee, - nodeAlias = null, - routeRisk = routeRisk, - allRouteHints = decoded.routeHints, - routeHintAliases = emptyMap() - ) - resolveAliasesInBackground( - payee = decoded.payee, - routeHints = decoded.routeHints, - readState = { _sendState.value }, - copyAlias = { state, alias -> - (state as? SendState.Bolt11Decoded) - ?.takeIf { it.payee == decoded.payee } - ?.copy(nodeAlias = alias) - }, - copyHint = { state, pubkey, alias -> - (state as? SendState.Bolt11Decoded) - ?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias)) - } - ) - } - .onFailure { e -> - Log.e(TAG, "SEND [DECODE ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) - } - return@launch - } - - // ── LNURL / Lightning Address ───────────────────────────────────── - - // 1. Try cache first — skips the lnurlscan round trip entirely - val cacheKey = normalized - val cached = lnurlCache.get(cacheKey) - if (cached != null) { - Log.d(TAG, "LNURL [SCAN CACHE ] $cacheKey — skipping lnurlscan network call") - when (cached.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(cached, normalized) - "withdrawRequest" -> handleWithdrawResponse(cached, normalized) - else -> _sendState.value = buildLnurlReadyState(cached, normalized) - } - return@launch - } - Log.d(TAG, "LNURL [SCAN START ] normalized=$normalized") - - // 2. Cache miss — try client-side resolution first (avoids server proxy) - 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(cacheKey, scanned.rawRes) // cache on client-side success - when (scanned.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) - "withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized) - null -> _sendState.value = SendState.Error("Empty response from server") - else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") - } - return@launch - } - - Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to server proxy") - - // 3. Client-side failed — fall back to server proxy - 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(cacheKey, scanned.rawRes) // cache on proxy success too - when (scanned.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) - "withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized) - null -> _sendState.value = SendState.Error("Empty response from server") - else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") - } - } - .onFailure { e -> - _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) - } + SendInputType.Bip21 -> handleBip21Scan(rawInput) + SendInputType.Lud17Url -> handleLud17Scan(rawInput) + SendInputType.Bolt11 -> handleBolt11Scan(normalized) + else -> handleLnurlScan(normalized) } } + 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 @@ -955,4 +793,167 @@ class WalletViewModel( } } } + /** + * Parses the query string of a BIP-21 URI into a key→value map. + * Handles URL-encoding and `+`-as-space. + */ + private fun parseBip21Params(raw: String): Map { + val query = raw.trim().substringAfter("?", missingDelimiterValue = "") + if (query.isBlank()) return emptyMap() + return query.split("&").mapNotNull { pair -> + val key = pair.substringBefore("=").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val value = runCatching { + java.net.URLDecoder.decode( + pair.substringAfter("=", "").replace("+", " "), "UTF-8" + ) + }.getOrDefault("") + key to value + }.toMap() + } + + private fun handleBip21Scan(rawInput: String) { + val params = parseBip21Params(rawInput) + val bolt11 = params["lightning"] + val lnurl = params["lnurl"] ?: params["lnurlp"] + when { + bolt11 != null -> scan(bolt11) + lnurl != null -> scan(lnurl) + else -> _sendState.value = SendState.Error( + "On-chain Bitcoin payments are not supported — " + + "share a BIP-21 URI with a lightning= parameter" + ) + } + } + private fun handleLud17Scan(rawInput: String) { + val scheme = rawInput.trim().lowercase().substringBefore("://") + if (scheme == "lnurlc") { + _sendState.value = SendState.Error("Channel requests (lnurlc) are not supported") + return + } + val httpsUrl = "https://" + rawInput.trim().substringAfter("://") + Log.d(TAG, "LUD17 [FETCH] $httpsUrl") + _sendState.value = SendState.Scanning + viewModelScope.launch { + runCatching { repo.fetchLnurlDirect(httpsUrl) } + .onSuccess { raw -> + Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}") + when (raw.tag) { + "withdrawRequest" -> handleWithdrawResponse(raw, httpsUrl) + "payRequest" -> { + lnurlCache.put(httpsUrl, raw) + _sendState.value = buildLnurlReadyState(raw, httpsUrl) + } + else -> _sendState.value = SendState.Error( + "Unsupported LNURL type: ${raw.tag ?: "unknown"}" + ) + } + } + .onFailure { e -> + Log.e(TAG, "LUD17 [ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + } + } + } + private fun handleBolt11Scan(normalized: String) { + viewModelScope.launch { + _sendState.value = SendState.Scanning + runCatching { repo.decodeBolt11(normalized) } + .onSuccess { decoded -> + val routeRisk = RouteHintAnalyzer.analyze( + amountMsat = decoded.amountSats * 1_000L, + hints = decoded.routeHints + ) + _sendState.value = SendState.Bolt11Decoded( + bolt11 = normalized, + amountSats = decoded.amountSats, + memo = decoded.memo, + payee = decoded.payee, + nodeAlias = null, + routeRisk = routeRisk, + allRouteHints = decoded.routeHints, + routeHintAliases = emptyMap() + ) + resolveAliasesInBackground( + payee = decoded.payee, + routeHints = decoded.routeHints, + readState = { _sendState.value }, + copyAlias = { state, alias -> + (state as? SendState.Bolt11Decoded) + ?.takeIf { it.payee == decoded.payee } + ?.copy(nodeAlias = alias) + }, + copyHint = { state, pubkey, alias -> + (state as? SendState.Bolt11Decoded) + ?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias)) + } + ) + } + .onFailure { e -> + Log.e(TAG, "SEND [DECODE ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + } + } + } + private fun handleLnurlScan(normalized: String) { + viewModelScope.launch { + _sendState.value = SendState.Scanning + + // 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" -> handleWithdrawResponse(cached, normalized) + else -> _sendState.value = buildLnurlReadyState(cached, normalized) + } + 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" -> handleWithdrawResponse(scanned.rawRes, normalized) + null -> _sendState.value = SendState.Error("Empty response from server") + else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + } + 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" -> handleWithdrawResponse(scanned.rawRes, normalized) + null -> _sendState.value = SendState.Error("Empty response from server") + else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + } + } + .onFailure { e -> + _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + } + } + } + } \ No newline at end of file