feat: localisation with lyricist

This commit is contained in:
2026-06-06 01:50:29 +02:00
parent 50d952b4d7
commit 8ef2d80901
9 changed files with 257 additions and 128 deletions
@@ -110,7 +110,7 @@ val EnHomeStrings = AppStrings(
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
receiveVmWithdrawFailed = "Withdraw failed",
// ── SendScreen — general ──────────────────────────────────────────────────
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Send",
paste = "Paste",
continueButton = "Continue",
@@ -176,6 +176,20 @@ val EnHomeStrings = AppStrings(
routeHintHopsHeader = "Private route hint hops",
routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" },
// ── SendViewModel ─────────────────────────────────────────────────────────
sendVmLnurlAuthNotSupported = "Lightning Login (LNURL-auth) is not yet supported",
sendVmUnrecognisedInput = "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address",
sendVmPaymentFailed = "Payment failed",
sendVmCouldNotFetchInvoice = "Could not fetch invoice from recipient",
sendVmCouldNotDecodeInvoice = "Could not decode invoice",
sendVmAuthFailed = { msg -> "Authentication failed: $msg" },
sendVmOnChainNotSupported = "On-chain Bitcoin payments are not supported — share a BIP-21 URI with a lightning= parameter",
sendVmChannelRequestNotSupported = "Channel requests (lnurlc) are not supported",
sendVmCouldNotResolveAddress = "Could not resolve address",
sendVmEmptyResponse = "Empty response from server",
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "History",
historyClose = "Close",
@@ -110,7 +110,7 @@ val EsHomeStrings = AppStrings(
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
receiveVmWithdrawFailed = "Error al retirar",
// ── SendScreen — general ──────────────────────────────────────────────────
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar",
paste = "Pegar",
continueButton = "Continuar",
@@ -176,7 +176,21 @@ val EsHomeStrings = AppStrings(
routeHintHopsHeader = "Saltos de ruta privada",
routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" },
// ── HistoryScreen ─────────────────────────────────────────────────────────
// ── SendViewModel ─────────────────────────────────────────────────────────
sendVmLnurlAuthNotSupported = "Lightning Login (LNURL-auth) aún no está disponible",
sendVmUnrecognisedInput = "Entrada no reconocida — pega una factura BOLT-11, LNURL o dirección Lightning",
sendVmPaymentFailed = "Pago fallido",
sendVmCouldNotFetchInvoice = "No se pudo obtener la factura del destinatario",
sendVmCouldNotDecodeInvoice = "No se pudo decodificar la factura",
sendVmAuthFailed = { msg -> "Autenticación fallida: $msg" },
sendVmOnChainNotSupported = "Los pagos Bitcoin en cadena no están disponibles — comparte un URI BIP-21 con el parámetro lightning=",
sendVmChannelRequestNotSupported = "Las solicitudes de canal (lnurlc) no están disponibles",
sendVmCouldNotResolveAddress = "No se pudo resolver la dirección",
sendVmEmptyResponse = "Respuesta vacía del servidor",
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historial",
historyClose = "Cerrar",
historySearchPayments = "Buscar pagos",
@@ -185,6 +185,20 @@ data class AppStrings(
val routeHintHopsHeader : String,
val routeHintHopFees : (ppm: Long, baseLabel: String) -> String,
// ── SendViewModel ─────────────────────────────────────────────────────────
val sendVmLnurlAuthNotSupported : String,
val sendVmUnrecognisedInput : String,
val sendVmPaymentFailed : String,
val sendVmCouldNotFetchInvoice : String,
val sendVmCouldNotDecodeInvoice : String,
val sendVmAuthFailed : (msg: CharSequence) -> String,
val sendVmOnChainNotSupported : String,
val sendVmChannelRequestNotSupported : String,
val sendVmCouldNotResolveAddress : String,
val sendVmEmptyResponse : String,
val sendVmUnsupportedType : (tag: String) -> String,
val sendVmPaymentFailedRescan : String,
// ── HistoryScreen ─────────────────────────────────────────────────────────
val historyTitle : String,
val historyClose : String,
@@ -64,6 +64,7 @@ import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.delay
@@ -90,6 +91,22 @@ fun WalletScreen(
lyricist : Lyricist<AppStrings>
) {
val strings = LocalAppStrings.current
val sendStrings = remember(strings) {
SendStrings(
lnurlAuthNotSupported = strings.sendVmLnurlAuthNotSupported,
unrecognisedInput = strings.sendVmUnrecognisedInput,
paymentFailed = strings.sendVmPaymentFailed,
couldNotFetchInvoice = strings.sendVmCouldNotFetchInvoice,
couldNotDecodeInvoice = strings.sendVmCouldNotDecodeInvoice,
onChainNotSupported = strings.sendVmOnChainNotSupported,
channelRequestNotSupported = strings.sendVmChannelRequestNotSupported,
couldNotResolveAddress = strings.sendVmCouldNotResolveAddress,
emptyResponse = strings.sendVmEmptyResponse,
unsupportedType = strings.sendVmUnsupportedType,
paymentFailedRescan = strings.sendVmPaymentFailedRescan,
authFailed = strings.sendVmAuthFailed,
)
}
val navController = rememberNavController()
val context = LocalContext.current
val historyFactory = remember {
@@ -153,7 +170,7 @@ fun WalletScreen(
if (intentUri != null) {
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
pendingPaymentUri.value = null
vm.scan(intentUri)
vm.scan(intentUri, sendStrings)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
@@ -316,7 +333,8 @@ fun WalletScreen(
navController.navigate("payment_detail/$checkingId") {
launchSingleTop = true
}
}
},
sendStrings
)
}
@@ -324,7 +342,7 @@ fun WalletScreen(
composable(ROUTE_SCANNER) {
QrScannerScreen(
onScanned = { scanned ->
vm.scan(scanned)
vm.scan(scanned, sendStrings)
val wentBack = navController.popBackStack(TabItem.Send.route, inclusive = false)
if (!wentBack) {
navController.navigate(TabItem.Send.route) {
@@ -418,7 +436,7 @@ fun WalletScreen(
.fillMaxWidth()
.clickable {
vm.dismissClipboardOffer()
vm.scan(offer.raw)
vm.scan(offer.raw, sendStrings)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
@@ -474,7 +492,7 @@ fun WalletScreen(
modifier = Modifier
.fillMaxWidth()
.clickable {
vm.scan(offer.raw)
vm.scan(offer.raw, sendStrings)
nfcVm.dismissNfcOffer()
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
@@ -17,6 +17,7 @@ import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@@ -75,15 +76,38 @@ class WalletViewModel(
)
val sendState: StateFlow<SendState> get() = sendVm.sendState
fun refreshBalance() = balanceVm.refreshBalance()
fun scan(rawInput: String) = sendVm.scan(rawInput)
fun payBolt11(bolt11: String) = sendVm.payBolt11(bolt11)
fun payLnurl(rawRes: LnurlScanResponse, lnurl: String, amountMsat: Long, comment: String?) =
sendVm.payLnurl(rawRes, lnurl, amountMsat, comment)
fun fetchLnurlInvoice(rawRes: LnurlScanResponse, lnurl: String, domain: String, amountMsat: Long, comment: String?) =
sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment)
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) = sendVm.payLnurlInvoice(state)
fun requestPayment(activity: FragmentActivity, subtitle: String, pay: () -> Unit) =
sendVm.requestPayment(activity, subtitle, pay)
fun scan(rawInput: String, strings: SendStrings) =
sendVm.scan(rawInput, strings)
fun payBolt11(bolt11: String, strings: SendStrings) =
sendVm.payBolt11(bolt11, strings)
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) = sendVm.payLnurl(rawRes, lnurl, amountMsat, comment, strings)
fun fetchLnurlInvoice(
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings)
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
sendVm.payLnurlInvoice(state, strings)
fun requestPayment(
activity : FragmentActivity,
subtitle : String,
strings : SendStrings,
pay : () -> Unit,
) = sendVm.requestPayment(activity, subtitle, strings, pay)
fun resetSendState() = sendVm.resetSendState()
val lightningAddress: StateFlow<String?> = lnAddressStore.lightningAddressFlow
@@ -43,7 +43,8 @@ internal fun Bolt11ConfirmCard(
activity : FragmentActivity,
vm : WalletViewModel,
fiatRate : Double?,
fiatCurrency: String?
fiatCurrency: String?,
sendStrings : SendStrings,
) {
val strings = LocalAppStrings.current
val fiatLbl = fiatLabel(state.amountSats, fiatRate, fiatCurrency)
@@ -54,8 +55,9 @@ internal fun Bolt11ConfirmCard(
onPay = {
vm.requestPayment(
activity,
strings.sendBiometricPrompt(state.amountSats) // ← "Send X sats"
) { vm.payBolt11(state.bolt11) }
strings.sendBiometricPrompt(state.amountSats),
strings = sendStrings, // ← "Send X sats"
) { vm.payBolt11(state.bolt11, sendStrings) }
},
onCancel = { vm.resetSendState() }
)
@@ -67,7 +69,8 @@ internal fun LnurlInvoiceConfirmCard(
activity : FragmentActivity,
vm : WalletViewModel,
fiatRate : Double?,
fiatCurrency: String?
fiatCurrency: String?,
sendStrings : SendStrings,
) {
val strings = LocalAppStrings.current
val label = payingToLabel(state.lnurl, state.domain)
@@ -79,8 +82,9 @@ internal fun LnurlInvoiceConfirmCard(
onPay = {
vm.requestPayment(
activity,
strings.sendBiometricPromptTo(state.amountSats, label) // ← "Send X sats to Y"
) { vm.payLnurlInvoice(state) }
strings.sendBiometricPromptTo(state.amountSats, label),
strings = sendStrings, // ← "Send X sats to Y"
) { vm.payLnurlInvoice(state, sendStrings) }
},
onCancel = { vm.resetSendState() }
)
@@ -31,7 +31,8 @@ internal fun LnurlPayForm(
state : SendState.LnurlReady,
vm : WalletViewModel,
fiatRate : Double?,
fiatCurrency: String?
fiatCurrency: String?,
sendStrings: SendStrings
) {
val strings = LocalAppStrings.current
@@ -115,7 +116,8 @@ internal fun LnurlPayForm(
lnurl = state.lnurl,
domain = state.domain,
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
comment = comment.takeIf { it.isNotBlank() }
comment = comment.takeIf { it.isNotBlank() },
strings = sendStrings
)
}
},
@@ -38,7 +38,8 @@ import kotlinx.coroutines.launch
fun SendScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel,
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> }
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> },
sendStrings: SendStrings
) {
val strings = LocalAppStrings.current
val sendState : SendState by vm.sendState.collectAsStateWithLifecycle()
@@ -63,7 +64,7 @@ fun SendScreen(
val offer = nfcOffer
if (offer is NfcOfferState.Detected && sendState is SendState.Idle) {
input = offer.raw
vm.scan(offer.raw)
vm.scan(offer.raw, sendStrings)
nfcVm.dismissNfcOffer()
}
}
@@ -124,7 +125,7 @@ fun SendScreen(
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = { if (input.isNotBlank()) vm.scan(input) }
onGo = { if (input.isNotBlank()) vm.scan(input, sendStrings) }
),
)
Spacer(Modifier.height(8.dp))
@@ -143,7 +144,7 @@ fun SendScreen(
?.toString()
if (!text.isNullOrBlank()) {
input = SendInputDetector.normalize(text)
vm.scan(text)
vm.scan(text, sendStrings)
}
}
},
@@ -159,7 +160,7 @@ fun SendScreen(
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { if (input.isNotBlank()) vm.scan(input) },
onClick = { if (input.isNotBlank()) vm.scan(input, sendStrings) },
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) { Text(strings.continueButton) } // ← "Continue"
@@ -178,7 +179,8 @@ fun SendScreen(
activity = activity,
vm = vm,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency
fiatCurrency = fiatCurrency,
sendStrings
)
}
@@ -187,7 +189,8 @@ fun SendScreen(
state = state,
vm = vm,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency
fiatCurrency = fiatCurrency,
sendStrings
)
}
@@ -197,7 +200,8 @@ fun SendScreen(
activity = activity,
vm = vm,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency
fiatCurrency = fiatCurrency,
sendStrings
)
}
@@ -284,7 +288,7 @@ fun SendScreen(
)
Spacer(Modifier.height(12.dp))
Button(
onClick = { if (input.isNotBlank()) vm.scan(input) },
onClick = { if (input.isNotBlank()) vm.scan(input, sendStrings) },
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) { Text(strings.tryAgain) } // ← "Try Again"
@@ -26,13 +26,32 @@ import kotlinx.coroutines.launch
private const val TAG = "SendViewModel"
// ── String bundle injected at the call site ───────────────────────────────────
/**
* All user-visible strings produced by [SendViewModel].
* Populate from [LocalAppStrings.current] in the owning Composable and pass
* to [SendViewModel.scan], [SendViewModel.payBolt11], etc.
*/
data class SendStrings(
val lnurlAuthNotSupported : String,
val unrecognisedInput : String,
val paymentFailed : String,
val couldNotFetchInvoice : String,
val couldNotDecodeInvoice : String,
val onChainNotSupported : String,
val channelRequestNotSupported : String,
val couldNotResolveAddress : String,
val emptyResponse : String,
val unsupportedType : (tag: String) -> String,
val paymentFailedRescan : String,
val authFailed : (msg: String) -> String,
)
class SendViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val lnurlCache : LnurlCacheRepository,
private val balanceVm : BalanceViewModel,
// Called when a withdrawRequest is scanned — hands off to ReceiveViewModel
// without creating a hard dependency between the two.
private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit,
) : ViewModel() {
@@ -41,33 +60,32 @@ class SendViewModel(
// ── Entry point ──────────────────────────────────────────────────────────
fun scan(rawInput: String) {
fun scan(rawInput: String, strings: SendStrings) {
val normalized = SendInputDetector.normalize(rawInput)
val type = SendInputDetector.detect(rawInput)
Log.d(TAG, "SCAN [DETECT] input='$rawInput' → type=$type")
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"
}
SendInputType.Unknown -> {
val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported"
else
strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
_sendState.value = SendState.Error(msg)
}
SendInputType.Bip21 -> handleBip21Scan(rawInput)
SendInputType.Lud17Url -> handleLud17Scan(rawInput)
SendInputType.Bolt11 -> handleBolt11Scan(normalized)
else -> handleLnurlScan(normalized)
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
SendInputType.Lud17Url -> handleLud17Scan(rawInput, strings)
SendInputType.Bolt11 -> handleBolt11Scan(normalized, strings)
else -> handleLnurlScan(normalized, strings)
}
}
// ── Payment ──────────────────────────────────────────────────────────────
fun payBolt11(bolt11: String) {
fun payBolt11(bolt11: String, strings: SendStrings) {
viewModelScope.launch {
val decoded = _sendState.value as? SendState.Bolt11Decoded
val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L
val decoded = _sendState.value as? SendState.Bolt11Decoded
val amountSats = decoded?.amountSats ?: 0L
_sendState.value = SendState.Paying
runCatching { repo.payBolt11(bolt11) }
.onSuccess { response ->
@@ -92,7 +110,9 @@ class SendViewModel(
}
.onFailure { e ->
Log.e(TAG, "SEND [PAY BOLT11 ERROR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Payment failed"))
_sendState.value = SendState.Error(
parseApiError(e, strings.paymentFailed) // ← "Payment failed"
)
}
}
}
@@ -101,23 +121,26 @@ class SendViewModel(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?
comment : String?,
strings : SendStrings,
) {
viewModelScope.launch {
_sendState.value = SendState.Paying
// ── 1. Initial attempt (direct → proxy)
val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL")
val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL", strings)
if (initial.isSuccess) {
recordPaymentSuccess(initial.getOrThrow(), amountMsat)
return@launch
}
// ── 2. Both paths failed — check if retrying is worthwhile
val initialError = initial.exceptionOrNull() ?: Exception("Payment failed")
val initialError = initial.exceptionOrNull() ?: Exception(strings.paymentFailed)
if (isTerminalPayError(initialError)) {
Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying")
_sendState.value = SendState.Error(parseApiError(initialError, "Payment failed"))
_sendState.value = SendState.Error(
parseApiError(initialError, strings.paymentFailed) // ← "Payment failed"
)
return@launch
}
@@ -125,7 +148,7 @@ class SendViewModel(
lnurlCache.invalidate(lnurl)
// ── 3. Re-fetch metadata
val freshRaw = rescanLnurl(lnurl) ?: return@launch // error already set
val freshRaw = rescanLnurl(lnurl, strings) ?: return@launch // error already set
val scanProtocolError = lnurlProtocolError(freshRaw)
if (scanProtocolError != null) {
@@ -136,29 +159,28 @@ class SendViewModel(
lnurlCache.put(lnurl, freshRaw)
// ── 4. Final attempt (direct → proxy) with fresh metadata
val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY")
val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY", strings)
if (retry.isSuccess) {
recordPaymentSuccess(retry.getOrThrow(), amountMsat)
return@launch
}
// ── 5. Final failure
val retryError = retry.exceptionOrNull() ?: Exception("Payment failed")
val retryError = retry.exceptionOrNull() ?: Exception(strings.paymentFailed)
Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}")
_sendState.value = SendState.Error(parseApiError(retryError, "Payment failed"))
_sendState.value = SendState.Error(
parseApiError(retryError, strings.paymentFailed) // ← "Payment failed"
)
}
}
/**
* Step 1: fetch the LNURL callback invoice, decode it, analyse route hints.
* Called after the user taps "Pay" in LnurlPayForm and passes biometric auth.
*/
fun fetchLnurlInvoice(
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat: Long,
comment : String?
comment : String?,
strings : SendStrings,
) {
viewModelScope.launch {
_sendState.value = SendState.Scanning
@@ -168,7 +190,7 @@ class SendViewModel(
}.getOrElse { e ->
Log.e(TAG, "LNURL [FETCH INVOICE ERR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, "Could not fetch invoice from recipient")
parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient"
)
return@launch
}
@@ -177,7 +199,9 @@ class SendViewModel(
repo.decodeBolt11(bolt11)
}.getOrElse { e ->
Log.e(TAG, "LNURL [DECODE ERR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice"))
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
)
return@launch
}
@@ -225,18 +249,14 @@ class SendViewModel(
}
}
/**
* Step 2: pay the bolt11 fetched in fetchLnurlInvoice.
* Tries payBolt11 directly first; falls back to the full payLnurl path.
*/
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) {
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
viewModelScope.launch {
_sendState.value = SendState.Paying
val directResult = runCatching { repo.payBolt11(state.bolt11) }
val paymentHash = directResult.getOrThrow().paymentHash
if (directResult.isSuccess) {
val paymentHash = directResult.getOrThrow().paymentHash
val feeSats = runCatching {
repo.getPaymentDetail(paymentHash).details?.feeSat
}.getOrNull()
@@ -265,7 +285,8 @@ class SendViewModel(
rawRes = state.rawRes,
lnurl = state.lnurl,
amountMsat = state.amountMsat,
comment = state.comment
comment = state.comment,
strings = strings,
)
}
}
@@ -274,9 +295,10 @@ class SendViewModel(
* Biometric gate. Shows prompt if device supports it; calls [pay] directly if not.
*/
fun requestPayment(
activity: FragmentActivity,
subtitle: String,
pay : () -> Unit
activity : FragmentActivity,
subtitle : String,
strings : SendStrings,
pay : () -> Unit
) {
if (!BiometricHelper.canAuthenticate(activity)) {
pay()
@@ -287,7 +309,9 @@ class SendViewModel(
subtitle = subtitle,
onSuccess = pay,
onError = { _, msg ->
_sendState.value = SendState.Error("Authentication failed: $msg")
_sendState.value = SendState.Error(
strings.authFailed(msg.toString()) // ← "Authentication failed: $msg"
)
}
)
}
@@ -296,24 +320,25 @@ class SendViewModel(
// ── Scan handlers ────────────────────────────────────────────────────────
private fun handleBip21Scan(rawInput: String) {
private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
val params = parseBip21Params(rawInput)
val bolt11 = params["lightning"]
val lnurl = params["lnurl"] ?: params["lnurlp"]
when {
bolt11 != null -> scan(bolt11)
lnurl != null -> scan(lnurl)
bolt11 != null -> scan(bolt11, strings)
lnurl != null -> scan(lnurl, strings)
else -> _sendState.value = SendState.Error(
"On-chain Bitcoin payments are not supported" +
"share a BIP-21 URI with a lightning= parameter"
strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…"
)
}
}
private fun handleLud17Scan(rawInput: String) {
private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
val scheme = rawInput.trim().lowercase().substringBefore("://")
if (scheme == "lnurlc") {
_sendState.value = SendState.Error("Channel requests (lnurlc) are not supported")
_sendState.value = SendState.Error(
strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported"
)
return
}
val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
@@ -333,18 +358,20 @@ class SendViewModel(
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
}
else -> _sendState.value = SendState.Error(
"Unsupported LNURL type: ${raw.tag ?: "unknown"}"
strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag"
)
}
}
.onFailure { e ->
Log.e(TAG, "LUD17 [ERROR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Could not resolve address"))
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address"
)
}
}
}
private fun handleBolt11Scan(normalized: String) {
private fun handleBolt11Scan(normalized: String, strings: SendStrings) {
_sendState.value = SendState.Scanning
viewModelScope.launch {
runCatching { repo.decodeBolt11(normalized) }
@@ -383,12 +410,14 @@ class SendViewModel(
}
.onFailure { e ->
Log.e(TAG, "SEND [DECODE ERROR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice"))
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
)
}
}
}
private fun handleLnurlScan(normalized: String) {
private fun handleLnurlScan(normalized: String, strings: SendStrings) {
_sendState.value = SendState.Scanning
viewModelScope.launch {
// 1. Cache hit
@@ -424,8 +453,8 @@ class SendViewModel(
_sendState.value = SendState.Idle
onWithdrawScanned(scanned.rawRes, normalized)
}
null -> _sendState.value = SendState.Error("Empty response from server")
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
null -> _sendState.value = SendState.Error(strings.emptyResponse) // ← "Empty response from server"
else -> _sendState.value = SendState.Error(strings.unsupportedType(scanned.tag)) // ← "Unsupported type: $tag"
}
return@launch
}
@@ -448,12 +477,14 @@ class SendViewModel(
_sendState.value = SendState.Idle
onWithdrawScanned(scanned.rawRes, normalized)
}
null -> _sendState.value = SendState.Error("Empty response from server")
else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}")
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, "Could not resolve address"))
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address"
)
}
}
}
@@ -474,16 +505,14 @@ class SendViewModel(
}
private suspend fun recordPaymentSuccess(
paymentHash: String,
amountMsat: Long,
bolt11 : String? = null,
memo : String? = null,
pubkey : String? = null,
alias : String? = null
paymentHash : String,
amountMsat : Long,
bolt11 : String? = null,
memo : String? = null,
pubkey : String? = null,
alias : String? = null
) {
val feeSats = runCatching {
repo.getPaymentDetail(paymentHash).details?.feeSat
}.getOrNull()
val feeSats = runCatching { repo.getPaymentDetail(paymentHash).details?.feeSat }.getOrNull()
val amountSats = amountMsat / WalletConstants.MSAT_PER_SAT
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
@@ -507,7 +536,8 @@ class SendViewModel(
lnurl : String,
amountMsat: Long,
comment : String?,
label : String
label : String,
strings : SendStrings,
): Result<String> {
val direct = runCatching { repo.payLnurlDirect(rawRes, amountMsat, comment) }
if (direct.isSuccess) {
@@ -522,10 +552,12 @@ class SendViewModel(
return Result.success(proxy.getOrThrow().paymentHash)
}
Log.w(TAG, "LNURL [PAY $label PROXY FAIL] ${proxy.exceptionOrNull()?.message}")
return Result.failure(proxy.exceptionOrNull() ?: Exception("Payment failed"))
return Result.failure(
proxy.exceptionOrNull() ?: Exception(strings.paymentFailed) // ← "Payment failed"
)
}
private suspend fun rescanLnurl(lnurl: String): LnurlScanResponse? {
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")
@@ -538,7 +570,10 @@ class SendViewModel(
Log.e(TAG, "LNURL [RESCAN ERR] ${proxy.exceptionOrNull()?.message}")
_sendState.value = SendState.Error(
parseApiError(proxy.exceptionOrNull() ?: Exception(), "Payment failed — could not re-fetch address")
parseApiError(
proxy.exceptionOrNull() ?: Exception(),
strings.paymentFailedRescan // ← "Payment failed — could not re-fetch address"
)
)
return null
}
@@ -560,14 +595,14 @@ class SendViewModel(
) {
payee?.let { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val current = readState()
copyAlias(current, alias)?.let { _sendState.value = it }
}
}
routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val current = readState()
copyHint(current, pubkey, alias)?.let { _sendState.value = it }
}
@@ -589,26 +624,26 @@ class SendViewModel(
}
private fun buildPendingRecord(
paymentHash: String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?
paymentHash : String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?
): PaymentRecord = PaymentRecord(
paymentHash = paymentHash,
checkingId = paymentHash,
amountMsat = -(amountSats * 1_000L), // negative = outgoing
feeMsat = (feeSats ?: 0L) * 1_000L,
memo = memo,
time = (System.currentTimeMillis() / 1_000L).toString(),
status = "complete",
bolt11 = bolt11,
pending = false,
preimage = null,
extra = null,
pubkey = pubkey,
alias = alias
checkingId = paymentHash,
amountMsat = -(amountSats * 1_000L),
feeMsat = (feeSats ?: 0L) * 1_000L,
memo = memo,
time = (System.currentTimeMillis() / 1_000L).toString(),
status = "complete",
bolt11 = bolt11,
pending = false,
preimage = null,
extra = null,
pubkey = pubkey,
alias = alias
)
}