refactor: WalletViewModel scan
This commit is contained in:
@@ -319,186 +319,24 @@ class WalletViewModel(
|
|||||||
fun scan(rawInput: String) {
|
fun scan(rawInput: String) {
|
||||||
val normalized = SendInputDetector.normalize(rawInput)
|
val normalized = SendInputDetector.normalize(rawInput)
|
||||||
val type = SendInputDetector.detect(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")) {
|
val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) {
|
||||||
"Lightning Login (LNURL-auth) is not yet supported"
|
"Lightning Login (LNURL-auth) is not yet supported"
|
||||||
} else {
|
} else {
|
||||||
"Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
|
"Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
|
||||||
}
|
}
|
||||||
_sendState.value = SendState.Error(msg)
|
_sendState.value = SendState.Error(msg)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (type == SendInputType.Bip21) {
|
SendInputType.Bip21 -> handleBip21Scan(rawInput)
|
||||||
val raw = rawInput.trim()
|
SendInputType.Lud17Url -> handleLud17Scan(rawInput)
|
||||||
val query = raw.substringAfter("?", missingDelimiterValue = "")
|
SendInputType.Bolt11 -> handleBolt11Scan(normalized)
|
||||||
val params = query.split("&").associate { pair ->
|
else -> handleLnurlScan(normalized)
|
||||||
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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
private fun handleWithdrawResponse(raw: LnurlScanResponse, lnurl: String) {
|
||||||
val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user