diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt index 03265903c..985b8346e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt @@ -112,16 +112,16 @@ fun AmountHeroInput( val rate = rates[unit] if (rate != null) { initialAmount?.toFiat(rate.price).toPlainString(limitDecimal = true) - } else "?!" + } else context.getString(R.string.utils_unknown_amount) } is BitcoinUnit -> { initialAmount?.toUnit(u).toPlainString() } - else -> "?!" + else -> context.getString(R.string.utils_unknown_amount) } )) } - var inputAmount by remember { mutableStateOf(initialAmount) } + var inputAmount by remember(initialAmount) { mutableStateOf(initialAmount) } val convertedValue: String by remember(inputAmount, unit) { val s = when (unit) { is FiatCurrency -> { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt index 495caf2d8..757c4f41b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt @@ -185,7 +185,7 @@ fun ColumnScope.LightningInvoiceView( if (state is LightningInvoiceState.Done) { Column(modifier = Modifier.padding(top = 2.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.description.isNullOrBlank() && state.amount == null) { - Text(text = "Add amount and description", style = MaterialTheme.typography.subtitle2) + Text(text = stringResource(id = R.string.receive_lightning_edit_button), style = MaterialTheme.typography.subtitle2) } state.description?.takeIf { it.isNotBlank() }?.let { desc -> QRCodeLabel(label = stringResource(R.string.receive_lightning_desc_label)) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt index c3ebbcc18..9d3a2b8c2 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt @@ -65,7 +65,7 @@ sealed class LightningInvoiceState { data class Bolt12(val offer: OfferTypes.Offer) : Done() { override val paymentData by lazy { offer.encode() } override val description: String? by lazy { offer.description } - override val amount: MilliSatoshi? by lazy { offer.amount } + override val amount: MilliSatoshi? by lazy { offer.amountMsat } } } data class Error(val e: Throwable) : LightningInvoiceState() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt index 67ae41ba7..322531096 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -42,27 +43,63 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.android.LocalBitcoinUnits import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.WalletId +import fr.acinq.phoenix.android.application import fr.acinq.phoenix.android.components.inputs.AmountHeroInput import fr.acinq.phoenix.android.components.AmountWithFiatRowView import fr.acinq.phoenix.android.components.buttons.BackButtonWithActiveWallet import fr.acinq.phoenix.android.components.buttons.Clickable import fr.acinq.phoenix.android.components.buttons.FilledButton import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.layouts.SplashLabelRow import fr.acinq.phoenix.android.components.layouts.SplashLayout import fr.acinq.phoenix.android.components.inputs.TextInput import fr.acinq.phoenix.android.components.buttons.SmartSpendButton import fr.acinq.phoenix.android.components.contact.ContactOrOfferView +import fr.acinq.phoenix.android.components.dialogs.Dialog import fr.acinq.phoenix.android.components.dialogs.ModalBottomSheet import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.payments.details.splash.translatePaymentError +import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.android.utils.converters.AmountConverter.toMilliSatoshi +import fr.acinq.phoenix.android.utils.converters.AmountFormatter.toPlainString import fr.acinq.phoenix.android.utils.converters.AmountFormatter.toPrettyString +import fr.acinq.phoenix.android.utils.converters.DateFormatter.toAbsoluteDateTimeString +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import kotlinx.coroutines.launch + + +sealed class InitialAmount { + abstract val amount: MilliSatoshi? + + data object None : InitialAmount() { override val amount = null } + + data class MilliSat(override val amount: MilliSatoshi): InitialAmount() + + sealed class Fiat : InitialAmount() { + abstract val value: Long + abstract val currencyCode: String? + data class ResolvingToMsat(override val value: Long, override val currencyCode: String) : Fiat() { + override val amount = null + } + data class ConvertedToMsat(override val value: Long, override val currencyCode: String, override val amount: MilliSatoshi, val rate: ExchangeRate.BitcoinPriceRate): Fiat() + data class CurrencyCodeUnsupported(override val value: Long, override val currencyCode: String): Fiat() { + override val amount = null + } + data class NoCurrencyInOffer(override val value: Long) : Fiat() { + override val amount = null + override val currencyCode = null + } + } +} @Composable fun SendToOfferView( @@ -79,16 +116,48 @@ fun SendToOfferView( val balance = business.balanceManager.balance.collectAsState(null).value val peer by business.peerManager.peerState.collectAsState() val trampolineFees = peer?.walletParams?.trampolineFees?.firstOrNull() + val currencyManager = application.phoenixGlobal.currencyManager val vm = viewModel(factory = SendOfferViewModel.Factory(offer, business.peerManager, business.nodeParamsManager, business.databaseManager), key = offer.encode()) - val requestedAmount = offer.amount - var amount by remember { mutableStateOf(requestedAmount) } + val requestedAmountState = produceState(initialValue = null, key1 = offer) { + value = when (val amountInOffer = offer.amount) { + is Either.Left -> InitialAmount.MilliSat(amountInOffer.value) + is Either.Right -> { + val currencyCode = offer.currency + if (currencyCode == null) { + InitialAmount.Fiat.NoCurrencyInOffer(value = amountInOffer.value) + } else { + val fiatCurrency = FiatCurrency.valueOfOrNull(currencyCode) + if (fiatCurrency == null) { + InitialAmount.Fiat.CurrencyCodeUnsupported(value = amountInOffer.value, currencyCode = currencyCode) + } else { + launch { + currencyManager.fetchRateForCurrency(fiatCurrency)?.let { rate -> + value = InitialAmount.Fiat.ConvertedToMsat( + value = amountInOffer.value, + currencyCode = currencyCode, + amount = amountInOffer.value.toDouble().toMilliSatoshi(rate.price), + rate = rate, + ) + } + } + InitialAmount.Fiat.ResolvingToMsat(value = amountInOffer.value, currencyCode = currencyCode) + } + } + } + null -> InitialAmount.None + } + } + val requestedAmount = requestedAmountState.value?.amount + + // the requested amount may be in fiat and needs to be converted first + var amount by remember(requestedAmount) { mutableStateOf(requestedAmount) } val amountErrorMessage: String = remember(amount) { val currentAmount = amount when { currentAmount == null -> "" balance != null && currentAmount > balance -> context.getString(R.string.send_error_amount_over_balance) - requestedAmount != null && currentAmount < requestedAmount -> context.getString( + requestedAmount != null && currentAmount < requestedAmount && requestedAmountState.value is InitialAmount.MilliSat -> context.getString( R.string.send_error_amount_below_requested, (requestedAmount).toPrettyString(prefBitcoinUnit, withUnit = true) ) @@ -112,6 +181,10 @@ fun SendToOfferView( validationErrorMessage = amountErrorMessage, inputTextSize = 42.sp, ) + when (val state = requestedAmountState.value) { + is InitialAmount.Fiat -> ConvertingFiatOfferAmount(state) + else -> Unit + } } ) { offer.description?.takeIf { it.isNotBlank() }?.let { @@ -248,3 +321,44 @@ private fun PayerNoteInput( Spacer(modifier = Modifier.height(80.dp)) } } + +@Composable +private fun ConvertingFiatOfferAmount(state: InitialAmount.Fiat) { + var showOfferInFiatDialog by remember { mutableStateOf(false) } + + Clickable(onClick = { showOfferInFiatDialog = true }, internalPadding = PaddingValues(8.dp), shape = RoundedCornerShape(12.dp)) { + TextWithIcon( + text = stringResource(R.string.send_offer_fiat_main_label, "${state.value} ${state.currencyCode}"), + textStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), + icon = R.drawable.ic_refresh, + iconTint = MaterialTheme.typography.caption.color, + ) + } + + if (showOfferInFiatDialog) { + Dialog(onDismiss = { showOfferInFiatDialog = false }, buttons = null) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + Text(text = stringResource(id = R.string.send_offer_fiat_main_label, "${state.value} ${state.currencyCode}"), style = MaterialTheme.typography.h4) + Spacer(Modifier.height(12.dp)) + when (state) { + is InitialAmount.Fiat.ResolvingToMsat -> { + Text(text = stringResource(id = R.string.send_offer_fiat_converting_desc, state.currencyCode)) + } + is InitialAmount.Fiat.CurrencyCodeUnsupported -> { + Text(text = stringResource(id = R.string.send_offer_fiat_err_unsupported, state.currencyCode)) + } + is InitialAmount.Fiat.NoCurrencyInOffer -> { + Text(text = stringResource(id = R.string.send_offer_fiat_err_malformed_invoice)) + } + is InitialAmount.Fiat.ConvertedToMsat -> { + Text(text = annotatedStringResource(id = R.string.send_offer_fiat_converted_to, state.amount.toPrettyString(unit = LocalBitcoinUnits.current.primary, withUnit = true))) + Spacer(Modifier.height(12.dp)) + Text(text = stringResource(id = R.string.send_offer_fiat_converted_rate_details, state.rate.price.toPlainString(), state.rate.fiatCurrency.name), style = MaterialTheme.typography.subtitle2) + Spacer(Modifier.height(2.dp)) + Text(text = stringResource(id = R.string.send_offer_fiat_converted_rate_timestamp, state.rate.timestampMillis.toAbsoluteDateTimeString()), style = MaterialTheme.typography.subtitle2) + } + } + } + } + } +} diff --git a/phoenix-android/src/main/res/drawable/ic_refresh.xml b/phoenix-android/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..a841d7952 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index a5083c492..f22e86627 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -143,6 +143,7 @@ This Lightning address uses the modern Bip353 standard that works with Bolt12 payment requests.\n\nIt is more private than LNURL-based Lightning addresses, and can even be self-hosted.\n\nHowever, it\'s is bleeding edge tech ; some wallets or services do not understand it yet and won\'t be able to pay you. Learn more. + Add amount and description Customise this Bolt11 invoice Customise this Bolt12 invoice Amount (optional) @@ -181,6 +182,14 @@ Payment has failed Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable. + Invoice requests %1$s + Retrieving %1$s/BTC feerate… + %1$s is not supported by Phoenix. You must convert the amount to BTC manually. + This invoice is malformed and misses a fiat currency code. + Phoenix converted that to %1$s, but you may adjust that value. + Rate: %1$s %2$s/BTC + Refreshed: %1$s + Pay on-chain On-chain transactions are typically slower and best suited for large payments. Pay with Lightning diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt index 0a6da06e4..58fb9a266 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt @@ -57,7 +57,7 @@ class PaymentMetadataQueue( internal fun enrichPaymentMetadata(metadata: WalletPaymentMetadata?): WalletPaymentMetadata { val metadataOrDefault = metadata ?: WalletPaymentMetadata() return if (metadataOrDefault.originalFiat == null) { - val currentFiatRate = appConfigurationManager.preferredFiatCurrencies.value?.let { currencyManager.calculateOriginalFiat(it.primary) } + val currentFiatRate = appConfigurationManager.preferredFiatCurrencies.value?.let { currencyManager.getSnapshotRateForCurrency(it.primary) } metadataOrDefault.copy(originalFiat = currentFiatRate) } else metadataOrDefault } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt index 256649cb4..9642f10db 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt @@ -19,6 +19,7 @@ package fr.acinq.phoenix.managers.global import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.info +import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.PreferredFiatCurrencies @@ -96,6 +97,15 @@ class CurrencyManager( ) } + @OptIn(ExperimentalCoroutinesApi::class) + val usdRate: StateFlow = ratesFlow.mapLatest { + it.filterIsInstance().firstOrNull { it.fiatCurrency == FiatCurrency.USD } + }.stateIn( + scope = scope, + started = SharingStarted.Companion.Eagerly, + initialValue = null + ) + private var refreshList = mutableMapOf() private var autoRefreshJob: Job? = null @@ -214,29 +224,45 @@ class CurrencyManager( } /** - * Returns a snapshot of the ExchangeRate for the primary FiatCurrency. - * That is, an instance of OriginalFiat, where: - * - type => current primary FiatCurrency (via AppConfigurationManager) - * - price => BitcoinPriceRate.price for FiatCurrency type + * Fetches the BTC exchange rate for the given [fiatCurrency] from the adequate API. May return null. This method should not be called + * repeatedly as it makes an HTTP request every time. To monitor a currency on the long run, use [startMonitoringCurrencies] instead. */ - fun calculateOriginalFiat(currency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { + suspend fun fetchRateForCurrency(fiatCurrency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { val rates = ratesFlow.value - val fiatRate = rates.firstOrNull { it.fiatCurrency == currency } ?: return null + val now = currentTimestampMillis() + val knownMatch = rates.firstOrNull { it.fiatCurrency == fiatCurrency && (now - it.timestampMillis) < 10.minutes.inWholeMilliseconds } + if (knownMatch != null) { + log.debug { "returning cached rate for ${fiatCurrency.name}" } + return toBitcoinExchangeRate(knownMatch) + } + + val api = listOf(coinbaseAPI, blockchainInfoAPI, yadioAPI, bluelyticsAPI) + .firstOrNull { it.fiatCurrencies.contains(fiatCurrency) } ?: return null + + val target = setOf(fiatCurrency) + log.debug { "fetching rate for ${fiatCurrency.name}" } + val rate = api.fetch(target).firstOrNull { it.fiatCurrency == fiatCurrency } ?: return null + updateRefreshList(api = api, attempted = target, refreshed = target) + appDb.saveExchangeRates(listOf(rate)) + return toBitcoinExchangeRate(rate) + } + + /** Returns a snapshot of [currency] in [ratesFlow], converted to a [ExchangeRate.BitcoinPriceRate]. */ + fun getSnapshotRateForCurrency(currency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { + val fiatRate = ratesFlow.value.firstOrNull { it.fiatCurrency == currency } ?: return null + return toBitcoinExchangeRate(fiatRate) + } + + /** Utility method that converts a generic exchange rate to a Bitcoin price rate, using the USD rate when needed. This is needed because + * the exchange rate for many currency is in USD, and not BTC! */ + private fun toBitcoinExchangeRate(fiatRate: ExchangeRate): ExchangeRate.BitcoinPriceRate? { return when (fiatRate) { - is ExchangeRate.BitcoinPriceRate -> { - // We have a direct exchange rate. - // BitcoinPriceRate.rate => The price of 1 BTC in this currency - fiatRate - } + is ExchangeRate.BitcoinPriceRate -> fiatRate is ExchangeRate.UsdPriceRate -> { - // We have an indirect exchange rate. - // UsdPriceRate.price => The price of 1 US Dollar in this currency - rates.filterIsInstance().firstOrNull { - it.fiatCurrency == FiatCurrency.USD - }?.let { usdRate -> + usdRate.value?.let { usdRate -> ExchangeRate.BitcoinPriceRate( - fiatCurrency = currency, + fiatCurrency = fiatRate.fiatCurrency, price = usdRate.price * fiatRate.price, source = "${fiatRate.source}/${usdRate.source}", timestampMillis = fiatRate.timestampMillis.coerceAtMost(