refactor: WalletViewModel scan

This commit is contained in:
2026-06-01 20:05:31 +02:00
parent 5179b24b44
commit 234fa11af8
@@ -319,186 +319,24 @@ 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) {
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)
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
SendInputType.Bip21 -> handleBip21Scan(rawInput)
SendInputType.Lud17Url -> handleLud17Scan(rawInput)
SendInputType.Bolt11 -> handleBolt11Scan(normalized)
else -> handleLnurlScan(normalized)
}
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"
)
}
}
}
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"))
}
}
}
private fun handleWithdrawResponse(raw: LnurlScanResponse, lnurl: String) {
val minSats = (raw.minWithdrawable ?: 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<String, String> {
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"))
}
}
}
}