feat: localisation with lyricist
This commit is contained in:
@@ -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"
|
||||
}
|
||||
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 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,8 +295,9 @@ class SendViewModel(
|
||||
* Biometric gate. Shows prompt if device supports it; calls [pay] directly if not.
|
||||
*/
|
||||
fun requestPayment(
|
||||
activity: FragmentActivity,
|
||||
subtitle: String,
|
||||
activity : FragmentActivity,
|
||||
subtitle : String,
|
||||
strings : SendStrings,
|
||||
pay : () -> Unit
|
||||
) {
|
||||
if (!BiometricHelper.canAuthenticate(activity)) {
|
||||
@@ -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,
|
||||
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
|
||||
}
|
||||
@@ -589,7 +624,7 @@ class SendViewModel(
|
||||
}
|
||||
|
||||
private fun buildPendingRecord(
|
||||
paymentHash: String,
|
||||
paymentHash : String,
|
||||
amountSats : Long,
|
||||
feeSats : Long?,
|
||||
bolt11 : String?,
|
||||
@@ -599,7 +634,7 @@ class SendViewModel(
|
||||
): PaymentRecord = PaymentRecord(
|
||||
paymentHash = paymentHash,
|
||||
checkingId = paymentHash,
|
||||
amountMsat = -(amountSats * 1_000L), // negative = outgoing
|
||||
amountMsat = -(amountSats * 1_000L),
|
||||
feeMsat = (feeSats ?: 0L) * 1_000L,
|
||||
memo = memo,
|
||||
time = (System.currentTimeMillis() / 1_000L).toString(),
|
||||
|
||||
Reference in New Issue
Block a user