diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index 97de5a654..c103844ad 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -71,12 +71,12 @@ import fr.acinq.phoenix.android.init.CreateWalletView import fr.acinq.phoenix.android.init.InitWallet import fr.acinq.phoenix.android.init.RestoreWalletView import fr.acinq.phoenix.android.intro.IntroView -import fr.acinq.phoenix.android.payments.ScanDataView import fr.acinq.phoenix.android.payments.details.PaymentDetailsView import fr.acinq.phoenix.android.payments.history.CsvExportView import fr.acinq.phoenix.android.payments.history.PaymentsHistoryView import fr.acinq.phoenix.android.payments.liquidity.RequestLiquidityView import fr.acinq.phoenix.android.payments.receive.ReceiveView +import fr.acinq.phoenix.android.payments.send.SendView import fr.acinq.phoenix.android.services.NodeServiceState import fr.acinq.phoenix.android.settings.AboutView import fr.acinq.phoenix.android.settings.AppAccessSettings @@ -109,8 +109,8 @@ import fr.acinq.phoenix.android.startup.LegacySwitcherView import fr.acinq.phoenix.android.startup.StartupView import fr.acinq.phoenix.android.utils.SystemNotificationHelper import fr.acinq.phoenix.android.utils.appBackground +import fr.acinq.phoenix.android.utils.extensions.findActivitySafe import fr.acinq.phoenix.android.utils.logger -import fr.acinq.phoenix.android.utils.safeFindActivity import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.WalletPaymentId @@ -210,7 +210,7 @@ fun AppView( val next = nextScreenLink?.takeUnless { it.isBlank() }?.let { Uri.parse(it) } if (next == null || !navController.graph.hasDeepLink(next)) { log.debug("redirecting from startup to home") - popToHome(navController) + navController.popToHome() } else { log.debug("redirecting from startup to {}", next) navController.navigate(next, navOptions = navOptions { @@ -243,29 +243,30 @@ fun AppView( onPaymentClick = { navigateToPaymentDetails(navController, id = it, isFromEvent = false) }, onSettingsClick = { navController.navigate(Screen.Settings.route) }, onReceiveClick = { navController.navigate(Screen.Receive.route) }, - onSendClick = { navController.navigate(Screen.ScanData.route) { launchSingleTop = true } }, + onSendClick = { navController.navigate(Screen.Send.route) }, onPaymentsHistoryClick = { navController.navigate(Screen.PaymentsHistory.route) }, - onTorClick = { navController.navigate(Screen.TorConfig) }, - onElectrumClick = { navController.navigate(Screen.ElectrumServer) }, - onNavigateToSwapInWallet = { navController.navigate(Screen.WalletInfo.SwapInWallet) }, - onNavigateToFinalWallet = { navController.navigate(Screen.WalletInfo.FinalWallet) }, - onShowNotifications = { navController.navigate(Screen.Notifications) }, + onTorClick = { navController.navigate(Screen.TorConfig.route) }, + onElectrumClick = { navController.navigate(Screen.ElectrumServer.route) }, + onNavigateToSwapInWallet = { navController.navigate(Screen.WalletInfo.SwapInWallet.route) }, + onNavigateToFinalWallet = { navController.navigate(Screen.WalletInfo.FinalWallet.route) }, + onShowNotifications = { navController.navigate(Screen.Notifications.route) }, onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, ) } } composable(Screen.Receive.route) { ReceiveView( - onSwapInReceived = { popToHome(navController) }, + onSwapInReceived = { navController.popToHome() }, onBackClick = { navController.popBackStack() }, - onScanDataClick = { navController.navigate(Screen.ScanData.route) }, + onScanDataClick = { navController.navigate(Screen.Send.route) }, onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) }, ) } composable( - route = "${Screen.ScanData.route}?input={input}", + route = "${Screen.Send}?input={input}&openScanner={openScanner}", arguments = listOf( navArgument("input") { type = NavType.StringType ; nullable = true }, + navArgument("openScanner") { type = NavType.BoolType ; defaultValue = false } ), deepLinks = listOf( navDeepLink { uriPattern = "lightning:{data}" }, @@ -279,30 +280,26 @@ fun AppView( navDeepLink { uriPattern = "scanview:{data}" }, ) ) { - log.info("input arg=${it.arguments?.getString("input")}") val intent = try { it.arguments?.getParcelable(NavController.KEY_DEEP_LINK_INTENT) } catch (e: Exception) { null } + // prevents forwarding an internal deeplink intent coming from androidx-navigation framework. + // TODO properly parse deeplinks following f0ae90444a23cc17d6d7407dfe43c0c8d20e62fc + val isIntentFromNavigation = intent?.dataString?.contains("androidx.navigation") ?: true + log.debug("isIntentFromNavigation=$isIntentFromNavigation") + val input = if (isIntentFromNavigation) { + it.arguments?.getString("input") + } else { + intent?.data?.toString()?.substringAfter("scanview:") + } RequireStarted(walletState, nextUri = "scanview:${intent?.data?.toString()}") { - val input = intent?.data?.toString()?.substringAfter("scanview:")?.takeIf { - // prevents forwarding an internal deeplink intent coming from androidx-navigation framework. - // TODO properly parse deeplinks following f0ae90444a23cc17d6d7407dfe43c0c8d20e62fc - !it.contains("androidx.navigation") - } ?: it.arguments?.getString("input") - ScanDataView( - input = input, - onBackClick = { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } else { - popToHome(navController) - } - }, - onAuthSchemeInfoClick = { navController.navigate("${Screen.PaymentSettings.route}/true") }, - onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) }, - onProcessingFinished = { popToHome(navController) }, + log.info("navigating to send-payment with input=$input") + SendView( + initialInput = input, + fromDeepLink = !isIntentFromNavigation, + immediatelyOpenScanner = it.arguments?.getBoolean("openScanner") ?: false ) } } @@ -330,12 +327,12 @@ fun AppView( paymentId = paymentId, onBackClick = { val previousNav = navController.previousBackStackEntry - if (fromEvent && previousNav?.destination?.route == Screen.ScanData.route) { - popToHome(navController) + if (fromEvent && previousNav?.destination?.route == Screen.Send.route) { + navController.popToHome() } else if (navController.previousBackStackEntry != null) { navController.popBackStack() } else { - popToHome(navController) + navController.popToHome() } }, fromEvent = fromEvent @@ -348,7 +345,7 @@ fun AppView( onBackClick = { navController.popBackStack() }, paymentsViewModel = paymentsViewModel, onPaymentClick = { navigateToPaymentDetails(navController, id = it, isFromEvent = false) }, - onCsvExportClick = { navController.navigate(Screen.PaymentsCsvExport) }, + onCsvExportClick = { navController.navigate(Screen.PaymentsCsvExport.route) }, ) } composable(Screen.PaymentsCsvExport.route) { @@ -373,12 +370,12 @@ fun AppView( composable(Screen.Channels.route) { ChannelsView( onBackClick = { - navController.navigate(Screen.Settings) { + navController.navigate(Screen.Settings.route) { popUpTo(Screen.Settings.route) { inclusive = true } } }, onChannelClick = { navController.navigate("${Screen.ChannelDetails.route}?id=$it") }, - onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData)}, + onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData.route)}, ) } composable( @@ -403,18 +400,11 @@ fun AppView( composable(Screen.About.route) { AboutView() } - composable(Screen.PaymentSettings.route) { - PaymentSettingsView( - initialShowLnurlAuthSchemeDialog = false, - ) - } - composable("${Screen.PaymentSettings.route}/{showAuthSchemeDialog}", arguments = listOf( + composable("${Screen.PaymentSettings.route}?showAuthSchemeDialog={showAuthSchemeDialog}", arguments = listOf( navArgument("showAuthSchemeDialog") { type = NavType.BoolType } )) { val showAuthSchemeDialog = it.arguments?.getBoolean("showAuthSchemeDialog") ?: false - PaymentSettingsView( - initialShowLnurlAuthSchemeDialog = showAuthSchemeDialog, - ) + PaymentSettingsView(initialShowLnurlAuthSchemeDialog = showAuthSchemeDialog) } composable(Screen.AppLock.route) { AppAccessSettings(onBackClick = { navController.popBackStack() }, appViewModel = appVM) @@ -521,7 +511,7 @@ fun AppView( if ((isBiometricLockEnabled == true || isCustomPinLockEnabled == true) && isScreenLocked) { BackHandler { // back button minimises the app - context.safeFindActivity()?.moveTaskToBack(false) + context.findActivitySafe()?.moveTaskToBack(false) } LockPrompt( promptScreenLockImmediately = appVM.promptScreenLockImmediately.value, @@ -563,13 +553,6 @@ fun AppView( } } -/** Navigates to Home and pops everything from the backstack up to Home. This effectively resets the nav stack. */ -private fun popToHome(navController: NavHostController) { - navController.navigate(Screen.Home.route) { - popUpTo(navController.graph.id) { inclusive = true } - } -} - fun navigateToPaymentDetails(navController: NavController, id: WalletPaymentId, isFromEvent: Boolean) { try { navController.navigate("${Screen.PaymentDetails.route}?direction=${id.dbType.value}&id=${id.dbId}&fromEvent=${isFromEvent}") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt index 4c5c8cf3a..9dd5d02c4 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt @@ -17,9 +17,6 @@ package fr.acinq.phoenix.android import androidx.navigation.NavController -import androidx.navigation.NavOptionsBuilder -import org.slf4j.LoggerFactory - sealed class Screen(val route: String) { data object SwitchToLegacy : Screen("switchtolegacy") @@ -30,11 +27,7 @@ sealed class Screen(val route: String) { data object Startup : Screen("startup") data object Home : Screen("home") data object Receive : Screen("receive") - /** - * This route also manages the payment flow. - * TODO: Separate scanning the data from processing the data (aka send payment, process lnurl...). Split to be done at the controller level. - */ - data object ScanData : Screen("readdata") + data object Send : Screen("send") data object PaymentDetails : Screen("payments") data object PaymentsHistory : Screen("payments/all") data object PaymentsCsvExport : Screen("payments/export") @@ -71,18 +64,10 @@ sealed class Screen(val route: String) { data object Experimental: Screen("settings/experimental") } -fun NavController.navigate(screen: Screen, arg: List = emptyList(), builder: NavOptionsBuilder.() -> Unit = {}) { - val log = LoggerFactory.getLogger("NavController") - val path = arg.joinToString{ "/$it" } - val route = "${screen.route}$path" - log.debug("navigating from ${currentDestination?.route} to $route") - try { - if (route == currentDestination?.route) { - log.warn("cannot navigate to same route") - } else { - navigate(route, builder) - } - } catch (e: Exception) { - log.error("failed to navigate to $route: " , e) +/** Navigates to Home and pops everything from the backstack up to Home. This effectively resets the nav stack. */ +fun NavController.popToHome() { + val navController = this + navigate(Screen.Home.route) { + popUpTo(navController.graph.id) { inclusive = true } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/NoticesViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/NoticesViewModel.kt index 519f84b58..e22fd4390 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/NoticesViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/NoticesViewModel.kt @@ -73,7 +73,6 @@ class NoticesViewModel( private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - log.info("power_saver=${powerManager.isPowerSaveMode}") isPowerSaverModeOn = powerManager.isPowerSaveMode } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt index 2b3f63a74..2bcb3b510 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt @@ -251,12 +251,12 @@ fun AmountInput( style = when { errorMessage.isNotBlank() -> MaterialTheme.typography.body2.copy(color = negativeColor, fontSize = 14.sp) isFocused -> MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.primary, fontSize = 14.sp) - else -> MaterialTheme.typography.body1.copy(fontSize = 14.sp) + else -> MaterialTheme.typography.body2.copy(fontSize = 14.sp) }, modifier = Modifier .align(Alignment.TopStart) .padding(start = 8.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(2.dp)) .background(MaterialTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 2.dp) ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt index abd4de427..0a230ff16 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt @@ -348,6 +348,8 @@ fun Clickable( shape: Shape = RectangleShape, clickDescription: String = "", internalPadding: PaddingValues = PaddingValues(0.dp), + indication: Indication? = LocalIndication.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, content: @Composable () -> Unit, ) { val colors = ButtonDefaults.buttonColors( @@ -370,6 +372,8 @@ fun Clickable( enabled = enabled, role = Role.Button, onClickLabel = clickDescription, + interactionSource = interactionSource, + indication = indication ) .padding(internalPadding) ) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt index 28c87009f..571f358d8 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt @@ -57,14 +57,16 @@ fun TextInput( staticLabel: String?, placeholder: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (RowScope.() -> Unit)? = null, + isError: Boolean = false, errorMessage: String? = null, enabled: Boolean = true, enabledEffect: Boolean = true, onTextChange: (String) -> Unit, textFieldColors: TextFieldColors = outlinedTextFieldColors(), showResetButton: Boolean = true, - keyboardType: KeyboardType = KeyboardType.Text + keyboardType: KeyboardType = KeyboardType.Text, + shape: Shape = RoundedCornerShape(8.dp), ) { val charsCount by remember(text) { mutableStateOf(text.length) } val focusManager = LocalFocusManager.current @@ -72,6 +74,8 @@ fun TextInput( val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val displayError = isError || !errorMessage.isNullOrBlank() + Box(modifier = modifier.enableOrFade(enabled || !enabledEffect)) { OutlinedTextField( value = text, @@ -91,7 +95,7 @@ fun TextInput( null } else { { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxHeight()) { if (text.isNotBlank()) { FilledButton( onClick = { onTextChange("") }, @@ -109,12 +113,14 @@ fun TextInput( }, enabled = enabled, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - colors = if (errorMessage.isNullOrBlank()) textFieldColors else errorOutlinedTextFieldColors(), - shape = RoundedCornerShape(8.dp), + colors = if (displayError) errorOutlinedTextFieldColors() else textFieldColors, + shape = shape, interactionSource = interactionSource, modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp, top = if (staticLabel != null) 14.dp else 0.dp) + .height(IntrinsicSize.Min) + .padding(top = if (staticLabel != null) 14.dp else 0.dp, bottom = 8.dp) + .clip(shape) ) staticLabel?.let { @@ -124,12 +130,12 @@ fun TextInput( style = when { !errorMessage.isNullOrBlank() -> MaterialTheme.typography.body2.copy(color = negativeColor, fontSize = 14.sp) isFocused -> MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.primary, fontSize = 14.sp) - else -> MaterialTheme.typography.body1.copy(fontSize = 14.sp) + else -> MaterialTheme.typography.body2.copy(fontSize = 14.sp) }, modifier = Modifier .align(Alignment.TopStart) .padding(start = 8.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(2.dp)) .background(MaterialTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 2.dp) ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactDetailsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactDetailsView.kt index 3fb0e9040..57c0994c5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactDetailsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactDetailsView.kt @@ -243,7 +243,7 @@ private fun ContactOffers( offers.forEach { offer -> OfferAttachedToContactRow( offer = offer, - onOfferClick = { navController.navigate("${Screen.ScanData.route}?input=${it.encode()}") }, + onOfferClick = { navController.navigate("${Screen.Send.route}?input=${it.encode()}") }, ) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactsListView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactsListView.kt index 6cb02aaea..80941beac 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactsListView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/ContactsListView.kt @@ -113,7 +113,7 @@ private fun ContactsList( itemsIndexed(contacts) { index, contact -> val onClick = { contact.mostRelevantOffer?.let { - navController.navigate("${Screen.ScanData.route}?input=${it.encode()}") + navController.navigate("${Screen.Send.route}?input=${it.encode()}") } ?: run { if (canEditContact) { onEditContact(contact) } } } if (isOnSurface) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/SaveNewContactView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/SaveNewContactView.kt index 65e86a9fb..36a5c7b7b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/SaveNewContactView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/contact/SaveNewContactView.kt @@ -56,8 +56,8 @@ import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.SwitchView import fr.acinq.phoenix.android.components.TextInput -import fr.acinq.phoenix.android.payments.CameraPermissionsView -import fr.acinq.phoenix.android.payments.ScannerView +import fr.acinq.phoenix.android.payments.send.CameraPermissionsView +import fr.acinq.phoenix.android.payments.send.ScannerView import fr.acinq.phoenix.data.ContactInfo import kotlinx.coroutines.launch diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/LockPrompt.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/LockPrompt.kt index 381031925..957823e28 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/LockPrompt.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/LockPrompt.kt @@ -50,8 +50,8 @@ import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.BiometricsHelper -import fr.acinq.phoenix.android.utils.findActivity -import fr.acinq.phoenix.android.utils.safeLet +import fr.acinq.phoenix.android.utils.extensions.findActivity +import fr.acinq.phoenix.android.utils.extensions.safeLet import kotlinx.coroutines.launch /** diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt index 8bbba4e0c..134148984 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt @@ -42,7 +42,7 @@ import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.HSeparator import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.userPrefs -import fr.acinq.phoenix.android.utils.isBadCertificate +import fr.acinq.phoenix.android.utils.extensions.isBadCertificate import fr.acinq.phoenix.android.utils.monoTypo import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.android.utils.orange diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt index a178d829b..cf1cc1872 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt @@ -48,7 +48,7 @@ import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.VSeparator import fr.acinq.phoenix.android.components.openLink -import fr.acinq.phoenix.android.utils.isBadCertificate +import fr.acinq.phoenix.android.utils.extensions.isBadCertificate import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.android.utils.positiveColor import fr.acinq.phoenix.android.utils.warningColor diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt index 7c527af3b..5021d5e2a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt @@ -56,7 +56,7 @@ import fr.acinq.phoenix.android.components.mvi.MVIView import fr.acinq.phoenix.android.utils.FCMHelper import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode -import fr.acinq.phoenix.android.utils.findActivity +import fr.acinq.phoenix.android.utils.extensions.findActivity import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.canRequestLiquidity import fr.acinq.phoenix.data.inFlightPaymentsCount diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/intro/IntroView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/intro/IntroView.kt index a3e3b2788..803a445a4 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/intro/IntroView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/intro/IntroView.kt @@ -32,6 +32,7 @@ import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.utils.* +import fr.acinq.phoenix.android.utils.extensions.findActivity import kotlinx.coroutines.* diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt deleted file mode 100644 index 23576a690..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt +++ /dev/null @@ -1,474 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package fr.acinq.phoenix.android.payments - -import android.Manifest -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidViewBinding -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale -import com.google.zxing.BarcodeFormat -import com.google.zxing.ResultPoint -import com.google.zxing.client.android.Intents -import com.journeyapps.barcodescanner.BarcodeCallback -import com.journeyapps.barcodescanner.BarcodeResult -import com.journeyapps.barcodescanner.DecoratedBarcodeView -import fr.acinq.phoenix.android.CF -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.business -import fr.acinq.phoenix.android.components.Button -import fr.acinq.phoenix.android.components.Card -import fr.acinq.phoenix.android.components.Clickable -import fr.acinq.phoenix.android.components.Dialog -import fr.acinq.phoenix.android.components.PhoenixIcon -import fr.acinq.phoenix.android.components.ProgressView -import fr.acinq.phoenix.android.components.TextInput -import fr.acinq.phoenix.android.components.VSeparator -import fr.acinq.phoenix.android.components.contact.ContactsListModal -import fr.acinq.phoenix.android.components.mvi.MVIControllerViewModel -import fr.acinq.phoenix.android.components.mvi.MVIView -import fr.acinq.phoenix.android.controllerFactory -import fr.acinq.phoenix.android.databinding.ScanViewBinding -import fr.acinq.phoenix.android.payments.offer.SendOfferView -import fr.acinq.phoenix.android.payments.spliceout.SendSpliceOutView -import fr.acinq.phoenix.android.utils.readClipboard -import fr.acinq.phoenix.controllers.ControllerFactory -import fr.acinq.phoenix.controllers.ScanController -import fr.acinq.phoenix.controllers.payments.Scan -import fr.acinq.phoenix.data.lnurl.LnurlError - - -class ScanDataViewModel(controller: ScanController) : MVIControllerViewModel(controller) { - class Factory( - private val controllerFactory: ControllerFactory, - private val getController: ControllerFactory.() -> ScanController - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return ScanDataViewModel(controllerFactory.getController()) as T - } - } -} - -/** - * @param input External input, for example a deeplink from another app, which needs to be parsed and validated. - * When not null, the camera should not be initialized at first as we already have a data input. - */ -@Composable -fun ScanDataView( - input: String? = null, - onBackClick: () -> Unit, - onProcessingFinished: () -> Unit, - onAuthSchemeInfoClick: () -> Unit, - onFeeManagementClick: () -> Unit, -) { - var initialInput = remember { input } - val peer by business.peerManager.peerState.collectAsState() - val trampolineFees = peer?.walletParams?.trampolineFees?.firstOrNull() - val vm: ScanDataViewModel = viewModel(factory = ScanDataViewModel.Factory(controllerFactory, CF::scan)) - - MVIView(vm) { model, postIntent -> - LaunchedEffect(key1 = initialInput) { - initialInput?.takeIf { it.isNotBlank() }?.let { - postIntent(Scan.Intent.Parse(it)) - } - } - when (model) { - Scan.Model.Ready, is Scan.Model.BadRequest, is Scan.Model.LnurlServiceFetch, is Scan.Model.ResolvingBip353 -> { - ReadDataView( - initialInput = initialInput, - model = model, - onFeedbackDismiss = { - initialInput = "" - postIntent(Scan.Intent.Reset) - }, - onScannedText = { postIntent(Scan.Intent.Parse(request = it)) }, - onBackClick = onBackClick, - ) - } - is Scan.Model.Bolt11InvoiceFlow.Bolt11InvoiceRequest -> { - SendBolt11PaymentView( - invoice = model.invoice, - trampolineFees = trampolineFees, - onBackClick = onBackClick, - onPayClick = { postIntent(it) } - ) - } - Scan.Model.Bolt11InvoiceFlow.Sending -> { - LaunchedEffect(key1 = Unit) { onBackClick() } - } - is Scan.Model.OfferFlow -> { - SendOfferView(offer = model.offer, trampolineFees = trampolineFees, onBackClick = onBackClick, onPaymentSent = onProcessingFinished) - } - is Scan.Model.OnchainFlow -> { - val lightningRequest = model.uri.paymentRequest?.write() ?: model.uri.offer?.encode() - if (lightningRequest == null) { - SendSpliceOutView( - requestedAmount = model.uri.amount, - address = model.uri.address, - onBackClick = onBackClick, - onSpliceOutSuccess = onProcessingFinished, - ) - } else { - var showPaymentModeDialog by remember { mutableStateOf(false) } - if (!showPaymentModeDialog) { - ChoosePaymentModeDialog( - onPayOffchainClick = { - showPaymentModeDialog = true - postIntent(Scan.Intent.Parse(request = lightningRequest)) - }, - onPayOnchainClick = { - showPaymentModeDialog = true - postIntent(Scan.Intent.Parse(request = model.uri.copy(paymentRequest = null, offer = null).write())) - }, - onDismiss = { - showPaymentModeDialog = false - } - ) - } - } - } - is Scan.Model.LnurlPayFlow -> { - LnurlPayView( - model = model, - trampolineFees = trampolineFees, - onBackClick = onBackClick, - onSendLnurlPayClick = { postIntent(it) } - ) - } - is Scan.Model.LnurlAuthFlow -> { - LnurlAuthView( - model = model, - onBackClick = onBackClick, - onLoginClick = { postIntent(it) }, - onAuthSchemeInfoClick = onAuthSchemeInfoClick, - onAuthDone = onProcessingFinished, - ) - } - is Scan.Model.LnurlWithdrawFlow -> { - LnurlWithdrawView( - model = model, - onWithdrawClick = { postIntent(it) }, - onFeeManagementClick = onFeeManagementClick, - onWithdrawDone = onProcessingFinished, - ) - } - } - } -} - -@Composable -fun ReadDataView( - initialInput: String?, - model: Scan.Model, - onFeedbackDismiss: () -> Unit, - onScannedText: (String) -> Unit, - onBackClick: () -> Unit, -) { - val context = LocalContext.current.applicationContext - - var showContactsList by remember { mutableStateOf(false) } - var showManualInputDialog by remember { mutableStateOf(false) } - var scanView by remember { mutableStateOf(null) } - - Box(Modifier.fillMaxSize()) { - - if (initialInput.isNullOrBlank()) { - ScannerView( - onScanViewBinding = { scanView = it }, - onScannedText = onScannedText - ) - CameraPermissionsView { - DisposableEffect(key1 = model, key2 = initialInput) { - if (model is Scan.Model.Ready && initialInput.isNullOrBlank()) scanView?.resume() - onDispose { - scanView?.pause() - } - } - } - } - - // buttons at the bottom of the screen - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(24.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colors.surface) - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically, - ) { - BackHandler(onBack = onBackClick) - Button(icon = R.drawable.ic_arrow_back, onClick = onBackClick, modifier = Modifier.fillMaxHeight(), padding = PaddingValues(horizontal = 12.dp)) - VSeparator(height = 48.dp) - Column(modifier = Modifier.weight(1f)) { - if (model is Scan.Model.Ready) { - Button( - modifier = Modifier.fillMaxWidth(), - icon = R.drawable.ic_user_search, - text = stringResource(id = R.string.settings_contacts), - onClick = { showContactsList = true }, - maxLines = 1, - ) - Button( - modifier = Modifier.fillMaxWidth(), - icon = R.drawable.ic_clipboard, - text = stringResource(id = R.string.scan_paste_button), - onClick = { readClipboard(context)?.let { onScannedText(it) } }, - maxLines = 1, - ) - Button( - modifier = Modifier.fillMaxWidth(), - icon = R.drawable.ic_input, - text = stringResource(id = R.string.scan_manual_input_button), - onClick = { showManualInputDialog = true }, - maxLines = 1, - ) - } - } - } - - if (model is Scan.Model.BadRequest) { - ScanErrorView(model, onFeedbackDismiss) - } - - if (model is Scan.Model.LnurlServiceFetch) { - Card(modifier = Modifier.align(Alignment.Center), internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { - ProgressView(text = stringResource(R.string.scan_lnurl_fetching)) - } - } - - if (model is Scan.Model.ResolvingBip353) { - Card(modifier = Modifier.align(Alignment.Center), internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { - ProgressView(text = stringResource(R.string.scan_bip353_resolving)) - } - } - - if (showManualInputDialog) { - ManualInputDialog(onInputConfirm = onScannedText, onDismiss = { showManualInputDialog = false }) - } - - if (showContactsList) { - ContactsListModal(onDismiss = { showContactsList = false }) - } - } -} - -@Composable -fun BoxScope.ScannerView( - onScanViewBinding: (DecoratedBarcodeView) -> Unit, - onScannedText: (String) -> Unit -) { - // scanner view using a legacy binding - AndroidViewBinding( - modifier = Modifier.fillMaxWidth(), - factory = { inflater, viewGroup, attach -> - val binding = ScanViewBinding.inflate(inflater, viewGroup, attach) - binding.scanView.let { scanView -> - scanView.initializeFromIntent(Intent().apply { - putExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) - putExtra(Intents.Scan.FORMATS, BarcodeFormat.QR_CODE.name) - }) - scanView.decodeContinuous(object : BarcodeCallback { - override fun possibleResultPoints(resultPoints: MutableList?) = Unit - override fun barcodeResult(result: BarcodeResult?) { - result?.text?.trim()?.takeIf { it.isNotBlank() }?.let { - scanView.pause() - onScannedText(it) - } - } - }) - onScanViewBinding(scanView) - scanView.resume() - } - binding - } - ) -} - -@Composable -private fun ScanErrorView( - model: Scan.Model.BadRequest, - onErrorDialogDismiss: () -> Unit, -) { - val message = when (val reason = model.reason) { - is Scan.BadRequestReason.Expired -> stringResource(R.string.scan_error_expired) - is Scan.BadRequestReason.ChainMismatch -> stringResource(R.string.scan_error_invalid_chain) - is Scan.BadRequestReason.AlreadyPaidInvoice -> stringResource(R.string.scan_error_already_paid) - is Scan.BadRequestReason.ServiceError -> when (val error = reason.error) { - is LnurlError.RemoteFailure.Code -> stringResource(R.string.lnurl_error_remote_code, reason.url.host, error.code.value.toString()) - is LnurlError.RemoteFailure.CouldNotConnect -> stringResource(R.string.lnurl_error_remote_connection, reason.url.host) - is LnurlError.RemoteFailure.Detailed -> stringResource(R.string.lnurl_error_remote_details, reason.url.host, error.reason) - is LnurlError.RemoteFailure.Unreadable -> stringResource(R.string.lnurl_error_remote_unreadable, reason.url.host) - is LnurlError.RemoteFailure.IsWebsite -> TODO() - is LnurlError.RemoteFailure.LightningAddressError -> TODO() - } - is Scan.BadRequestReason.InvalidLnurl -> stringResource(R.string.scan_error_lnurl_invalid) - is Scan.BadRequestReason.UnsupportedLnurl -> stringResource(R.string.scan_error_lnurl_unsupported) - is Scan.BadRequestReason.UnknownFormat -> stringResource(R.string.scan_error_invalid_generic) - is Scan.BadRequestReason.Bip353NameNotFound -> stringResource(id = R.string.scan_error_bip353_name_not_found, reason.username, reason.domain) - is Scan.BadRequestReason.Bip353InvalidUri -> stringResource(id = R.string.scan_error_bip353_invalid_uri) - is Scan.BadRequestReason.Bip353InvalidOffer -> stringResource(id = R.string.scan_error_bip353_invalid_offer) - is Scan.BadRequestReason.Bip353NoDNSSEC -> stringResource(id = R.string.scan_error_bip353_dnssec) - } - Dialog( - onDismiss = onErrorDialogDismiss, - content = { Text(text = message, modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp)) } - ) -} - -@Composable -private fun ChoosePaymentModeDialog( - onPayOnchainClick: () -> Unit, - onPayOffchainClick: () -> Unit, - onDismiss: () -> Unit, -) { - Dialog(onDismiss = onDismiss, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = true), isScrollable = true, buttons = null) { - Clickable(onClick = onPayOnchainClick) { - Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) { - PhoenixIcon(resourceId = R.drawable.ic_chain) - Spacer(Modifier.width(16.dp)) - Column { - Text(text = stringResource(id = R.string.send_paymentmode_onchain), style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = stringResource(id = R.string.send_paymentmode_onchain_desc), style = MaterialTheme.typography.caption.copy(fontSize = 14.sp)) - } - } - } - Clickable(onClick = onPayOffchainClick) { - Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 18.dp), verticalAlignment = Alignment.CenterVertically) { - PhoenixIcon(resourceId = R.drawable.ic_zap) - Spacer(Modifier.width(16.dp)) - Column { - Text(text = stringResource(id = R.string.send_paymentmode_lightning), style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = stringResource(id = R.string.send_paymentmode_lightning_desc), style = MaterialTheme.typography.caption.copy(fontSize = 14.sp)) - } - } - } - } -} - -@Composable -private fun ManualInputDialog( - onInputConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { - var input by remember { mutableStateOf("") } - Dialog( - onDismiss = onDismiss, - title = stringResource(id = R.string.scan_manual_input_title), - buttons = { - Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel), padding = PaddingValues(16.dp)) - Button(onClick = { onInputConfirm(input); onDismiss() }, text = stringResource(id = R.string.btn_ok), padding = PaddingValues(16.dp)) - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) { - Text(text = stringResource(id = R.string.scan_manual_input_instructions)) - Spacer(Modifier.height(16.dp)) - TextInput( - text = input, - onTextChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 3, - staticLabel = stringResource(id = R.string.scan_manual_input_label), - placeholder = { Text(text = stringResource(id = R.string.scan_manual_input_hint)) }, - keyboardType = KeyboardType.Email, - ) - } - } -} - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun BoxScope.CameraPermissionsView( - onPermissionGranted: @Composable () -> Unit -) { - val context = LocalContext.current - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - if (cameraPermissionState.status.isGranted) { - onPermissionGranted() - } else { - Card( - modifier = Modifier.align(Alignment.Center), - ) { - // if user has denied permission, open the system settings for Phoenix - val isDenied = cameraPermissionState.status.shouldShowRationale - Button( - icon = R.drawable.ic_camera, - text = stringResource(id = if (isDenied) R.string.scan_request_camera_access_denied else R.string.scan_request_camera_access), - onClick = { - if (isDenied) { - context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - }) - } else { - cameraPermissionState.launchPermissionRequest() - } - } - ) - } - } -} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt index db507521a..04dd4af9f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt @@ -50,7 +50,7 @@ import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.SplashLabelRow import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import fr.acinq.phoenix.android.payments.spliceout.spliceFailureDetails +import fr.acinq.phoenix.android.payments.send.spliceout.spliceFailureDetails import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.positiveColor diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt index b1ee3f876..763d5b3bd 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt @@ -61,7 +61,7 @@ import fr.acinq.phoenix.android.utils.mutedBgColor import fr.acinq.phoenix.android.utils.mutedTextColor import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.android.utils.positiveColor -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.extensions.smartDescription import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt index ceac944ea..8b7c422bd 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt @@ -35,7 +35,7 @@ import fr.acinq.phoenix.android.components.SplashLabelRow import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy import fr.acinq.phoenix.android.utils.isLegacyMigration -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.extensions.smartDescription import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.data.walletPaymentId diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt index 4d43a62ca..7e4a5d726 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt @@ -43,7 +43,7 @@ import fr.acinq.phoenix.android.components.contact.ContactCompactView import fr.acinq.phoenix.android.components.contact.OfferContactState import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.extensions.smartDescription import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.data.walletPaymentId diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt index 30811e798..21f01febc 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt @@ -41,7 +41,7 @@ import fr.acinq.phoenix.android.components.WebLink import fr.acinq.phoenix.android.components.contact.ContactOrOfferView import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.extensions.smartDescription import fr.acinq.phoenix.data.LnurlPayMetadata import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentMetadata diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt index bbe8b431c..6b733c22b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt @@ -31,7 +31,7 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.SplashLabelRow import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.extensions.smartDescription import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.data.walletPaymentId diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt index d34fa57d4..e3355b02a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt @@ -31,7 +31,7 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.SplashLabelRow import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.extensions.smartDescription import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.data.walletPaymentId diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt index 99176b661..c8cd8f5bd 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.android.BuildConfig import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString -import fr.acinq.phoenix.android.utils.basicDescription +import fr.acinq.phoenix.android.utils.extensions.basicDescription import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.managers.DatabaseManager import fr.acinq.phoenix.managers.PaymentsFetcher diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt index 245b9881b..26ce9d478 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt @@ -78,7 +78,7 @@ import fr.acinq.phoenix.android.components.enableOrFade import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.feedback.InfoMessage import fr.acinq.phoenix.android.components.feedback.SuccessMessage -import fr.acinq.phoenix.android.payments.spliceout.spliceFailureDetails +import fr.acinq.phoenix.android.payments.send.spliceout.spliceFailureDetails import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.orange diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt index 814be9da3..9b4a67754 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt @@ -70,7 +70,7 @@ import fr.acinq.phoenix.android.components.feedback.WarningMessage import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.BitmapHelper import fr.acinq.phoenix.android.utils.copyToClipboard -import fr.acinq.phoenix.android.utils.safeLet +import fr.acinq.phoenix.android.utils.extensions.safeLet import fr.acinq.phoenix.android.utils.updateScreenBrightnesss import kotlinx.coroutines.launch diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendSmartInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendSmartInput.kt new file mode 100644 index 000000000..92aaaf584 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendSmartInput.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.send + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.lightning.utils.getValue +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.utils.negativeColor + +val domains = listOf( + "testnet.phoenixwallet.me", + "bitrefill.me", + "strike.me", + "coincorner.io", + "sparkwallet.me", + "ln.tips", + "getalby.com", + "walletofsatoshi.com", + "stacker.news", +) + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SendSmartInput( + onValueChange: (String) -> Unit, + onValueSubmit: () -> Unit, + isProcessing: Boolean, + isError: Boolean, +) { + var textFieldValue by remember { mutableStateOf(TextFieldValue(text = "", selection = TextRange.Zero)) } + + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var showCompletionBox by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = showCompletionBox, + onExpandedChange = { showCompletionBox = it }, + modifier = Modifier.weight(1f) + ) { + val interactionSource = remember { MutableInteractionSource() } + val shape = RoundedCornerShape(24.dp) + val colors = if (isError) { + TextFieldDefaults.outlinedTextFieldColors( + backgroundColor = MaterialTheme.colors.surface, + focusedLabelColor = negativeColor, + unfocusedLabelColor = negativeColor.copy(alpha = .5f), + focusedBorderColor = negativeColor, + unfocusedBorderColor = negativeColor.copy(alpha = .5f), + ) + } else { + TextFieldDefaults.outlinedTextFieldColors( + backgroundColor = MaterialTheme.colors.surface, + focusedLabelColor = MaterialTheme.colors.primary, + unfocusedLabelColor = MaterialTheme.typography.body1.color, + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.primary, + ) + } + + BasicTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + onValueChange(it.text) + }, + modifier = Modifier + .fillMaxWidth() + .background(colors.backgroundColor(true).value, shape), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrect = false, + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Send, + ), + textStyle = MaterialTheme.typography.body1.copy(fontSize = 18.sp), + minLines = 1, + maxLines = 3, + singleLine = false, + enabled = true, + visualTransformation = VisualTransformation.None, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = textFieldValue.text, + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + placeholder = { + Text( + text = stringResource(id = R.string.preparesend_manual_input_hint), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + }, + trailingIcon = { + if (textFieldValue.text.isNotEmpty()) { + FilledButton( + onClick = { + textFieldValue = TextFieldValue("") + onValueChange("") + }, + icon = R.drawable.ic_cross, + enabled = true, + enabledEffect = false, + backgroundColor = Color.Transparent, + iconTint = MaterialTheme.colors.onSurface, + padding = PaddingValues(12.dp), + ) + } + }, + singleLine = false, + enabled = true, + isError = isError, + interactionSource = interactionSource, + colors = colors, + border = { + TextFieldDefaults.BorderBox(enabled = true, isError = isError, interactionSource = interactionSource, colors = colors, shape = shape) + } + ) + } + ) + + data class DomainFilterResult(val affix: String, val domainFilter: String, val domainsMatching: List) { + val isPerfectMatch: Boolean by lazy { domainsMatching.size == 1 && domainsMatching.first() == domainFilter } + } + + val filterResult = remember(textFieldValue.text) { + textFieldValue.text.takeIf { it.length >= 3 }?.let { input -> + val index = input.lastIndexOf("@") + if (index == -1) null else input.substring(0, index) to input.substring(index + 1, input.length) + }?.let { (affix, domain) -> + DomainFilterResult(affix, domain, domains.filter { it.contains(domain, ignoreCase = true) } ) + } + } + + if (filterResult != null && filterResult.domainsMatching.isNotEmpty()) { + if (filterResult.isPerfectMatch) { + showCompletionBox = false + } else { + showCompletionBox = true + Box(modifier = Modifier.padding(top = 48.dp, start = 16.dp, end = 0.dp)) { + ExposedDropdownMenu( + expanded = showCompletionBox, + onDismissRequest = { showCompletionBox = false }, + modifier = Modifier.exposedDropdownSize(false) + ) { + filterResult.domainsMatching.forEach { option -> + Clickable( + onClick = { + val newInput = "${filterResult.affix}@$option" + textFieldValue = TextFieldValue(text = newInput, selection = TextRange(newInput.length)) + onValueChange(newInput) + showCompletionBox = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(text = "@", style = MaterialTheme.typography.caption, modifier = Modifier.alignByBaseline()) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = option, modifier = Modifier.alignByBaseline()) + } + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + if (isProcessing) { + CircularProgressIndicator(Modifier.size(42.dp), strokeWidth = 2.dp) + } else { + Button( + icon = R.drawable.ic_send, + iconTint = MaterialTheme.colors.onPrimary, + onClick = onValueSubmit, + padding = PaddingValues(14.dp), + shape = CircleShape, + backgroundColor = if (isError) negativeColor else MaterialTheme.colors.primary, + ) + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendView.kt new file mode 100644 index 000000000..ff1296205 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendView.kt @@ -0,0 +1,495 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.send + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.Screen +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.DefaultScreenHeader +import fr.acinq.phoenix.android.components.DefaultScreenLayout +import fr.acinq.phoenix.android.components.Dialog +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.PhoenixIcon +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.components.contact.ContactPhotoView +import fr.acinq.phoenix.android.components.enableOrFade +import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.isDarkTheme +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.payments.send.bolt11.SendToBolt11View +import fr.acinq.phoenix.android.payments.send.lnurl.LnurlAuthView +import fr.acinq.phoenix.android.payments.send.lnurl.LnurlPayView +import fr.acinq.phoenix.android.payments.send.lnurl.LnurlWithdrawView +import fr.acinq.phoenix.android.payments.send.offer.SendToOfferView +import fr.acinq.phoenix.android.payments.send.spliceout.SendSpliceOutView +import fr.acinq.phoenix.android.popToHome +import fr.acinq.phoenix.android.utils.extensions.toLocalisedMessage +import fr.acinq.phoenix.android.utils.gray300 +import fr.acinq.phoenix.android.utils.gray800 +import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.android.utils.readClipboard +import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.data.lnurl.LnurlError +import fr.acinq.phoenix.managers.SendManager + +@Composable +fun SendView( + initialInput: String?, + immediatelyOpenScanner: Boolean, + fromDeepLink: Boolean, +) { + val navController = navController + val vm = viewModel(factory = PrepareSendViewModel.Factory(sendManager = business.sendManager)) + var showScanner by remember { mutableStateOf(immediatelyOpenScanner) } + val keyboardManager = LocalSoftwareKeyboardController.current + + val onBackClick: () -> Unit = { + if (fromDeepLink) { + navController.popToHome() + } else { + if (vm.parsePaymentState is ParsePaymentState.Ready) { + navController.popBackStack() + } else { + vm.resetParsing() + } + } + } + + when (val parseState = vm.parsePaymentState) { + // if payment data has been successfully parsed, redirect to the relevant payment screen + is ParsePaymentState.Success -> when (val data = parseState.data) { + is SendManager.ParseResult.Bolt11Invoice -> { + SendToBolt11View(invoice = data.invoice, onBackClick = onBackClick, onPaymentSent = { navController.popToHome() }) + } + is SendManager.ParseResult.Bolt12Offer -> { + SendToOfferView(offer = data.offer, onBackClick = onBackClick, onPaymentSent = { navController.popToHome() }) + } + is SendManager.ParseResult.Uri -> { + SendSpliceOutView(requestedAmount = data.uri.amount, address = data.uri.address, onBackClick = onBackClick, onSpliceOutSuccess = {navController.popToHome() }) + } + is SendManager.ParseResult.Lnurl.Pay -> { + LnurlPayView(payIntent = data.paymentIntent, onBackClick, onPaymentSent = { navController.popToHome() }) + } + is SendManager.ParseResult.Lnurl.Withdraw -> { + LnurlWithdrawView(withdraw = data.lnurlWithdraw, onBackClick = onBackClick, onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) }, onWithdrawDone = { navController.popToHome()}) + } + is SendManager.ParseResult.Lnurl.Auth -> { + LnurlAuthView(auth = data.auth, onBackClick = { navController.popBackStack() }, onChangeAuthSchemeSettingClick = { navController.navigate("${Screen.PaymentSettings.route}?showAuthSchemeDialog=true") }, + onAuthDone = { navController.popToHome() },) + } + } + is ParsePaymentState.Ready, is ParsePaymentState.Processing, is ParsePaymentState.Error, is ParsePaymentState.ChoosePaymentMode -> { + PrepareSendView( + onBackClick = onBackClick, + initialInput = initialInput, + vm = vm, + onShowScanner = { + vm.resetParsing() + keyboardManager?.hide() + showScanner = true + } + ) + + // show dialogs when a parsing error occurs + if (parseState is ParsePaymentState.Error && !showScanner) { + Dialog(onDismiss = vm::resetParsing, buttons = null) { + PaymentDataError( + errorMessage = when (parseState) { + is ParsePaymentState.GenericError -> parseState.errorMessage + is ParsePaymentState.ParsingFailure -> parseState.error.toLocalisedMessage() + }, + modifier = Modifier.padding(16.dp) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Spacer(modifier = Modifier.weight(1f)) + if (parseState is ParsePaymentState.ParsingFailure && parseState.error.reason is SendManager.BadRequestReason.ServiceError) { + val error = (parseState.error.reason as SendManager.BadRequestReason.ServiceError).error + if (error is LnurlError.RemoteFailure.IsWebsite) { + val context = LocalContext.current + Button(text = "Open link", icon = R.drawable.ic_external_link, onClick = { openLink(context, error.origin) }) + } + } + Button(text = stringResource(id = R.string.btn_ok), onClick = vm::resetParsing) + } + } + } + + if (parseState is ParsePaymentState.ChooseOnchainOrBolt11) { + ChoosePaymentModeDialog( + onPayOnchainClick = { vm.parsePaymentState = ParsePaymentState.Success(SendManager.ParseResult.Uri(parseState.uri)) }, + onPayOffchainClick = { vm.parsePaymentState = ParsePaymentState.Success(SendManager.ParseResult.Bolt11Invoice(request = parseState.request, parseState.bolt11)) }, + onDismiss = { vm.resetParsing() } + ) + } + + if (parseState is ParsePaymentState.ChooseOnchainOrOffer) { + ChoosePaymentModeDialog( + onPayOnchainClick = { vm.parsePaymentState = ParsePaymentState.Success(SendManager.ParseResult.Uri(parseState.uri)) }, + onPayOffchainClick = { vm.parsePaymentState = ParsePaymentState.Success(SendManager.ParseResult.Bolt12Offer(parseState.offer)) }, + onDismiss = { vm.resetParsing() } + ) + } + + if (showScanner) { + ScannerBox(state = vm.parsePaymentState, onDismiss = { vm.resetParsing() ; showScanner = false }, onReset = vm::resetParsing, onSubmit = vm::parsePaymentData) + } + } + } +} + +@Composable +private fun PrepareSendView( + initialInput: String?, + vm: PrepareSendViewModel, + onBackClick: () -> Unit, + onShowScanner: () -> Unit +) { + val context = LocalContext.current + var freeFormInput by remember { mutableStateOf(initialInput ?: "") } + val parsePaymentState = vm.parsePaymentState + val isProcessingData = vm.parsePaymentState.isProcessing || vm.readImageState.isProcessing + + LaunchedEffect(key1 = Unit) { + if (!initialInput.isNullOrBlank()) { + vm.parsePaymentData(initialInput) + } + } + + DefaultScreenLayout(isScrollable = false) { + DefaultScreenHeader(title = stringResource(id = R.string.preparesend_title), onBackClick = onBackClick) + + // show error message when reading an image from disk fails + when (vm.readImageState) { + is ReadImageState.Error -> Dialog(onDismiss = { vm.readImageState = ReadImageState.Ready }) { + Text(text = stringResource(id = R.string.preparesend_imagepicker_error), modifier = Modifier.padding(16.dp)) + } + is ReadImageState.NotFound -> Dialog(onDismiss = { vm.readImageState = ReadImageState.Ready }) { + Text(text = stringResource(id = R.string.preparesend_imagepicker_not_found), modifier = Modifier.padding(16.dp)) + } + ReadImageState.Reading, ReadImageState.Ready -> Unit + } + + SendSmartInput( + onValueChange = { + if (it.isBlank()) { vm.resetParsing() } + freeFormInput = it + }, + onValueSubmit = { vm.parsePaymentData(freeFormInput) }, + isProcessing = isProcessingData, + isError = parsePaymentState.hasFailed, + ) + + // contacts list + val contacts by business.contactsManager.contactsList.collectAsState() + Column(modifier = Modifier + .weight(1f) + .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + if (contacts.isEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + TextWithIcon(text = stringResource(id = R.string.preparesend_contacts_none), icon = R.drawable.ic_user, textStyle = MaterialTheme.typography.caption.copy(fontSize = 16.sp), iconTint = MaterialTheme.typography.caption.color, iconSize = 24.dp, space = 8.dp) + } else { + val filteredContacts by produceState(initialValue = emptyList(), key1 = contacts, key2 = freeFormInput) { + value = if (freeFormInput.isBlank() || freeFormInput.length < 2) { + contacts + } else { + contacts.filter { it.name.contains(freeFormInput, ignoreCase = true) } + } + } + if (freeFormInput.isNotBlank() && filteredContacts.isEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + TextWithIcon(text = stringResource(id = R.string.preparesend_contacts_no_matches), icon = R.drawable.ic_user_search, textStyle = MaterialTheme.typography.caption.copy(fontSize = 16.sp), iconTint = MaterialTheme.typography.caption.color, iconSize = 24.dp, space = 8.dp) + } else { + LazyColumn { + item { Spacer(modifier = Modifier.height(12.dp)) } + items(filteredContacts) { + ContactRow( + contactInfo = it, + onClick = { it.mostRelevantOffer?.let { vm.parsePaymentData(it.encode()) } }, + enabled = !isProcessingData + ) + } + item { Spacer(modifier = Modifier.height(12.dp)) } + } + } + } + } + + // bottom buttons + Surface( + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + color = MaterialTheme.colors.surface, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + ) { + if (isProcessingData) { + ProgressView(text = when { + vm.parsePaymentState is ParsePaymentState.ResolvingBip353 -> stringResource(id = R.string.preparesend_bip353_resolving) + vm.parsePaymentState is ParsePaymentState.ResolvingLnurl -> stringResource(id = R.string.preparesend_lnurl_fetching) + else -> stringResource(id = R.string.preparesend_parsing) + }, modifier = Modifier.heightIn(min = 80.dp), padding = PaddingValues(20.dp)) + } else { + SendButtonsRow( + onSubmit = { + freeFormInput = "" + vm.parsePaymentData(it) + }, + onReadImage = { + vm.resetParsing() + vm.readImage(context, it, onDataFound = vm::parsePaymentData) + }, + onShowScanner = onShowScanner, + enabled = !isProcessingData + ) + } + } + } + } +} + +@Composable +private fun RowScope.SendButtonsRow( + onSubmit: (String) -> Unit, + onReadImage: (Uri) -> Unit, + onShowScanner: () -> Unit, + enabled: Boolean, +) { + val context = LocalContext.current + var imageUri by remember { mutableStateOf(null) } + val imagePickerLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + imageUri = it + } + LaunchedEffect(key1 = imageUri) { + imageUri?.let { onReadImage(it) ; imageUri = null } + } + ReadDataButton(label = stringResource(id = R.string.preparesend_imagepicker_button), icon = R.drawable.ic_image, onClick = { imagePickerLauncher.launch("image/*") }, enabled = enabled) + ReadDataButton(label = stringResource(id = R.string.preparesend_paste_button), icon = R.drawable.ic_paste, onClick = { readClipboard(context)?.let { onSubmit(it) } }, enabled = enabled) + ReadDataButton(label = stringResource(id = R.string.preparesend_scan_button), icon = R.drawable.ic_scan_qr, onClick = onShowScanner, enabled = enabled) +} + +@Composable +private fun PaymentDataError(errorMessage: String, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.body1) { + Row(modifier) { + PhoenixIcon(resourceId = R.drawable.ic_alert_triangle, tint = negativeColor, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = errorMessage, style = textStyle) + } +} + +@Composable +private fun ScannerBox( + state: ParsePaymentState, + onReset: () -> Unit, + onDismiss: () -> Unit, + onSubmit: (String) -> Unit, +) { + var scanView by remember { mutableStateOf(null) } + Box(Modifier.fillMaxSize()) { + ScannerView( + onScanViewBinding = { scanView = it }, + onScannedText = onSubmit + ) + CameraPermissionsView { + DisposableEffect(Unit) { + scanView?.resume() + onDispose { scanView?.pause() } + } + } + + BackHandler(onBack = onDismiss) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + if (state is ParsePaymentState.Error) { + Dialog(onDismiss = { onReset() ; scanView?.resume() }) { + PaymentDataError( + errorMessage = when (state) { + is ParsePaymentState.GenericError -> state.errorMessage + is ParsePaymentState.ParsingFailure -> state.error.toLocalisedMessage() + }, + modifier = Modifier.padding(16.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + FilledButton( + text = stringResource(id = R.string.btn_cancel), + icon = R.drawable.ic_arrow_back, + iconTint = MaterialTheme.colors.onSurface, + backgroundColor = MaterialTheme.colors.surface, + textStyle = MaterialTheme.typography.button, + padding = PaddingValues(16.dp), + onClick = onDismiss, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun RowScope.ReadDataButton( + label: String, + icon: Int, + onClick: () -> Unit, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + Clickable( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .alpha(if (enabled) 1f else 0.35f), + onClick = onClick, + interactionSource = interactionSource, + indication = null, + enabled = enabled, + ) { + Column( + modifier = Modifier.padding(top = 12.dp, start = 8.dp, end = 8.dp, bottom = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .indication(interactionSource, rememberRipple(bounded = false, color = if (isDarkTheme) gray300 else gray800, radius = 32.dp)) + .clip(CircleShape) + .border(width = 1.dp, color = MaterialTheme.colors.primary, shape = CircleShape) + .background(MaterialTheme.colors.surface) + .padding(14.dp), + ) { + PhoenixIcon(resourceId = icon, tint = MaterialTheme.colors.primary, modifier = Modifier.size(22.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = label, style = MaterialTheme.typography.body2.copy(fontSize = 12.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun ContactRow( + contactInfo: ContactInfo, + onClick: () -> Unit, + enabled: Boolean, +) { + Clickable(modifier = Modifier.fillMaxWidth(), onClick = onClick, enabled = enabled) { + Row(modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .enableOrFade(enabled), verticalAlignment = Alignment.CenterVertically) { + ContactPhotoView(photoUri = contactInfo.photoUri, name = contactInfo.name, onChange = null, imageSize = 32.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = contactInfo.name, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 18.sp) + } + } +} + +@Composable +private fun ChoosePaymentModeDialog( + onPayOnchainClick: () -> Unit, + onPayOffchainClick: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismiss = onDismiss, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = true), isScrollable = true, buttons = null) { + Clickable(onClick = onPayOnchainClick) { + Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) { + PhoenixIcon(resourceId = R.drawable.ic_chain) + Spacer(Modifier.width(16.dp)) + Column { + Text(text = stringResource(id = R.string.send_paymentmode_onchain), style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(id = R.string.send_paymentmode_onchain_desc), style = MaterialTheme.typography.caption.copy(fontSize = 14.sp)) + } + } + } + Clickable(onClick = onPayOffchainClick) { + Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 18.dp), verticalAlignment = Alignment.CenterVertically) { + PhoenixIcon(resourceId = R.drawable.ic_zap) + Spacer(Modifier.width(16.dp)) + Column { + Text(text = stringResource(id = R.string.send_paymentmode_lightning), style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(id = R.string.send_paymentmode_lightning_desc), style = MaterialTheme.typography.caption.copy(fontSize = 14.sp)) + } + } + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendViewModel.kt new file mode 100644 index 000000000..02c7b0579 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/PrepareSendViewModel.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.send + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.google.zxing.BinaryBitmap +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.multi.qrcode.QRCodeMultiReader +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.BitcoinUri +import fr.acinq.phoenix.managers.SendManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + + +sealed class ReadImageState { + data object Ready: ReadImageState() + data object Reading: ReadImageState() + data object NotFound: ReadImageState() + data object Error: ReadImageState() + + val isProcessing by lazy { this is Reading || this is NotFound } +} + +sealed class ParsePaymentState { + data object Ready : ParsePaymentState() + + sealed class Processing : ParsePaymentState() + data object Parsing : Processing() + data object ResolvingLnurl : Processing() + data object ResolvingBip353 : Processing() + + sealed class ChoosePaymentMode: ParsePaymentState() + data class ChooseOnchainOrBolt11(val request: String, val uri: BitcoinUri, val bolt11: Bolt11Invoice) : ChoosePaymentMode() + data class ChooseOnchainOrOffer(val request: String, val uri: BitcoinUri, val offer: OfferTypes.Offer) : ChoosePaymentMode() + + data class Success(val data: SendManager.ParseResult.Success) : ParsePaymentState() + + sealed class Error : ParsePaymentState() + data class ParsingFailure(val error: SendManager.ParseResult.BadRequest) : Error() + data class GenericError(val cause: Throwable): Error() { + val errorMessage: String by lazy { cause.localizedMessage ?: cause::class.java.name } + } + + val isProcessing by lazy { this is Processing } + + val hasFailed by lazy { this is Error } +} + +class PrepareSendViewModel(val sendManager: SendManager) : ViewModel() { + private val log = LoggerFactory.getLogger(this::class.java) + + var readImageState by mutableStateOf(ReadImageState.Ready) + + var parsePaymentState by mutableStateOf(ParsePaymentState.Ready) + + fun readImage(context: Context, uri: Uri, onDataFound: (String) -> Unit) { + if (!(readImageState is ReadImageState.Ready || readImageState is ReadImageState.Error)) return + readImageState = ReadImageState.Reading + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.info("failed to load file or read QR code for file=$uri: ", e) + readImageState = when (e) { + is com.google.zxing.NotFoundException -> ReadImageState.NotFound + else -> ReadImageState.Error + } + }) { + val bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { + BitmapFactory.decodeFileDescriptor(it.fileDescriptor) + } + if (bitmap == null) { + readImageState = ReadImageState.NotFound + return@launch + } else { + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val binaryBitmap = BinaryBitmap(HybridBinarizer(RGBLuminanceSource(bitmap.width, bitmap.height, pixels))) + val result = QRCodeMultiReader().decodeMultiple(binaryBitmap) + if (result.isEmpty()) { + log.debug("could not find any QR code for file={}", uri) + readImageState = ReadImageState.NotFound + } else { + val data = result.first().text + if (data.isNotBlank()) { + log.debug("decoded data={} for file={}", data, uri) + delay(400) + readImageState = ReadImageState.Ready + onDataFound(data) + } else { + readImageState = ReadImageState.NotFound + } + } + } + } + } + + fun parsePaymentData(input: String) { + if (parsePaymentState.isProcessing) return + parsePaymentState = ParsePaymentState.Parsing + + viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> + log.error("error when parsing payment data: ${e.message}") + parsePaymentState = ParsePaymentState.GenericError(e) + }) { + val result = sendManager.parse( + request = input, + progress = { + parsePaymentState = when (it) { + is SendManager.ParseProgress.ResolvingBip353 -> ParsePaymentState.ResolvingBip353 + is SendManager.ParseProgress.LnurlServiceFetch -> ParsePaymentState.ResolvingLnurl + } + } + ) + log.info("parsed [${result.javaClass.canonicalName}] from input=$input") + parsePaymentState = when (result) { + is SendManager.ParseResult.BadRequest -> ParsePaymentState.ParsingFailure(result) + is SendManager.ParseResult.Success -> { + when (result) { + is SendManager.ParseResult.Uri -> { + val bolt11 = result.uri.paymentRequest + val offer = result.uri.offer + when { + bolt11 != null -> ParsePaymentState.ChooseOnchainOrBolt11(request = input, uri = result.uri.copy(paymentRequest = null, offer = null), bolt11 = bolt11) + offer != null -> ParsePaymentState.ChooseOnchainOrOffer(request = input, uri = result.uri.copy(paymentRequest = null, offer = null), offer = offer) + else -> ParsePaymentState.Success(result) + } + } + else -> ParsePaymentState.Success(result) + } + } + } + } + } + + fun resetParsing() { + if (parsePaymentState !is ParsePaymentState.Processing) parsePaymentState = ParsePaymentState.Ready + } + + class Factory( + private val sendManager: SendManager + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return PrepareSendViewModel(sendManager) as T + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/ScannerView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/ScannerView.kt new file mode 100644 index 000000000..e38c8fcb1 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/ScannerView.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package fr.acinq.phoenix.android.payments.send + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidViewBinding +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.google.zxing.BarcodeFormat +import com.google.zxing.ResultPoint +import com.google.zxing.client.android.Intents +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.databinding.ScanViewBinding + +@Composable +fun ScannerView( + onScanViewBinding: (DecoratedBarcodeView) -> Unit, + onScannedText: (String) -> Unit +) { + // scanner view using a legacy binding + AndroidViewBinding( + modifier = Modifier + .fillMaxSize() + .background(Color.Red), + factory = { inflater, viewGroup, attach -> + val binding = ScanViewBinding.inflate(inflater, viewGroup, attach) + binding.scanView.let { scanView -> + scanView.initializeFromIntent(Intent().apply { + putExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) + putExtra(Intents.Scan.FORMATS, BarcodeFormat.QR_CODE.name) + }) + scanView.decodeContinuous(object : BarcodeCallback { + override fun possibleResultPoints(resultPoints: MutableList?) = Unit + override fun barcodeResult(result: BarcodeResult?) { + result?.text?.trim()?.takeIf { it.isNotBlank() }?.let { + scanView.pause() + onScannedText(it) + } + } + }) + onScanViewBinding(scanView) + scanView.resume() + } + binding + } + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun BoxScope.CameraPermissionsView( + onPermissionGranted: @Composable () -> Unit +) { + val context = LocalContext.current + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + if (cameraPermissionState.status.isGranted) { + onPermissionGranted() + } else { + Card( + modifier = Modifier.align(Alignment.Center), + ) { + // if user has denied permission, open the system settings for Phoenix + val isDenied = cameraPermissionState.status.shouldShowRationale + Button( + icon = R.drawable.ic_camera, + text = stringResource(id = if (isDenied) R.string.scan_request_camera_access_denied else R.string.scan_request_camera_access), + onClick = { + if (isDenied) { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + }) + } else { + cameraPermissionState.launchPermissionRequest() + } + } + ) + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/bolt11/SendToBolt11.kt similarity index 90% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/bolt11/SendToBolt11.kt index 7f3145a81..bd44440d7 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/bolt11/SendToBolt11.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 ACINQ SAS + * Copyright 2024 ACINQ SAS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments +package fr.acinq.phoenix.android.payments.send.bolt11 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -33,7 +33,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import fr.acinq.lightning.TrampolineFees import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R @@ -41,21 +40,24 @@ import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString -import fr.acinq.phoenix.android.utils.safeLet -import fr.acinq.phoenix.controllers.payments.Scan +import fr.acinq.phoenix.android.utils.extensions.safeLet import fr.acinq.phoenix.utils.extensions.isAmountlessTrampoline +import kotlinx.coroutines.launch @Composable -fun SendBolt11PaymentView( +fun SendToBolt11View( invoice: Bolt11Invoice, - trampolineFees: TrampolineFees?, onBackClick: () -> Unit, - onPayClick: (Scan.Intent.Bolt11InvoiceFlow.SendBolt11Invoice) -> Unit + onPaymentSent: () -> Unit, ) { val context = LocalContext.current - val balance = business.balanceManager.balance.collectAsState(null).value val prefBitcoinUnit = LocalBitcoinUnit.current + val balance = business.balanceManager.balance.collectAsState(null).value + val sendManager = business.sendManager + val peer by business.peerManager.peerState.collectAsState() + val trampolineFees = peer?.walletParams?.trampolineFees?.firstOrNull() + val requestedAmount = invoice.amount var amount by remember { mutableStateOf(requestedAmount) } val amountErrorMessage: String = remember(amount) { @@ -157,6 +159,7 @@ fun SendBolt11PaymentView( } } Spacer(modifier = Modifier.height(36.dp)) + val scope = rememberCoroutineScope() val mayDoPayments by business.peerManager.mayDoPayments.collectAsState() Row(verticalAlignment = Alignment.CenterVertically) { FilledButton( @@ -165,7 +168,15 @@ fun SendBolt11PaymentView( enabled = mayDoPayments && amount != null && amountErrorMessage.isBlank() && trampolineFees != null, ) { safeLet(amount, trampolineFees) { amt, fees -> - onPayClick(Scan.Intent.Bolt11InvoiceFlow.SendBolt11Invoice(invoice = invoice, amount = amt, trampolineFees = fees)) + scope.launch { + sendManager.payBolt11Invoice( + amountToSend = amt, + trampolineFees = fees, + invoice = invoice, + metadata = null + ) + onPaymentSent() + } } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlAuthView.kt similarity index 65% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlAuthView.kt index f07e5ab4d..b777c51d1 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlAuthView.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 ACINQ SAS + * Copyright 2024 ACINQ SAS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments +package fr.acinq.phoenix.android.payments.send.lnurl import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -28,23 +28,26 @@ import androidx.compose.ui.res.stringResource 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.phoenix.android.R +import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.userPrefs -import fr.acinq.phoenix.controllers.payments.Scan import fr.acinq.phoenix.data.lnurl.LnurlAuth +import fr.acinq.phoenix.managers.SendManager @Composable fun LnurlAuthView( - model: Scan.Model.LnurlAuthFlow, + auth: LnurlAuth, onBackClick: () -> Unit, - onLoginClick: (Scan.Intent.LnurlAuthFlow) -> Unit, - onAuthSchemeInfoClick: () -> Unit, + onChangeAuthSchemeSettingClick: () -> Unit, onAuthDone: () -> Unit, ) { var showHowItWorks by remember { mutableStateOf(false) } val prefAuthScheme by userPrefs.getLnurlAuthScheme.collectAsState(initial = null) - val isLegacyDomain = remember(model) { LnurlAuth.LegacyDomain.isEligible(model.auth.initialUrl) } + val isLegacyDomain = remember(auth) { LnurlAuth.LegacyDomain.isEligible(auth.initialUrl) } + val vm = viewModel(factory = LnurlAuthViewModel.Factory(business.sendManager)) Column( modifier = Modifier @@ -54,14 +57,14 @@ fun LnurlAuthView( ) { DefaultScreenHeader(onBackClick = onBackClick) Spacer(Modifier.height(16.dp)) - when (model) { - is Scan.Model.LnurlAuthFlow.LoginRequest -> { + when (val state = vm.state.value) { + is LnurlAuthViewState.Init -> { val isLegacySchemeUsed = prefAuthScheme == LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME && isLegacyDomain if (showHowItWorks) { HowItWorksDialog( - domain = LnurlAuth.LegacyDomain.filterDomain(model.auth.initialUrl), + domain = LnurlAuth.LegacyDomain.filterDomain(auth.initialUrl), isLegacySchemeUsed = isLegacySchemeUsed, - onAuthSchemeInfoClick = onAuthSchemeInfoClick, + onChangeAuthSchemeSettingClick = onChangeAuthSchemeSettingClick, onDismiss = { showHowItWorks = false } ) } @@ -72,7 +75,7 @@ fun LnurlAuthView( ) { Text(text = stringResource(R.string.lnurl_auth_instructions), textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(16.dp)) - Text(text = model.auth.initialUrl.host, style = MaterialTheme.typography.body2, textAlign = TextAlign.Center) + Text(text = auth.initialUrl.host, style = MaterialTheme.typography.body2, textAlign = TextAlign.Center) if (isLegacySchemeUsed) { Text( text = stringResource(R.string.lnurl_auth_legacy_notice), @@ -95,61 +98,66 @@ fun LnurlAuthView( FilledButton( text = stringResource(id = R.string.lnurl_auth_button), icon = R.drawable.ic_key, - onClick = { - if (prefAuthScheme != null) { - onLoginClick(Scan.Intent.LnurlAuthFlow.Login(model.auth, minSuccessDelaySeconds = 1.0, scheme = prefAuthScheme ?: LnurlAuth.Scheme.DEFAULT_SCHEME)) - } - }, + onClick = { vm.authenticateToDomain(auth, prefAuthScheme ?: LnurlAuth.Scheme.DEFAULT_SCHEME) }, enabled = prefAuthScheme != null ) } - is Scan.Model.LnurlAuthFlow.LoggingIn -> { + is LnurlAuthViewState.LoggingIn -> { Card( externalPadding = PaddingValues(horizontal = 16.dp), internalPadding = PaddingValues(32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(text = stringResource(R.string.lnurl_auth_in_progress, model.auth.initialUrl.host), textAlign = TextAlign.Center) + Text(text = stringResource(R.string.lnurl_auth_in_progress, auth.initialUrl.host), textAlign = TextAlign.Center) } } - is Scan.Model.LnurlAuthFlow.LoginResult -> { - val error = model.error - if (error != null) { - Card( - externalPadding = PaddingValues(horizontal = 16.dp), - internalPadding = PaddingValues(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ErrorResponseView(error) - BorderButton(text = stringResource(R.string.lnurl_auth_try_again_button), icon = R.drawable.ic_arrow_back, onClick = onBackClick) - } - } else { - Card( - externalPadding = PaddingValues(horizontal = 16.dp), - internalPadding = PaddingValues(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = stringResource(id = R.string.lnurl_auth_success, model.auth.initialUrl.host), textAlign = TextAlign.Center) - } - Spacer(Modifier.height(32.dp)) - FilledButton(text = stringResource(id = R.string.btn_ok), icon = R.drawable.ic_check, onClick = onAuthDone) + is LnurlAuthViewState.AuthFailure -> { + Card( + externalPadding = PaddingValues(horizontal = 16.dp), + internalPadding = PaddingValues(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ErrorResponseView(state.error) + BorderButton(text = stringResource(R.string.lnurl_auth_try_again_button), icon = R.drawable.ic_arrow_back, onClick = onBackClick) + } + } + is LnurlAuthViewState.Error -> { + Card( + externalPadding = PaddingValues(horizontal = 16.dp), + internalPadding = PaddingValues(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ErrorMessage(header = stringResource(R.string.lnurl_auth_failure), details = state.cause.message) + Spacer(Modifier.height(24.dp)) + BorderButton(text = stringResource(R.string.lnurl_auth_try_again_button), icon = R.drawable.ic_arrow_back, onClick = onBackClick) } } + is LnurlAuthViewState.AuthSuccess -> { + Card( + externalPadding = PaddingValues(horizontal = 16.dp), + internalPadding = PaddingValues(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(id = R.string.lnurl_auth_success, auth.initialUrl.host), textAlign = TextAlign.Center) + } + Spacer(Modifier.height(32.dp)) + FilledButton(text = stringResource(id = R.string.btn_ok), icon = R.drawable.ic_check, onClick = onAuthDone) + } } } } @Composable private fun ErrorResponseView( - error: Scan.LoginError + error: SendManager.LnurlAuthError ) { Text(text = stringResource(R.string.lnurl_auth_failure), style = MaterialTheme.typography.body2) Spacer(Modifier.height(8.dp)) Text( text = when (error) { - is Scan.LoginError.ServerError -> error.details.details - is Scan.LoginError.NetworkError -> stringResource(R.string.lnurl_auth_error_network) - is Scan.LoginError.OtherError -> stringResource(R.string.lnurl_auth_error_other) + is SendManager.LnurlAuthError.ServerError -> error.details.details + is SendManager.LnurlAuthError.NetworkError -> stringResource(R.string.lnurl_auth_error_network) + is SendManager.LnurlAuthError.OtherError -> stringResource(R.string.lnurl_auth_error_other) }, style = MaterialTheme.typography.body1.copy(textAlign = TextAlign.Center), modifier = Modifier @@ -163,7 +171,7 @@ private fun ErrorResponseView( private fun HowItWorksDialog( domain: String, isLegacySchemeUsed: Boolean, - onAuthSchemeInfoClick: () -> Unit, + onChangeAuthSchemeSettingClick: () -> Unit, onDismiss: () -> Unit ) { Dialog(title = stringResource(id = R.string.lnurl_auth_help_dialog_title), onDismiss = onDismiss) { @@ -185,7 +193,7 @@ private fun HowItWorksDialog( text = stringResource(id = R.string.lnurl_auth_help_dialog_compat_button), icon = R.drawable.ic_settings, space = 8.dp, - onClick = onAuthSchemeInfoClick + onClick = onChangeAuthSchemeSettingClick ) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlAuthViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlAuthViewModel.kt new file mode 100644 index 000000000..cd2c07285 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlAuthViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.send.lnurl + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.acinq.phoenix.data.lnurl.LnurlAuth +import fr.acinq.phoenix.managers.SendManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +sealed class LnurlAuthViewState { + data object Init : LnurlAuthViewState() + data object LoggingIn : LnurlAuthViewState() + data object AuthSuccess : LnurlAuthViewState() + data class AuthFailure(val error: SendManager.LnurlAuthError) : LnurlAuthViewState() + data class Error(val cause: Throwable) : LnurlAuthViewState() +} + +class LnurlAuthViewModel(private val sendManager: SendManager) : ViewModel() { + + val log = LoggerFactory.getLogger(this::class.java) + val state = mutableStateOf(LnurlAuthViewState.Init) + + fun authenticateToDomain(auth: LnurlAuth, scheme: LnurlAuth.Scheme) { + if (state.value is LnurlAuthViewState.LoggingIn) return + state.value = LnurlAuthViewState.LoggingIn + + viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> + log.error("failed to authenticate on lnurl-auth=$auth: ", e) + state.value = LnurlAuthViewState.Error(e) + }) { + val result = sendManager.lnurlAuth_signAndSend(auth, minSuccessDelaySeconds = 1.0, scheme = scheme) + when (result) { + is SendManager.LnurlAuthError -> state.value = LnurlAuthViewState.AuthFailure(result) + null -> state.value = LnurlAuthViewState.AuthSuccess + } + } + } + + class Factory( + private val sendManager: SendManager + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return LnurlAuthViewModel(sendManager) as T + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlPayView.kt similarity index 61% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlPayView.kt index c0b216aa0..5674ca92a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlPayView.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 ACINQ SAS + * Copyright 2024 ACINQ SAS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,19 +14,22 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments +package fr.acinq.phoenix.android.payments.send.lnurl import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap @@ -35,37 +38,52 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.TrampolineFees import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business -import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.AmountHeroInput +import fr.acinq.phoenix.android.components.BackButtonWithBalance +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Dialog +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.SplashClickableContent +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.SplashLayout +import fr.acinq.phoenix.android.components.TextInput import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.fiatRate import fr.acinq.phoenix.android.preferredAmountUnit import fr.acinq.phoenix.android.utils.BitmapHelper import fr.acinq.phoenix.android.utils.Converter.toPrettyStringWithFallback import fr.acinq.phoenix.android.utils.annotatedStringResource -import fr.acinq.phoenix.android.utils.safeLet -import fr.acinq.phoenix.controllers.payments.Scan +import fr.acinq.phoenix.android.utils.extensions.safeLet +import fr.acinq.phoenix.android.utils.extensions.toLocalisedMessage import fr.acinq.phoenix.data.lnurl.LnurlError +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.managers.SendManager @Composable fun LnurlPayView( - model: Scan.Model.LnurlPayFlow, - trampolineFees: TrampolineFees?, + payIntent: LnurlPay.Intent, onBackClick: () -> Unit, - onSendLnurlPayClick: (Scan.Intent.LnurlPayFlow) -> Unit + onPaymentSent: () -> Unit, ) { val context = LocalContext.current val balance = business.balanceManager.balance.collectAsState(null).value val prefUnit = preferredAmountUnit val rate = fiatRate - val minRequestedAmount = model.paymentIntent.minSendable + val peer by business.peerManager.peerState.collectAsState() + val trampolineFees = peer?.walletParams?.trampolineFees?.firstOrNull() + + val minRequestedAmount = payIntent.minSendable var amount by remember { mutableStateOf(minRequestedAmount) } var amountErrorMessage by remember { mutableStateOf("") } + val vm = viewModel(factory = LnurlPayViewModel.Factory(business.sendManager)) + SplashLayout( header = { BackButtonWithBalance(onBackClick = onBackClick, balance = balance) }, topContent = { @@ -78,44 +96,44 @@ fun LnurlPayView( balance != null && newAmount.amount > balance -> { amountErrorMessage = context.getString(R.string.send_error_amount_over_balance) } - newAmount.amount < model.paymentIntent.minSendable -> { - amountErrorMessage = context.getString(R.string.lnurl_pay_amount_below_min, model.paymentIntent.minSendable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) + newAmount.amount < payIntent.minSendable -> { + amountErrorMessage = context.getString(R.string.lnurl_pay_amount_below_min, payIntent.minSendable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) } - newAmount.amount > model.paymentIntent.maxSendable -> { - amountErrorMessage = context.getString(R.string.lnurl_pay_amount_above_max, model.paymentIntent.maxSendable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) + newAmount.amount > payIntent.maxSendable -> { + amountErrorMessage = context.getString(R.string.lnurl_pay_amount_above_max, payIntent.maxSendable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) } } amount = newAmount?.amount }, validationErrorMessage = amountErrorMessage, inputTextSize = 42.sp, - enabled = model.paymentIntent.minSendable != model.paymentIntent.maxSendable + enabled = payIntent.minSendable != payIntent.maxSendable ) } ) { - val image = remember(model.paymentIntent.metadata.imagePng + model.paymentIntent.metadata.imageJpg) { - listOfNotNull(model.paymentIntent.metadata.imagePng, model.paymentIntent.metadata.imageJpg).firstOrNull()?.let { + val image = remember(payIntent.metadata.imagePng + payIntent.metadata.imageJpg) { + listOfNotNull(payIntent.metadata.imagePng, payIntent.metadata.imageJpg).firstOrNull()?.let { BitmapHelper.decodeBase64Image(it)?.asImageBitmap() } } image?.let { - Image(bitmap = it, contentDescription = model.paymentIntent.metadata.plainText, modifier = Modifier.size(90.dp)) + Image(bitmap = it, contentDescription = payIntent.metadata.plainText, modifier = Modifier.size(90.dp)) Spacer(modifier = Modifier.height(16.dp)) } SplashLabelRow(label = stringResource(R.string.lnurl_pay_domain)) { - Text(text = model.paymentIntent.callback.host, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = payIntent.callback.host, maxLines = 1, overflow = TextOverflow.Ellipsis) } Spacer(modifier = Modifier.height(8.dp)) SplashLabelRow(label = stringResource(R.string.lnurl_pay_meta_description)) { Text( - text = model.paymentIntent.metadata.longDesc ?: model.paymentIntent.metadata.plainText, + text = payIntent.metadata.longDesc ?: payIntent.metadata.plainText, maxLines = 3, overflow = TextOverflow.Ellipsis ) } var comment by remember { mutableStateOf(null) } - val commentLength = model.paymentIntent.maxCommentLength?.toInt() + val commentLength = payIntent.maxCommentLength?.toInt() if (commentLength != null && commentLength > 0) { var showCommentDialog by remember { mutableStateOf(false) } if (showCommentDialog) { @@ -140,20 +158,22 @@ fun LnurlPayView( } Spacer(modifier = Modifier.height(32.dp)) - when (model) { - is Scan.Model.LnurlPayFlow.LnurlPayRequest -> { - val error = model.error - if (error != null) { + when (val state = vm.state.value) { + is LnurlPayViewState.Init, is LnurlPayViewState.Error -> { + if (state is LnurlPayViewState.Error) { ErrorMessage( header = stringResource(id = R.string.lnurl_pay_error_header), - details = when (error) { - is Scan.LnurlPayError.AlreadyPaidInvoice -> annotatedStringResource(R.string.lnurl_pay_error_already_paid, model.paymentIntent.callback.host) - is Scan.LnurlPayError.ChainMismatch -> annotatedStringResource(R.string.lnurl_pay_error_invalid_chain, model.paymentIntent.callback.host) - is Scan.LnurlPayError.BadResponseError -> when (val errorDetail = error.err) { - is LnurlError.Pay.Invoice.InvalidAmount -> annotatedStringResource(R.string.lnurl_pay_error_invalid_amount, errorDetail.origin) - is LnurlError.Pay.Invoice.Malformed -> annotatedStringResource(R.string.lnurl_pay_error_invalid_malformed, errorDetail.origin) + details = when (state) { + is LnurlPayViewState.Error.Generic -> state.cause.localizedMessage + is LnurlPayViewState.Error.PayError -> when (val error = state.error) { + is SendManager.LnurlPayError.AlreadyPaidInvoice -> annotatedStringResource(R.string.lnurl_pay_error_already_paid, payIntent.callback.host) + is SendManager.LnurlPayError.ChainMismatch -> annotatedStringResource(R.string.lnurl_pay_error_invalid_chain, payIntent.callback.host) + is SendManager.LnurlPayError.BadResponseError -> when (val errorDetail = error.err) { + is LnurlError.Pay.Invoice.InvalidAmount -> annotatedStringResource(R.string.lnurl_pay_error_invalid_amount, errorDetail.origin) + is LnurlError.Pay.Invoice.Malformed -> annotatedStringResource(R.string.lnurl_pay_error_invalid_malformed, errorDetail.origin) + } + is SendManager.LnurlPayError.RemoteError -> error.err.toLocalisedMessage() } - is Scan.LnurlPayError.RemoteError -> getRemoteErrorMessage(error = error.err) }, alignment = Alignment.CenterHorizontally ) @@ -167,21 +187,16 @@ fun LnurlPayView( enabled = mayDoPayments && amount != null && amountErrorMessage.isBlank() && trampolineFees != null, ) { safeLet(trampolineFees, amount) { fees, amt -> - onSendLnurlPayClick( - Scan.Intent.LnurlPayFlow.RequestInvoice( - paymentIntent = model.paymentIntent, - amount = amt, - trampolineFees = fees, - comment = comment?.takeIf { it.isNotBlank() }, - ) - ) + vm.requestAndPayInvoice(payIntent, amt, fees, comment?.takeIf { it.isNotBlank() }, onPaymentSent) } } } - is Scan.Model.LnurlPayFlow.LnurlPayFetch -> { + is LnurlPayViewState.RequestingInvoice -> { ProgressView(text = stringResource(id = R.string.lnurl_pay_requesting_invoice)) } - is Scan.Model.LnurlPayFlow.Sending -> LaunchedEffect(Unit) { onBackClick() } + is LnurlPayViewState.PayingInvoice -> { + ProgressView(text = stringResource(id = R.string.lnurl_pay_paying_invoice)) + } } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlPayViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlPayViewModel.kt new file mode 100644 index 000000000..9442cc9a7 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlPayViewModel.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.send.lnurl + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.TrampolineFees +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.managers.SendManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + + +sealed class LnurlPayViewState { + data object Init : LnurlPayViewState() + data object RequestingInvoice: LnurlPayViewState() + data class PayingInvoice(val invoice: LnurlPay.Invoice) : LnurlPayViewState() + sealed class Error : LnurlPayViewState() { + data class Generic(val cause: Throwable): Error() + data class PayError(val error : SendManager.LnurlPayError) : Error() + } +} + +class LnurlPayViewModel(private val sendManager: SendManager) : ViewModel() { + + val log = LoggerFactory.getLogger(this::class.java) + val state = mutableStateOf(LnurlPayViewState.Init) + + fun requestAndPayInvoice(payIntent: LnurlPay.Intent, amount: MilliSatoshi, fees: TrampolineFees, comment: String?, onPaymentSent: () -> Unit) { + if (state.value is LnurlPayViewState.RequestingInvoice || state.value is LnurlPayViewState.PayingInvoice) return + state.value = LnurlPayViewState.RequestingInvoice + + viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> + log.error("failed to pay lnurl-pay intent: ", e) + state.value = LnurlPayViewState.Error.Generic(e) + }) { + when (val result = sendManager.lnurlPay_requestInvoice(payIntent, amount, comment)) { + is Either.Left -> state.value = LnurlPayViewState.Error.PayError(result.value) + is Either.Right -> { + val invoice = result.value + state.value = LnurlPayViewState.PayingInvoice(invoice) + sendManager.lnurlPay_payInvoice(payIntent, amount, comment, invoice, fees) + onPaymentSent() + } + } + } + } + + class Factory( + private val sendManager: SendManager + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return LnurlPayViewModel(sendManager) as T + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlWithdrawView.kt similarity index 59% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlWithdrawView.kt index 48dc7a473..bc17a1c18 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlWithdrawView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments +package fr.acinq.phoenix.android.payments.send.lnurl import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -24,10 +24,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString 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.lightning.MilliSatoshi import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business @@ -38,13 +38,14 @@ import fr.acinq.phoenix.android.payments.receive.EvaluateLiquidityIssuesForPayme import fr.acinq.phoenix.android.preferredAmountUnit import fr.acinq.phoenix.android.utils.Converter.toPrettyStringWithFallback import fr.acinq.phoenix.android.utils.annotatedStringResource -import fr.acinq.phoenix.controllers.payments.Scan -import fr.acinq.phoenix.data.lnurl.LnurlError +import fr.acinq.phoenix.android.utils.extensions.toLocalisedMessage +import fr.acinq.phoenix.data.lnurl.LnurlWithdraw +import fr.acinq.phoenix.managers.SendManager @Composable fun LnurlWithdrawView( - model: Scan.Model.LnurlWithdrawFlow, - onWithdrawClick: (Scan.Intent.LnurlWithdrawFlow) -> Unit, + withdraw: LnurlWithdraw, + onBackClick: () -> Unit, onFeeManagementClick: () -> Unit, onWithdrawDone: () -> Unit, ) { @@ -52,14 +53,17 @@ fun LnurlWithdrawView( val prefUnit = preferredAmountUnit val rate = fiatRate - val maxWithdrawable = model.lnurlWithdraw.maxWithdrawable + val maxWithdrawable = withdraw.maxWithdrawable var amount by remember { mutableStateOf(maxWithdrawable) } var amountErrorMessage by remember { mutableStateOf("") } + val vm = viewModel(factory = LnurlWithdrawViewModel.Factory(business.sendManager)) + val withdrawState = vm.state.value + SplashLayout( - header = { DefaultScreenHeader(onBackClick = onWithdrawDone) }, + header = { DefaultScreenHeader(onBackClick = onBackClick) }, topContent = { - Text(text = annotatedStringResource(R.string.lnurl_withdraw_header, model.lnurlWithdraw.initialUrl.host), textAlign = TextAlign.Center) + Text(text = annotatedStringResource(R.string.lnurl_withdraw_header, withdraw.initialUrl.host), textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(16.dp)) AmountHeroInput( initialAmount = maxWithdrawable, @@ -67,33 +71,36 @@ fun LnurlWithdrawView( amountErrorMessage = "" when { newAmount == null -> {} - newAmount.amount < model.lnurlWithdraw.minWithdrawable -> { - amountErrorMessage = context.getString(R.string.lnurl_withdraw_amount_below_min, model.lnurlWithdraw.minWithdrawable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) + newAmount.amount < withdraw.minWithdrawable -> { + amountErrorMessage = context.getString(R.string.lnurl_withdraw_amount_below_min, withdraw.minWithdrawable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) } - newAmount.amount > model.lnurlWithdraw.maxWithdrawable -> { - amountErrorMessage = context.getString(R.string.lnurl_withdraw_amount_above_max, model.lnurlWithdraw.maxWithdrawable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) + newAmount.amount > withdraw.maxWithdrawable -> { + amountErrorMessage = context.getString(R.string.lnurl_withdraw_amount_above_max, withdraw.maxWithdrawable.toPrettyStringWithFallback(prefUnit, rate, withUnit = true)) } } amount = newAmount?.amount }, validationErrorMessage = amountErrorMessage, inputTextSize = 42.sp, - enabled = model.lnurlWithdraw.minWithdrawable != model.lnurlWithdraw.maxWithdrawable - && model is Scan.Model.LnurlWithdrawFlow.LnurlWithdrawRequest, + enabled = withdraw.minWithdrawable != withdraw.maxWithdrawable && withdrawState is LnurlWithdrawViewState.SendingInvoice, ) } ) { SplashLabelRow(label = stringResource(R.string.lnurl_pay_meta_description)) { - Text(text = model.lnurlWithdraw.defaultDescription) + Text(text = withdraw.defaultDescription) } Spacer(Modifier.height(32.dp)) - when (model) { - is Scan.Model.LnurlWithdrawFlow.LnurlWithdrawRequest -> { - val error = model.error - if (error != null && error is Scan.LnurlWithdrawError.RemoteError) { + when (withdrawState) { + is LnurlWithdrawViewState.Init, is LnurlWithdrawViewState.Error -> { + if (withdrawState is LnurlWithdrawViewState.Error) { ErrorMessage( header = stringResource(id = R.string.lnurl_withdraw_error_header), - details = getRemoteErrorMessage(error = error.err), + details = when (withdrawState) { + is LnurlWithdrawViewState.Error.WithdrawError -> when (val error = withdrawState.error) { + is SendManager.LnurlWithdrawError.RemoteError -> error.err.toLocalisedMessage() + } + is LnurlWithdrawViewState.Error.Generic -> withdrawState.cause.localizedMessage ?: withdrawState.cause::class.java.simpleName + }, alignment = Alignment.CenterHorizontally, ) } @@ -103,17 +110,8 @@ fun LnurlWithdrawView( text = if (!mayDoPayments) stringResource(id = R.string.send_connecting_button) else stringResource(id = R.string.lnurl_withdraw_confirm_button), icon = R.drawable.ic_receive, enabled = mayDoPayments && amount != null && amountErrorMessage.isBlank(), - ) { - amount?.let { - onWithdrawClick( - Scan.Intent.LnurlWithdrawFlow.SendLnurlWithdraw( - lnurlWithdraw = model.lnurlWithdraw, - amount = it, - description = model.lnurlWithdraw.defaultDescription - ) - ) - } - } + onClick = { amount?.let { vm.sendInvoice(withdraw, amount = it) } } + ) EvaluateLiquidityIssuesForPayment( amount = amount, @@ -122,12 +120,12 @@ fun LnurlWithdrawView( onDialogShown = {}, ) } - is Scan.Model.LnurlWithdrawFlow.LnurlWithdrawFetch -> { + is LnurlWithdrawViewState.SendingInvoice -> { ProgressView(text = stringResource(id = R.string.lnurl_withdraw_wait)) } - is Scan.Model.LnurlWithdrawFlow.Receiving -> { + is LnurlWithdrawViewState.InvoiceSent -> { Text( - text = annotatedStringResource(id = R.string.lnurl_withdraw_success, model.lnurlWithdraw.callback.host), + text = annotatedStringResource(id = R.string.lnurl_withdraw_success, withdraw.callback.host), textAlign = TextAlign.Center ) Spacer(Modifier.height(12.dp)) @@ -136,17 +134,3 @@ fun LnurlWithdrawView( } } } - -@Composable -fun getRemoteErrorMessage( - error: LnurlError.RemoteFailure -): AnnotatedString { - return when (error) { - is LnurlError.RemoteFailure.Code -> annotatedStringResource(id = R.string.lnurl_error_remote_code, error.origin, error.code.value.toString()) - is LnurlError.RemoteFailure.CouldNotConnect -> annotatedStringResource(id = R.string.lnurl_error_remote_connection, error.origin) - is LnurlError.RemoteFailure.Detailed -> annotatedStringResource(id = R.string.lnurl_error_remote_details, error.origin, error.reason) - is LnurlError.RemoteFailure.Unreadable -> annotatedStringResource(id = R.string.lnurl_error_remote_unreadable, error.origin) - is LnurlError.RemoteFailure.IsWebsite -> TODO() - is LnurlError.RemoteFailure.LightningAddressError -> TODO() - } -} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlWithdrawViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlWithdrawViewModel.kt new file mode 100644 index 000000000..a68fd21f4 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/lnurl/LnurlWithdrawViewModel.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.send.lnurl + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.data.lnurl.LnurlWithdraw +import fr.acinq.phoenix.managers.SendManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + + +sealed class LnurlWithdrawViewState { + data object Init : LnurlWithdrawViewState() + data object SendingInvoice: LnurlWithdrawViewState() + data object InvoiceSent : LnurlWithdrawViewState() + sealed class Error : LnurlWithdrawViewState() { + data class Generic(val cause: Throwable): Error() + data class WithdrawError(val error : SendManager.LnurlWithdrawError) : Error() + } +} + +class LnurlWithdrawViewModel(private val sendManager: SendManager) : ViewModel() { + val log = LoggerFactory.getLogger(this::class.java) + val state = mutableStateOf(LnurlWithdrawViewState.Init) + + fun sendInvoice(withdraw: LnurlWithdraw, amount: MilliSatoshi) { + if (state.value is LnurlWithdrawViewState.SendingInvoice) return + state.value = LnurlWithdrawViewState.SendingInvoice + + viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> + log.error("error in lnurl-withdraw process: ", e) + state.value = LnurlWithdrawViewState.Error.Generic(e) + }) { + val invoice = sendManager.lnurlWithdraw_createInvoice(withdraw, amount = amount, description = withdraw.defaultDescription) + val result = sendManager.lnurlWithdraw_sendInvoice(withdraw, invoice) + if (result == null) { + state.value = LnurlWithdrawViewState.InvoiceSent + } else { + log.error("lnurl-withdraw rejected by service: $result") + state.value = LnurlWithdrawViewState.Error.WithdrawError(result) + } + } + } + + class Factory( + private val sendManager: SendManager + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return LnurlWithdrawViewModel(sendManager) as T + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt similarity index 97% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt index 6e4e583cc..9bb7ecfa1 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments.offer +package fr.acinq.phoenix.android.payments.send.offer import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -49,7 +49,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.TrampolineFees import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R @@ -58,6 +57,7 @@ import fr.acinq.phoenix.android.components.AmountHeroInput import fr.acinq.phoenix.android.components.AmountWithFiatRowView import fr.acinq.phoenix.android.components.BackButtonWithBalance import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.SplashLabelRow @@ -70,17 +70,19 @@ import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString @Composable -fun SendOfferView( +fun SendToOfferView( offer: OfferTypes.Offer, - trampolineFees: TrampolineFees?, onBackClick: () -> Unit, onPaymentSent: () -> Unit, ) { val context = LocalContext.current - val balance = business.balanceManager.balance.collectAsState(null).value val prefBitcoinUnit = LocalBitcoinUnit.current val keyboardManager = LocalSoftwareKeyboardController.current + val balance = business.balanceManager.balance.collectAsState(null).value + val peer by business.peerManager.peerState.collectAsState() + val trampolineFees = peer?.walletParams?.trampolineFees?.firstOrNull() + val vm = viewModel(factory = SendOfferViewModel.Factory(offer, business.peerManager, business.nodeParamsManager, business.contactsManager)) val requestedAmount = offer.amount var amount by remember { mutableStateOf(requestedAmount) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferViewModel.kt similarity index 93% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferViewModel.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferViewModel.kt index 02b71671b..c5246a541 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments.offer +package fr.acinq.phoenix.android.payments.send.offer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,7 +22,6 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import fr.acinq.lightning.Lightning import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.LightningOutgoingPayment @@ -31,15 +30,11 @@ import fr.acinq.lightning.io.PaymentNotSent import fr.acinq.lightning.io.PaymentSent import fr.acinq.lightning.payment.OutgoingPaymentFailure import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.android.PhoenixApplication -import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository -import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.managers.ContactsManager import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.PeerManager import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.slf4j.LoggerFactory import kotlin.time.Duration.Companion.seconds diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/spliceout/SpliceOutView.kt similarity index 99% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/spliceout/SpliceOutView.kt index 502634b50..54948aae5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/spliceout/SpliceOutView.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 ACINQ SAS + * Copyright 2024 ACINQ SAS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments.spliceout +package fr.acinq.phoenix.android.payments.send.spliceout import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/spliceout/SpliceOutViewModel.kt similarity index 98% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/spliceout/SpliceOutViewModel.kt index b4d95caf3..9cc53219f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/spliceout/SpliceOutViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 ACINQ SAS + * Copyright 2024 ACINQ SAS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.payments.spliceout +package fr.acinq.phoenix.android.payments.send.spliceout import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedPin.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedPin.kt index a193e04f5..735aef931 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedPin.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedPin.kt @@ -17,15 +17,13 @@ package fr.acinq.phoenix.android.security import android.content.Context -import fr.acinq.phoenix.android.utils.tryWith -import fr.acinq.secp256k1.Hex +import fr.acinq.phoenix.android.utils.extensions.tryWith import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.security.GeneralSecurityException -import javax.crypto.Cipher import javax.crypto.SecretKey diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt index 7bb4d4f49..68cde5c6c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt @@ -16,14 +16,13 @@ package fr.acinq.phoenix.android.security -import fr.acinq.phoenix.android.utils.tryWith +import fr.acinq.phoenix.android.utils.extensions.tryWith import fr.acinq.secp256k1.Hex import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.security.GeneralSecurityException -import javax.crypto.Cipher import javax.crypto.SecretKey sealed class EncryptedSeed { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppAccessSettings.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppAccessSettings.kt index cece2158e..51f26d955 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppAccessSettings.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppAccessSettings.kt @@ -41,6 +41,7 @@ import fr.acinq.phoenix.android.components.settings.Setting import fr.acinq.phoenix.android.components.settings.SettingSwitch import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.* +import fr.acinq.phoenix.android.utils.extensions.findActivity import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt index 7bb7830e3..aecd6e26f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt @@ -37,8 +37,8 @@ import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.UserTheme import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository -import fr.acinq.phoenix.android.utils.label -import fr.acinq.phoenix.android.utils.labels +import fr.acinq.phoenix.android.utils.extensions.label +import fr.acinq.phoenix.android.utils.extensions.labels import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.managers.AppConfigurationManager @@ -155,4 +155,16 @@ private fun AppLocaleSetting() { }) } ) +} + +@Composable +private fun UserTheme.label(): String { + val context = LocalContext.current + return remember(key1 = this.name) { + when (this) { + UserTheme.DARK -> context.getString(R.string.prefs_display_theme_dark_label) + UserTheme.LIGHT -> context.getString(R.string.prefs_display_theme_light_label) + UserTheme.SYSTEM -> context.getString(R.string.prefs_display_theme_system_label) + } + } } \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt index d51f7ea10..e7dff8ef8 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt @@ -44,6 +44,7 @@ import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.mvi.MVIView import fr.acinq.phoenix.android.components.settings.Setting import fr.acinq.phoenix.android.utils.* +import fr.acinq.phoenix.android.utils.extensions.isBadCertificate import fr.acinq.phoenix.controllers.config.ElectrumConfiguration import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.secp256k1.Hex diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt index 348564b4b..5f9f9926a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt @@ -39,8 +39,8 @@ import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.components.mvi.MVIView -import fr.acinq.phoenix.android.payments.CameraPermissionsView -import fr.acinq.phoenix.android.payments.ScannerView +import fr.acinq.phoenix.android.payments.send.CameraPermissionsView +import fr.acinq.phoenix.android.payments.send.ScannerView import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.monoTypo diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt index cce73ea4a..e397fc764 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt @@ -49,7 +49,7 @@ import fr.acinq.phoenix.android.services.ChannelsWatcher import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString -import fr.acinq.phoenix.android.utils.safeLet +import fr.acinq.phoenix.android.utils.extensions.safeLet import fr.acinq.phoenix.data.Notification import fr.acinq.phoenix.data.WatchTowerOutcome import kotlinx.coroutines.launch diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt index 22e817893..b19f7f368 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt @@ -47,7 +47,6 @@ import fr.acinq.phoenix.android.components.DefaultScreenHeader import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.MenuButton import fr.acinq.phoenix.android.navController -import fr.acinq.phoenix.android.navigate import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.legacy.utils.LegacyAppStatus import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore @@ -66,7 +65,7 @@ fun SettingsView( val notifications = business.notificationsManager.notifications.collectAsState() DefaultScreenLayout { - DefaultScreenHeader(onBackClick = { nc.navigate(Screen.Home) }) { + DefaultScreenHeader(onBackClick = { nc.navigate(Screen.Home.route) }) { Text( text = stringResource(id = R.string.menu_settings), modifier = Modifier @@ -82,47 +81,47 @@ fun SettingsView( // -- general CardHeader(text = stringResource(id = R.string.settings_general_title)) Card { - MenuButton(text = stringResource(R.string.settings_about), icon = R.drawable.ic_help_circle, onClick = { nc.navigate(Screen.About) }) - MenuButton(text = stringResource(R.string.settings_display_prefs), icon = R.drawable.ic_brush, onClick = { nc.navigate(Screen.Preferences) }) - MenuButton(text = stringResource(R.string.settings_payment_settings), icon = R.drawable.ic_tool, onClick = { nc.navigate(Screen.PaymentSettings) }) - MenuButton(text = stringResource(R.string.settings_liquidity_policy), icon = R.drawable.ic_settings, onClick = { nc.navigate(Screen.LiquidityPolicy) }) - MenuButton(text = stringResource(R.string.settings_payment_history), icon = R.drawable.ic_list, onClick = { nc.navigate(Screen.PaymentsHistory) }) - MenuButton(text = stringResource(R.string.settings_contacts), icon = R.drawable.ic_user, onClick = { nc.navigate(Screen.Contacts) }) + MenuButton(text = stringResource(R.string.settings_about), icon = R.drawable.ic_help_circle, onClick = { nc.navigate(Screen.About.route) }) + MenuButton(text = stringResource(R.string.settings_display_prefs), icon = R.drawable.ic_brush, onClick = { nc.navigate(Screen.Preferences.route) }) + MenuButton(text = stringResource(R.string.settings_payment_settings), icon = R.drawable.ic_tool, onClick = { nc.navigate(Screen.PaymentSettings.route) }) + MenuButton(text = stringResource(R.string.settings_liquidity_policy), icon = R.drawable.ic_settings, onClick = { nc.navigate(Screen.LiquidityPolicy.route) }) + MenuButton(text = stringResource(R.string.settings_payment_history), icon = R.drawable.ic_list, onClick = { nc.navigate(Screen.PaymentsHistory.route) }) + MenuButton(text = stringResource(R.string.settings_contacts), icon = R.drawable.ic_user, onClick = { nc.navigate(Screen.Contacts.route) }) MenuButton( text = stringResource(R.string.settings_notifications) + ((notices.size + notifications.value.size).takeIf { it > 0 }?.let { " ($it)"} ?: ""), icon = R.drawable.ic_notification, - onClick = { nc.navigate(Screen.Notifications) } + onClick = { nc.navigate(Screen.Notifications.route) } ) } // -- privacy & security CardHeader(text = stringResource(id = R.string.settings_security_title)) Card { - MenuButton(text = stringResource(R.string.settings_access_control), icon = R.drawable.ic_unlock, onClick = { nc.navigate(Screen.AppLock) }) - MenuButton(text = stringResource(R.string.settings_display_seed), icon = R.drawable.ic_key, onClick = { nc.navigate(Screen.DisplaySeed) }) - MenuButton(text = stringResource(R.string.settings_electrum), icon = R.drawable.ic_chain, onClick = { nc.navigate(Screen.ElectrumServer) }) - MenuButton(text = stringResource(R.string.settings_tor), icon = R.drawable.ic_tor_shield, onClick = { nc.navigate(Screen.TorConfig) }) + MenuButton(text = stringResource(R.string.settings_access_control), icon = R.drawable.ic_unlock, onClick = { nc.navigate(Screen.AppLock.route) }) + MenuButton(text = stringResource(R.string.settings_display_seed), icon = R.drawable.ic_key, onClick = { nc.navigate(Screen.DisplaySeed.route) }) + MenuButton(text = stringResource(R.string.settings_electrum), icon = R.drawable.ic_chain, onClick = { nc.navigate(Screen.ElectrumServer.route) }) + MenuButton(text = stringResource(R.string.settings_tor), icon = R.drawable.ic_tor_shield, onClick = { nc.navigate(Screen.TorConfig.route) }) } // -- advanced CardHeader(text = stringResource(id = R.string.settings_advanced_title)) Card { - MenuButton(text = stringResource(R.string.settings_wallet_info), icon = R.drawable.ic_box, onClick = { nc.navigate(Screen.WalletInfo) }) - MenuButton(text = stringResource(R.string.settings_list_channels), icon = R.drawable.ic_zap, onClick = { nc.navigate(Screen.Channels) }) - MenuButton(text = stringResource(R.string.experimental_title), icon = R.drawable.ic_experimental, onClick = { nc.navigate(Screen.Experimental) }) - MenuButton(text = stringResource(R.string.settings_logs), icon = R.drawable.ic_text, onClick = { nc.navigate(Screen.Logs) }) + MenuButton(text = stringResource(R.string.settings_wallet_info), icon = R.drawable.ic_box, onClick = { nc.navigate(Screen.WalletInfo.route) }) + MenuButton(text = stringResource(R.string.settings_list_channels), icon = R.drawable.ic_zap, onClick = { nc.navigate(Screen.Channels.route) }) + MenuButton(text = stringResource(R.string.experimental_title), icon = R.drawable.ic_experimental, onClick = { nc.navigate(Screen.Experimental.route) }) + MenuButton(text = stringResource(R.string.settings_logs), icon = R.drawable.ic_text, onClick = { nc.navigate(Screen.Logs.route) }) } // -- advanced CardHeader(text = stringResource(id = R.string.settings_danger_title)) Card { - MenuButton(text = stringResource(R.string.settings_mutual_close), icon = R.drawable.ic_cross_circle, onClick = { nc.navigate(Screen.MutualClose) }) - MenuButton(text = stringResource(id = R.string.settings_reset_wallet), icon = R.drawable.ic_trash, onClick = { nc.navigate(Screen.ResetWallet) }) + MenuButton(text = stringResource(R.string.settings_mutual_close), icon = R.drawable.ic_cross_circle, onClick = { nc.navigate(Screen.MutualClose.route) }) + MenuButton(text = stringResource(id = R.string.settings_reset_wallet), icon = R.drawable.ic_trash, onClick = { nc.navigate(Screen.ResetWallet.route) }) MenuButton( text = stringResource(R.string.settings_force_close), textStyle = MaterialTheme.typography.button.copy(color = negativeColor), icon = R.drawable.ic_alert_triangle, iconTint = negativeColor, - onClick = { nc.navigate(Screen.ForceClose) }, + onClick = { nc.navigate(Screen.ForceClose.route) }, ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/displayseed/DisplaySeedView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/displayseed/DisplaySeedView.kt index 229fbc974..dfd0d363a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/displayseed/DisplaySeedView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/displayseed/DisplaySeedView.kt @@ -43,7 +43,7 @@ import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.mutedTextColor -import fr.acinq.phoenix.android.utils.safeLet +import fr.acinq.phoenix.android.utils.extensions.safeLet import kotlinx.coroutines.launch diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/FinalWalletRefundView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/FinalWalletRefundView.kt index 72a1835d9..23b3b18fb 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/FinalWalletRefundView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/FinalWalletRefundView.kt @@ -69,9 +69,8 @@ import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.SplashLabelRow import fr.acinq.phoenix.android.components.TextInput import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import fr.acinq.phoenix.android.components.feedback.SuccessMessage -import fr.acinq.phoenix.android.payments.CameraPermissionsView -import fr.acinq.phoenix.android.payments.ScannerView +import fr.acinq.phoenix.android.payments.send.CameraPermissionsView +import fr.acinq.phoenix.android.payments.send.ScannerView import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.data.MempoolFeerate import fr.acinq.phoenix.utils.Parser diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt index 121aa910b..937dea18e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt @@ -72,8 +72,8 @@ import fr.acinq.phoenix.android.components.InlineTransactionLink import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.feedback.SuccessMessage import fr.acinq.phoenix.android.fiatRate -import fr.acinq.phoenix.android.payments.CameraPermissionsView -import fr.acinq.phoenix.android.payments.ScannerView +import fr.acinq.phoenix.android.payments.send.CameraPermissionsView +import fr.acinq.phoenix.android.payments.send.ScannerView import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.managers.PeerManager diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt index d75d81723..283b9026e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt @@ -77,7 +77,7 @@ import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.BiometricsHelper import fr.acinq.phoenix.android.utils.Logging import fr.acinq.phoenix.android.utils.errorOutlinedTextFieldColors -import fr.acinq.phoenix.android.utils.findActivity +import fr.acinq.phoenix.android.utils.extensions.findActivity import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.outlinedTextFieldColors import fr.acinq.phoenix.android.utils.shareFile diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt index 2ec3f91cb..c8cc2bcd0 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt @@ -42,6 +42,7 @@ import fr.acinq.phoenix.android.LocalTheme import fr.acinq.phoenix.android.Screen import fr.acinq.phoenix.android.isDarkTheme import fr.acinq.phoenix.android.userPrefs +import fr.acinq.phoenix.android.utils.extensions.findActivitySafe // primary for testnet val horizon = Color(0xff91b4d1) @@ -275,7 +276,7 @@ fun systemNavBarColor(entry: NavBackStackEntry?): Color { return when { entry?.destination?.route == Screen.Home.route -> MaterialTheme.colors.surface entry?.destination?.route?.startsWith(Screen.PaymentDetails.route) ?: false -> MaterialTheme.colors.surface - entry?.destination?.route?.startsWith(Screen.ScanData.route) ?: false -> MaterialTheme.colors.surface + entry?.destination?.route?.startsWith(Screen.Send.route, ignoreCase = true) ?: false -> MaterialTheme.colors.surface else -> MaterialTheme.colors.background } } @@ -338,7 +339,7 @@ fun getColor(context: Context, @AttrRes attrRes: Int): Int { } fun updateScreenBrightnesss(context: Context, toMax: Boolean) { - val activity = context.safeFindActivity() ?: return + val activity = context.findActivitySafe() ?: return activity.window.attributes.apply { screenBrightness = if (toMax) 1.0f else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE }.let { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/AndroidContextExtensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/AndroidContextExtensions.kt new file mode 100644 index 000000000..bc9a7d293 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/AndroidContextExtensions.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.utils.extensions + +import android.content.Context +import android.content.ContextWrapper +import fr.acinq.phoenix.android.MainActivity + +fun Context.findActivitySafe(): MainActivity? { + var context = this + while (context is ContextWrapper) { + if (context is MainActivity) return context + context = context.baseContext + } + return null +} + +fun Context.findActivity(): MainActivity { + var context = this + while (context is ContextWrapper) { + if (context is MainActivity) return context + context = context.baseContext + } + throw IllegalStateException("not in the context of the main Phoenix activity") +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/CurrencyExtensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/CurrencyExtensions.kt new file mode 100644 index 000000000..66a3347d7 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/CurrencyExtensions.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.utils.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.FiatCurrency +import java.util.Currency + +@Composable +fun BitcoinUnit.label(): String = when (this) { + BitcoinUnit.Sat -> stringResource(id = R.string.prefs_display_coin_sat_label) + BitcoinUnit.Bit -> stringResource(id = R.string.prefs_display_coin_bit_label) + BitcoinUnit.MBtc -> stringResource(id = R.string.prefs_display_coin_mbtc_label) + BitcoinUnit.Btc -> stringResource(id = R.string.prefs_display_coin_btc_label) +} + +@Composable +fun FiatCurrency.labels(): Pair { + val context = LocalContext.current + return remember(key1 = displayCode) { + val fullName = when { + // use the free market rates as default. Name for official rates gets a special tag, as those rates are usually inaccurate. + this == FiatCurrency.ARS -> context.getString(R.string.currency_ars_official) + this == FiatCurrency.ARS_BM -> context.getString(R.string.currency_ars_bm) + this == FiatCurrency.CUP -> context.getString(R.string.currency_cup_official) + this == FiatCurrency.CUP_FM -> context.getString(R.string.currency_cup_fm) + this == FiatCurrency.LBP -> context.getString(R.string.currency_lbp_official) + this == FiatCurrency.LBP_BM -> context.getString(R.string.currency_lbp_bm) + // use the JVM API otherwise to get the name + displayCode.length == 3 -> try { + Currency.getInstance(displayCode).displayName + } catch (e: Exception) { + "N/A" + } + else -> "N/A" + } + "$flag $displayCode" to fullName + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/ErrorExtensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/ErrorExtensions.kt new file mode 100644 index 000000000..c27db5ee8 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/ErrorExtensions.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.utils.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import fr.acinq.lightning.utils.Connection +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.data.lnurl.LnurlError +import fr.acinq.phoenix.managers.SendManager +import java.security.cert.CertificateException + +fun Connection.CLOSED.isBadCertificate() = this.reason?.cause is CertificateException + +@Composable +fun SendManager.ParseResult.BadRequest.toLocalisedMessage(): String { + return when (val reason = this.reason) { + is SendManager.BadRequestReason.Expired -> stringResource(R.string.send_error_invoice_expired) + is SendManager.BadRequestReason.ChainMismatch -> stringResource(R.string.send_error_invalid_chain) + is SendManager.BadRequestReason.AlreadyPaidInvoice -> stringResource(R.string.send_error_already_paid) + is SendManager.BadRequestReason.ServiceError -> reason.error.toLocalisedMessage().text + is SendManager.BadRequestReason.InvalidLnurl -> stringResource(R.string.send_error_lnurl_invalid) + is SendManager.BadRequestReason.UnsupportedLnurl -> stringResource(R.string.send_error_lnurl_unsupported) + is SendManager.BadRequestReason.UnknownFormat -> stringResource(R.string.send_error_invalid_generic) + is SendManager.BadRequestReason.Bip353NameNotFound -> stringResource(id = R.string.send_error_bip353_name_not_found, reason.username, reason.domain) + is SendManager.BadRequestReason.Bip353InvalidUri -> stringResource(id = R.string.send_error_bip353_invalid_uri) + is SendManager.BadRequestReason.Bip353InvalidOffer -> stringResource(id = R.string.send_error_bip353_invalid_offer) + is SendManager.BadRequestReason.Bip353NoDNSSEC -> stringResource(id = R.string.send_error_bip353_dnssec) + } +} + +@Composable +fun LnurlError.RemoteFailure.toLocalisedMessage(): AnnotatedString { + return when (this) { + is LnurlError.RemoteFailure.Code -> annotatedStringResource(id = R.string.lnurl_error_remote_code, origin, code.value.toString()) + is LnurlError.RemoteFailure.CouldNotConnect -> annotatedStringResource(id = R.string.lnurl_error_remote_connection, origin) + is LnurlError.RemoteFailure.Detailed -> annotatedStringResource(id = R.string.lnurl_error_remote_details, origin, reason) + is LnurlError.RemoteFailure.Unreadable -> annotatedStringResource(id = R.string.lnurl_error_remote_unreadable, origin) + is LnurlError.RemoteFailure.IsWebsite -> annotatedStringResource(id = R.string.lnurl_error_remote_is_website, origin) + is LnurlError.RemoteFailure.LightningAddressError -> annotatedStringResource(id = R.string.lnurl_error_remote_unknown_lnaddress, origin) + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/PaymentExtensions.kt similarity index 51% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/PaymentExtensions.kt index 7dbb18673..ee0f7ac5c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/PaymentExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 ACINQ SAS + * Copyright 2024 ACINQ SAS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,114 +14,22 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.utils +package fr.acinq.phoenix.android.utils.extensions -import android.content.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import fr.acinq.lightning.db.* -import fr.acinq.lightning.utils.Connection -import fr.acinq.phoenix.android.* +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.lightning.db.WalletPayment import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.data.BitcoinUnit -import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.utils.extensions.desc import fr.acinq.phoenix.utils.extensions.isManualPurchase -import java.security.cert.CertificateException -import java.util.* -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract - -/** - * Utility method rebinding any exceptions thrown by a method into another exception, using the origin exception as the root cause. - * Helps with pattern matching. - */ -inline fun tryWith(exception: Exception, action: () -> T): T = try { - action.invoke() -} catch (t: Exception) { - exception.initCause(t) - throw exception -} - -inline fun safeLet(p1: T1?, p2: T2?, block: (T1, T2) -> R?): R? { - return if (p1 != null && p2 != null) block(p1, p2) else null -} - -@OptIn(ExperimentalContracts::class) -inline fun T.ifLet(block: (T) -> R): R { - contract { - callsInPlace(block, InvocationKind.EXACTLY_ONCE) - } - return block(this) -} - -fun Context.safeFindActivity(): MainActivity? { - var context = this - while (context is ContextWrapper) { - if (context is MainActivity) return context - context = context.baseContext - } - return null -} - -fun Context.findActivity(): MainActivity { - var context = this - while (context is ContextWrapper) { - if (context is MainActivity) return context - context = context.baseContext - } - throw IllegalStateException("not in the context of the main Phoenix activity") -} - -@Composable -fun BitcoinUnit.label(): String = when (this) { - BitcoinUnit.Sat -> stringResource(id = R.string.prefs_display_coin_sat_label) - BitcoinUnit.Bit -> stringResource(id = R.string.prefs_display_coin_bit_label) - BitcoinUnit.MBtc -> stringResource(id = R.string.prefs_display_coin_mbtc_label) - BitcoinUnit.Btc -> stringResource(id = R.string.prefs_display_coin_btc_label) -} - -@Composable -fun FiatCurrency.labels(): Pair { - val context = LocalContext.current - return remember(key1 = displayCode) { - val fullName = when { - // use the free market rates as default. Name for official rates gets a special tag, as those rates are usually inaccurate. - this == FiatCurrency.ARS -> context.getString(R.string.currency_ars_official) - this == FiatCurrency.ARS_BM -> context.getString(R.string.currency_ars_bm) - this == FiatCurrency.CUP -> context.getString(R.string.currency_cup_official) - this == FiatCurrency.CUP_FM -> context.getString(R.string.currency_cup_fm) - this == FiatCurrency.LBP -> context.getString(R.string.currency_lbp_official) - this == FiatCurrency.LBP_BM -> context.getString(R.string.currency_lbp_bm) - // use the JVM API otherwise to get the name - displayCode.length == 3 -> try { - Currency.getInstance(displayCode).displayName - } catch (e: Exception) { - "N/A" - } - else -> "N/A" - } - "$flag $displayCode" to fullName - } -} - -@Composable -fun UserTheme.label(): String { - val context = LocalContext.current - return remember(key1 = this.name) { - when (this) { - UserTheme.DARK -> context.getString(R.string.prefs_display_theme_dark_label) - UserTheme.LIGHT -> context.getString(R.string.prefs_display_theme_light_label) - UserTheme.SYSTEM -> context.getString(R.string.prefs_display_theme_system_label) - } - } -} - -fun Connection.CLOSED.isBadCertificate() = this.reason?.cause is CertificateException @Composable fun LightningOutgoingPayment.smartDescription(): String? = when (val details = this.details) { @@ -183,4 +91,4 @@ fun WalletPayment.basicDescription(): String? = when (this) { is SpliceOutgoingPayment -> null is SpliceCpfpOutgoingPayment -> null is InboundLiquidityOutgoingPayment -> null -}?.takeIf { it.isNotBlank() } \ No newline at end of file +}?.takeIf { it.isNotBlank() } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/TechnicalExtensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/TechnicalExtensions.kt new file mode 100644 index 000000000..5602dac8c --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions/TechnicalExtensions.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.utils.extensions + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Utility method rebinding any exceptions thrown by a method into another exception, using the origin exception as the root cause. + * Helps with pattern matching. + */ +inline fun tryWith(exception: Exception, action: () -> T): T = try { + action.invoke() +} catch (t: Exception) { + exception.initCause(t) + throw exception +} + +inline fun safeLet(p1: T1?, p2: T2?, block: (T1, T2) -> R?): R? { + return if (p1 != null && p2 != null) block(p1, p2) else null +} + +@OptIn(ExperimentalContracts::class) +inline fun T.ifLet(block: (T) -> R): R { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return block(this) +} \ No newline at end of file diff --git a/phoenix-android/src/main/res/drawable/ic_copy.xml b/phoenix-android/src/main/res/drawable/ic_copy.xml index f5c3455b9..f107554ea 100644 --- a/phoenix-android/src/main/res/drawable/ic_copy.xml +++ b/phoenix-android/src/main/res/drawable/ic_copy.xml @@ -22,14 +22,14 @@ diff --git a/phoenix-android/src/main/res/drawable/ic_image.xml b/phoenix-android/src/main/res/drawable/ic_image.xml index c4cc228a1..1d330488a 100644 --- a/phoenix-android/src/main/res/drawable/ic_image.xml +++ b/phoenix-android/src/main/res/drawable/ic_image.xml @@ -1,6 +1,6 @@ + + + + + + diff --git a/phoenix-android/src/main/res/drawable/ic_sad.xml b/phoenix-android/src/main/res/drawable/ic_sad.xml new file mode 100644 index 000000000..bc8ee5115 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_sad.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/phoenix-android/src/main/res/drawable/ic_user_search.xml b/phoenix-android/src/main/res/drawable/ic_user_search.xml index cb8c6766d..21a6a92c2 100644 --- a/phoenix-android/src/main/res/drawable/ic_user_search.xml +++ b/phoenix-android/src/main/res/drawable/ic_user_search.xml @@ -29,7 +29,7 @@ diff --git a/phoenix-android/src/main/res/layout/scan_view.xml b/phoenix-android/src/main/res/layout/scan_view.xml index c822ff9b6..409b4dd7d 100644 --- a/phoenix-android/src/main/res/layout/scan_view.xml +++ b/phoenix-android/src/main/res/layout/scan_view.xml @@ -21,8 +21,8 @@ diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml index b5b31e370..92d20293b 100644 --- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml @@ -161,16 +161,16 @@ - Esta factura está vencida. - Este pago ya se realizó. - Este pago no usa la misma cadena de bloques de la billetera. - Error al procesar este enlace LNURL. Comprueba que sea válido. - Este tipo de LNURL aún no es compatible. - Estos datos usan un formato desconocido por lo que no se pueden procesar. - El nombre \"%1$s\" no se encuentra en \"%2$s\". - Esta dirección utiliza un recurso Bip21 no válido. - Esta dirección utiliza una oferta Bolt12 no válida. - Esta dirección está alojada en un DNS no seguro. DNSSEC debe estar habilitado. + Esta factura está vencida. + Este pago ya se realizó. + Este pago no usa la misma cadena de bloques de la billetera. + Error al procesar este enlace LNURL. Comprueba que sea válido. + Este tipo de LNURL aún no es compatible. + Estos datos usan un formato desconocido por lo que no se pueden procesar. + El nombre \"%1$s\" no se encuentra en \"%2$s\". + Esta dirección utiliza un recurso Bip21 no válido. + Esta dirección utiliza una oferta Bolt12 no válida. + Esta dirección está alojada en un DNS no seguro. DNSSEC debe estar habilitado. diff --git a/phoenix-android/src/main/res/values-b+es+419/strings.xml b/phoenix-android/src/main/res/values-b+es+419/strings.xml index e7a1cfd3d..17d43ec0d 100644 --- a/phoenix-android/src/main/res/values-b+es+419/strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/strings.xml @@ -166,7 +166,7 @@ Tocar para conceder permiso a la cámara Se negó el permiso a la cámara - Obteniendo datos del servicio… + Obteniendo datos del servicio… Ingreso manual Ingresa una factura de Lightning, LNURL o dirección de Lightning a la que quieras enviar dinero. diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml index e33bf4de9..5e71bbb2e 100644 --- a/phoenix-android/src/main/res/values-cs/important_strings.xml +++ b/phoenix-android/src/main/res/values-cs/important_strings.xml @@ -157,16 +157,16 @@ - Platnost této faktury vypršela. - Tato platba již byla uhrazena. - Tato platba nepoužívá stejný blockchain jako vaše peněženka. - Tento LNURL odkaz se nepodařilo zpracovat. Ujistěte se, že je platný. - Tento typ LNURL zatím není podporován. - Tato data používají neznámý formát a nelze je zpracovat. - Název \"%1$s\" nebyl nalezen na \"%2$s\". - Tato adresa používá neplatný zdroj Bip21. - Tato adresa používá neplatnou nabídku Bolt12. - Tato adresa je hostována na nezabezpečeném DNS. DNSSEC musí být povolen. + Platnost této faktury vypršela. + Tato platba již byla uhrazena. + Tato platba nepoužívá stejný blockchain jako vaše peněženka. + Tento LNURL odkaz se nepodařilo zpracovat. Ujistěte se, že je platný. + Tento typ LNURL zatím není podporován. + Tato data používají neznámý formát a nelze je zpracovat. + Název \"%1$s\" nebyl nalezen na \"%2$s\". + Tato adresa používá neplatný zdroj Bip21. + Tato adresa používá neplatnou nabídku Bolt12. + Tato adresa je hostována na nezabezpečeném DNS. DNSSEC musí být povolen. diff --git a/phoenix-android/src/main/res/values-cs/strings.xml b/phoenix-android/src/main/res/values-cs/strings.xml index a4ab410fb..e62f6ab0e 100644 --- a/phoenix-android/src/main/res/values-cs/strings.xml +++ b/phoenix-android/src/main/res/values-cs/strings.xml @@ -161,8 +161,8 @@ Klepnutím udělíte povolení k fotoaparátu Povolení k použití fotoaparátu bylo zamítnuto - Získávání dat ze služby… - Zpracovávání žádosti o platbu přes DNS… + Získávání dat ze služby… + Zpracovávání žádosti o platbu přes DNS… Ruční vstup Zadejte Lightningovou fakturu, LNURL nebo Lightningovou adresu na kterou chcete poslat peníze. diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml index b6b709328..0878933d8 100644 --- a/phoenix-android/src/main/res/values-de/important_strings.xml +++ b/phoenix-android/src/main/res/values-de/important_strings.xml @@ -160,16 +160,16 @@ - Die Rechnung ist abgelaufen. - Diese Zahlung wurde bereits getätigt. - Diese Zahlung verwendet nicht die gleiche Blockchain wie Ihre Wallet. - Dieser LNURL-Link konnte nicht verarbeitet werden. Stellen Sie sicher, dass er gültig ist. - Dieser LNURL-Typ wird noch nicht unterstützt. - Diese Daten haben ein unbekanntes Format und können nicht verarbeitet werden. - Name \"%1$s\" wird auf \"%2$s\" nicht gefunden. - Diese Adresse verwendet eine ungültige Bip21 Ressource. - Diese Adresse verwendet ein ungültiges Bolt12-Angebot. - Diese Adresse wird über einen unsicheren DNS gehostet. DNSSEC muss aktiviert sein. + Die Rechnung ist abgelaufen. + Diese Zahlung wurde bereits getätigt. + Diese Zahlung verwendet nicht die gleiche Blockchain wie Ihre Wallet. + Dieser LNURL-Link konnte nicht verarbeitet werden. Stellen Sie sicher, dass er gültig ist. + Dieser LNURL-Typ wird noch nicht unterstützt. + Diese Daten haben ein unbekanntes Format und können nicht verarbeitet werden. + Name \"%1$s\" wird auf \"%2$s\" nicht gefunden. + Diese Adresse verwendet eine ungültige Bip21 Ressource. + Diese Adresse verwendet ein ungültiges Bolt12-Angebot. + Diese Adresse wird über einen unsicheren DNS gehostet. DNSSEC muss aktiviert sein. diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml index 9bf092f71..673215f55 100644 --- a/phoenix-android/src/main/res/values-de/strings.xml +++ b/phoenix-android/src/main/res/values-de/strings.xml @@ -168,7 +168,7 @@ Tippen, um Kamerazugriff zu erlauben Kamerazugriff wurde abgelehnt - Daten werden vom Service geholt… + Daten werden vom Service geholt… Manuelle Eingabe Geben Sie eine Lightning-Rechnung, LNURL oder Lightning-Adresse ein, an die Sie etwas senden möchten. diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml index 545a62af7..766f1bd64 100644 --- a/phoenix-android/src/main/res/values-es/important_strings.xml +++ b/phoenix-android/src/main/res/values-es/important_strings.xml @@ -164,16 +164,16 @@ - Esta factura ha caducado. - Este pago ya ha sido abonado. - Este pago no utiliza la misma blockchain que su cartera. - No se ha podido procesar este enlace LNURL. Asegúrese de que es válido. - Este tipo de LNURL aún no se admite. - Estos datos utilizan un formato desconocido y no pueden ser procesados. - El nombre \"%1$s\" no se encuentra en \"%2$s\". - Esta dirección utiliza un recurso Bip21 no válido. - Esta dirección utiliza una oferta Bolt12 no válida. - Esta dirección está alojada en un DNS no seguro. DNSSEC debe estar habilitado. + Esta factura ha caducado. + Este pago ya ha sido abonado. + Este pago no utiliza la misma blockchain que su cartera. + No se ha podido procesar este enlace LNURL. Asegúrese de que es válido. + Este tipo de LNURL aún no se admite. + Estos datos utilizan un formato desconocido y no pueden ser procesados. + El nombre \"%1$s\" no se encuentra en \"%2$s\". + Esta dirección utiliza un recurso Bip21 no válido. + Esta dirección utiliza una oferta Bolt12 no válida. + Esta dirección está alojada en un DNS no seguro. DNSSEC debe estar habilitado. diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml index f6e51226a..67ea17898 100644 --- a/phoenix-android/src/main/res/values-fr/important_strings.xml +++ b/phoenix-android/src/main/res/values-fr/important_strings.xml @@ -164,16 +164,16 @@ - Cette requête a expiré. - Ce paiement a déjà été réglé. - Ce paiement n\'utilise pas la même blockchain que votre wallet. - Ce lien LNURL n\'a pas pu être traité. Assurez-vous qu\'il soit valide. - Ce type de lien LNURL n\'est pas supporté. - Ce contenu est mal formatté ou bien n\'est pas géré par Phoenix. - Le nom \"%1$s\" est introuvable sur \"%2$s\" - Cette adresse utilise une ressource Bip21 non valide. - Cette adresse utilise une offre Bolt12 invalide. - Cette adresse est hébergée sur un DNS non sécurisé. DNSSEC doit être activé. + Cette requête a expiré. + Ce paiement a déjà été réglé. + Ce paiement n\'utilise pas la même blockchain que votre wallet. + Ce lien LNURL n\'a pas pu être traité. Assurez-vous qu\'il soit valide. + Ce type de lien LNURL n\'est pas supporté. + Ce contenu est mal formatté ou bien n\'est pas géré par Phoenix. + Le nom \"%1$s\" est introuvable sur \"%2$s\" + Cette adresse utilise une ressource Bip21 non valide. + Cette adresse utilise une offre Bolt12 invalide. + Cette adresse est hébergée sur un DNS non sécurisé. DNSSEC doit être activé. diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml index f930f9221..6936980c9 100644 --- a/phoenix-android/src/main/res/values-fr/strings.xml +++ b/phoenix-android/src/main/res/values-fr/strings.xml @@ -159,8 +159,8 @@ Autoriser l\'accès à la caméra L\'accès à la caméra a été refusé - Récupération des données auprès du service… - Résolution de la requête de paiement via DNS… + Récupération des données auprès du service… + Résolution de la requête de paiement via DNS… Saisie manuelle Saisissez une requête de paiement Lightning, un LNURL, ou une adresse Ligthning que vous souhaitez payer. diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml index c28165b54..48c814128 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml @@ -162,16 +162,16 @@ - Esta fatura está expirada. - Este pagamento já foi pago. - Este pagamento não usa o mesmo blockchain que sua carteira. - Falha ao processar esse link LNURL. Verifique se ele é válido. - Este tipo de LNURL ainda não é suportado. - Esses dados usam um formato desconhecido e não podem ser processados. - O nome \"%1$s\" não foi encontrado em \"%2$s\". - Este endereço usa um recurso Bip21 inválido. - Este endereço usa uma oferta Bolt12 inválida. - Este endereço está hospedado em um DNS não seguro. O DNSSEC deve estar ativado. + Esta fatura está expirada. + Este pagamento já foi pago. + Este pagamento não usa o mesmo blockchain que sua carteira. + Falha ao processar esse link LNURL. Verifique se ele é válido. + Este tipo de LNURL ainda não é suportado. + Esses dados usam um formato desconhecido e não podem ser processados. + O nome \"%1$s\" não foi encontrado em \"%2$s\". + Este endereço usa um recurso Bip21 inválido. + Este endereço usa uma oferta Bolt12 inválida. + Este endereço está hospedado em um DNS não seguro. O DNSSEC deve estar ativado. diff --git a/phoenix-android/src/main/res/values-pt-rBR/strings.xml b/phoenix-android/src/main/res/values-pt-rBR/strings.xml index 541ec3b6f..17f970c11 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/strings.xml @@ -164,7 +164,7 @@ Toque para conceder permissão de câmera Permissão de câmera negada - Obtendo dados do serviço… + Obtendo dados do serviço… Entrada manual Insira uma fatura Lightning, LNURL ou endereço Lightning para o qual deseja enviar dinheiro. diff --git a/phoenix-android/src/main/res/values-sk/important_strings.xml b/phoenix-android/src/main/res/values-sk/important_strings.xml index 133f6c3d3..bd84892b0 100644 --- a/phoenix-android/src/main/res/values-sk/important_strings.xml +++ b/phoenix-android/src/main/res/values-sk/important_strings.xml @@ -163,16 +163,16 @@ - Platnosť tejto faktúry vypršala. - Táto platba už bola uhradená. - Táto platba nepoužíva ten istý blockchain ako vaša peňaženka. - Tento LNURL odkaz sa nepodarilo spracovať. Uistite sa, že je platný. - Tento typ LNURL zatiaľ nie je podporovaný. - Tieto údaje používajú neznámy formát a nemožno ich spracovať. - Názov \"%1$s\" sa nenašiel na \"%2$s\". - Táto adresa používa neplatný zdroj Bip21. - Táto adresa používa neplatnú ponuku Bolt12. - Táto adresa je umiestnená na nezabezpečenom DNS. DNSSEC musí byť povolený. + Platnosť tejto faktúry vypršala. + Táto platba už bola uhradená. + Táto platba nepoužíva ten istý blockchain ako vaša peňaženka. + Tento LNURL odkaz sa nepodarilo spracovať. Uistite sa, že je platný. + Tento typ LNURL zatiaľ nie je podporovaný. + Tieto údaje používajú neznámy formát a nemožno ich spracovať. + Názov \"%1$s\" sa nenašiel na \"%2$s\". + Táto adresa používa neplatný zdroj Bip21. + Táto adresa používa neplatnú ponuku Bolt12. + Táto adresa je umiestnená na nezabezpečenom DNS. DNSSEC musí byť povolený. diff --git a/phoenix-android/src/main/res/values-sk/strings.xml b/phoenix-android/src/main/res/values-sk/strings.xml index 90986f0cf..889fa286f 100644 --- a/phoenix-android/src/main/res/values-sk/strings.xml +++ b/phoenix-android/src/main/res/values-sk/strings.xml @@ -182,7 +182,7 @@ Kliknite a povoľte prístup ku kamere Povolenie používať kameru bolo zamietnuté - Získavanie dát zo služby… + Získavanie dát zo služby… Manuálne zadanie Zadajte Lightning faktúru, LNURL alebo Lightning adresu, na ktorú chcete poslať peniaze. diff --git a/phoenix-android/src/main/res/values-sw/important_strings.xml b/phoenix-android/src/main/res/values-sw/important_strings.xml index ce792d85b..8f2fea9c0 100644 --- a/phoenix-android/src/main/res/values-sw/important_strings.xml +++ b/phoenix-android/src/main/res/values-sw/important_strings.xml @@ -166,16 +166,16 @@ - Ankara hii imeisha muda wake. - Malipo haya tayari yamelipwa. - Malipo haya hayatumii mnyororo sawa na pochi yako. - Imeshindwa kuchakata kiungo hiki cha LNURL. Hakikisha ni halali. - Aina hii ya LNURL haijaungwa mkono bado. - Data hii inatumia muundo usiojulikana na haiwezi kuchakatwa. - Jina \"%1$s\" halijapatikana kwenye \"%2$s\". - Anwani hii inatumia rasilimali ya Bip21 isiyo halali. - Anwani hii inatumia ombi batili la Bolt12. - Anwani hii inashikiliwa kwenye DNS isiyo salama. DNSSEC lazima iwezeshwe. + Ankara hii imeisha muda wake. + Malipo haya tayari yamelipwa. + Malipo haya hayatumii mnyororo sawa na pochi yako. + Imeshindwa kuchakata kiungo hiki cha LNURL. Hakikisha ni halali. + Aina hii ya LNURL haijaungwa mkono bado. + Data hii inatumia muundo usiojulikana na haiwezi kuchakatwa. + Jina \"%1$s\" halijapatikana kwenye \"%2$s\". + Anwani hii inatumia rasilimali ya Bip21 isiyo halali. + Anwani hii inatumia ombi batili la Bolt12. + Anwani hii inashikiliwa kwenye DNS isiyo salama. DNSSEC lazima iwezeshwe. diff --git a/phoenix-android/src/main/res/values-sw/strings.xml b/phoenix-android/src/main/res/values-sw/strings.xml index 37395a3d3..37e04cf51 100644 --- a/phoenix-android/src/main/res/values-sw/strings.xml +++ b/phoenix-android/src/main/res/values-sw/strings.xml @@ -196,8 +196,8 @@ Gonga ili kutoa ruhusa ya kamera Ruhusa ya kamera imekana - Inapata data kutoka huduma… - Inapata ombi la malipo kupitia DNS… + Inapata data kutoka huduma… + Inapata ombi la malipo kupitia DNS… Ingiza kwa mkono Ingiza hati ya Lightning, ofa, LNURL au anwani ya Lightning unayotaka kutuma pesa kwake. diff --git a/phoenix-android/src/main/res/values-vi/important_strings.xml b/phoenix-android/src/main/res/values-vi/important_strings.xml index 896773408..0c50454f7 100644 --- a/phoenix-android/src/main/res/values-vi/important_strings.xml +++ b/phoenix-android/src/main/res/values-vi/important_strings.xml @@ -170,16 +170,16 @@ - Hoá đơn này đã hết hạn. - Khoản thanh toán này đã được trả. - Khoản thanh toán này không sử dụng cùng blockchain với ví của bạn. - Không thể xử lý liên kết LNURL này. Hãy đảm bảo liên kết này có hiệu lực. - Loại LNURL này chưa được hỗ trợ. - Dữ liệu này sử dụng định dạng không xác định và không thể xử lý được. - Không tìm thấy tên \"%1$s\" trên \"%2$s\". - Địa chỉ này sử dụng tài nguyên Bip21 không hợp lệ. - Địa chỉ này sử dụng ưu đãi Bolt12 không hợp lệ. - Địa chỉ này được lưu trữ trên một DNS không an toàn. DNSSEC phải được bật. + Hoá đơn này đã hết hạn. + Khoản thanh toán này đã được trả. + Khoản thanh toán này không sử dụng cùng blockchain với ví của bạn. + Không thể xử lý liên kết LNURL này. Hãy đảm bảo liên kết này có hiệu lực. + Loại LNURL này chưa được hỗ trợ. + Dữ liệu này sử dụng định dạng không xác định và không thể xử lý được. + Không tìm thấy tên \"%1$s\" trên \"%2$s\". + Địa chỉ này sử dụng tài nguyên Bip21 không hợp lệ. + Địa chỉ này sử dụng ưu đãi Bolt12 không hợp lệ. + Địa chỉ này được lưu trữ trên một DNS không an toàn. DNSSEC phải được bật. diff --git a/phoenix-android/src/main/res/values-vi/strings.xml b/phoenix-android/src/main/res/values-vi/strings.xml index 2edaf3c67..f87cfd680 100644 --- a/phoenix-android/src/main/res/values-vi/strings.xml +++ b/phoenix-android/src/main/res/values-vi/strings.xml @@ -169,7 +169,7 @@ Nhấn để cho phép camera hoạt động Quyền hoạt động của camera đã bị từ chối - Đang lấy dữ liệu từ bên dịch vụ… + Đang lấy dữ liệu từ bên dịch vụ… Nhập bằng tay Nhập hóa đơn Lightning, LNURL hoặc địa chỉ Lightning bạn muốn gửi tiền tới. diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index 6ffd5a152..f93cf7597 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -164,18 +164,18 @@ Phoenix cannot receive payments in the background. To avoid payment failures, use Orbot instead, or make sure Phoenix remain visible in the foreground when payments are sent to you. - - - This invoice is expired. - This payment has already been paid. - This payment does not use the same blockchain as your wallet. - Failed to process this LNURL link. Make sure it is valid. - This type of LNURL is not supported yet. - This data uses an unknown format and cannot be processed. - Name \"%1$s\" is not found on \"%2$s\". - This address uses an invalid Bip21 resource. - This address uses an invalid Bolt12 offer. - This address is hosted on an unsecure DNS. DNSSEC must be enabled. + + + This invoice is expired. + This payment has already been paid. + This payment does not use the same blockchain as your wallet. + Failed to process this LNURL link. Make sure it is valid. + This type of LNURL is not supported yet. + This is not a supported payment request. + Name \"%1$s\" is not found on \"%2$s\". + This address uses an invalid Bip21 resource. + This address uses an invalid Bolt12 offer. + This address is hosted on an unsecure DNS. DNSSEC must be enabled. diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 30295df29..d0ead3e85 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -193,18 +193,25 @@ - Manual input - Paste data from clipboard Tap to grant camera permission Camera permission has been denied - Fetching data from service… - Resolving payment request over DNS… + - Manual input - Enter a Lightning invoice, an offer, a LNURL or a Lightning address you want to send money to. - Invoice, LN address… - satoshi@domain.com, bc1q..., lnbc... + + Send + No contacts yet… + No matches for search… + satoshi@domain, bc1q..., lnbc... + + Paste + Scan QR code + Choose image + This image could not be processed. + No QR code found in this image. + Reading input… + Fetching data from service… + Resolving payment request over DNS… @@ -565,6 +572,8 @@ The service %1$s returned an HTTP error (%2$s). Contact their helpdesk if needed. The service %1$s returned a malformed message. Could not connect to service %1$s. + This appears to be a website (not a lightning invoice):\n\n%1$s + Service %1$s doesn\'t support lightning addresses, or doesn\'t know this user. @@ -575,6 +584,7 @@ You can attach a message to the payment. This message will be sent to the recipient. Pay Requesting invoice… + Paying invoice… Amount must be at least %1$s Amount must be at most %1$s diff --git a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt index 9c8eeeaf3..7643272e5 100644 --- a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt +++ b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt @@ -271,7 +271,7 @@ class LegacyMigrationHelperTest { amountReceived = 55_000.msat, channelId = ByteVector32.Zeroes, htlcId = 0, - fundingFee = null + fundingFee = null, ) ), receivedAt = 1656337800788 @@ -294,7 +294,7 @@ class LegacyMigrationHelperTest { amountReceived = 350_000.msat, channelId = ByteVector32.Zeroes, htlcId = 0, - fundingFee = null + fundingFee = null, ) ), receivedAt = 1656338085497 diff --git a/phoenix-ios/phoenix-ios/MVI/MVI+Mock.swift b/phoenix-ios/phoenix-ios/MVI/MVI+Mock.swift index c14852d0f..368eea128 100644 --- a/phoenix-ios/phoenix-ios/MVI/MVI+Mock.swift +++ b/phoenix-ios/phoenix-ios/MVI/MVI+Mock.swift @@ -27,9 +27,6 @@ extension View { func mock(_ mock: RestoreWallet.Model) -> some View { environment(\.controllerFactory, MockControllerFactory(mock)) } - func mock(_ mock: Scan.Model) -> some View { - environment(\.controllerFactory, MockControllerFactory(mock)) - } } class MockControllerFactory : ControllerFactory { @@ -139,16 +136,5 @@ class MockControllerFactory : ControllerFactory { return base.restoreWallet() } } - - var mock_scan: Scan.Model? = nil - init(_ mock: Scan.Model) { - mock_scan = mock - } - func scan(firstModel: Scan.Model) -> MVIController { - if let mock = mock_scan { - return MVIControllerMock(model: mock) - } else { - return base.scan(firstModel: firstModel) - } - } + } diff --git a/phoenix-ios/phoenix-ios/MVI/MVI.swift b/phoenix-ios/phoenix-ios/MVI/MVI.swift index f5a9e0a89..f058dfacc 100644 --- a/phoenix-ios/phoenix-ios/MVI/MVI.swift +++ b/phoenix-ios/phoenix-ios/MVI/MVI.swift @@ -268,8 +268,4 @@ class FakeControllerFactory: ControllerFactory { fatalError("Missing @Environment: ControllerFactory") } - func scan(firstModel: Scan.Model) -> - MVIController { - fatalError("Missing @Environment: ControllerFactory") - } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt index 32271ecd2..578a58f4a 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt @@ -30,8 +30,6 @@ import fr.acinq.phoenix.controllers.init.AppRestoreWalletController import fr.acinq.phoenix.controllers.main.AppContentController import fr.acinq.phoenix.controllers.main.AppHomeController import fr.acinq.phoenix.controllers.payments.AppReceiveController -import fr.acinq.phoenix.controllers.payments.AppScanController -import fr.acinq.phoenix.controllers.payments.Scan import fr.acinq.phoenix.data.StartupParams import fr.acinq.phoenix.db.SqliteAppDb import fr.acinq.phoenix.db.createAppDbDriver @@ -160,9 +158,6 @@ class PhoenixBusiness( override fun receive(): ReceiveController = AppReceiveController(_this) - override fun scan(firstModel: Scan.Model): ScanController = - AppScanController(_this, firstModel) - override fun restoreWallet(): RestoreWalletController = AppRestoreWalletController(_this) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/ControllerFactory.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/ControllerFactory.kt index ab9f8966a..e20fad098 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/ControllerFactory.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/ControllerFactory.kt @@ -6,13 +6,11 @@ import fr.acinq.phoenix.controllers.init.RestoreWallet import fr.acinq.phoenix.controllers.main.Content import fr.acinq.phoenix.controllers.main.Home import fr.acinq.phoenix.controllers.payments.Receive -import fr.acinq.phoenix.controllers.payments.Scan typealias ContentController = MVI.Controller typealias HomeController = MVI.Controller typealias InitializationController = MVI.Controller typealias ReceiveController = MVI.Controller -typealias ScanController = MVI.Controller typealias RestoreWalletController = MVI.Controller typealias CloseChannelsConfigurationController = MVI.Controller @@ -25,7 +23,6 @@ interface ControllerFactory { fun initialization(): InitializationController fun home(): HomeController fun receive(): ReceiveController - fun scan(firstModel: Scan.Model = Scan.Model.Ready): ScanController fun restoreWallet(): RestoreWalletController fun configuration(): ConfigurationController fun electrumConfiguration(): ElectrumConfigurationController diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt deleted file mode 100644 index 6402ce47f..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.controllers.payments - -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.TrampolineFees -import fr.acinq.lightning.payment.Bolt11Invoice -import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.controllers.MVI -import fr.acinq.phoenix.data.BitcoinUri -import fr.acinq.phoenix.data.lnurl.* -import io.ktor.http.* - -data class MaxFees( - val feeBase: Satoshi, - val feeProportionalMillionths: Long -) - -object Scan { - - sealed class BadRequestReason : Exception() { - object UnknownFormat : BadRequestReason() - object AlreadyPaidInvoice : BadRequestReason() - data class Expired(val timestampSeconds: Long, val expirySeconds: Long) : BadRequestReason() - data class ChainMismatch(val expected: Chain) : BadRequestReason() - data class ServiceError(val url: Url, val error: LnurlError.RemoteFailure) : BadRequestReason() - data class InvalidLnurl(val url: Url) : BadRequestReason() - data class Bip353NameNotFound(val username: String, val domain: String) : BadRequestReason() - data class Bip353InvalidUri(val path: String) : BadRequestReason() - data class Bip353InvalidOffer(val path: String) : BadRequestReason() - data class Bip353NoDNSSEC(val path: String) : BadRequestReason() - data class UnsupportedLnurl(val url: Url) : BadRequestReason() - } - - sealed class LnurlPayError { - data class RemoteError(val err: LnurlError.RemoteFailure) : LnurlPayError() - data class BadResponseError(val err: LnurlError.Pay.Invoice) : LnurlPayError() - data class ChainMismatch(val expected: Chain) : LnurlPayError() - object AlreadyPaidInvoice : LnurlPayError() - } - - sealed class LnurlWithdrawError { - data class RemoteError(val err: LnurlError.RemoteFailure) : LnurlWithdrawError() - } - - sealed class LoginError { - data class ServerError(val details: LnurlError.RemoteFailure) : LoginError() - data class NetworkError(val details: Throwable) : LoginError() - data class OtherError(val details: Throwable) : LoginError() - } - - sealed class Model : MVI.Model() { - object Ready : Model() - - data class BadRequest( - val request: String, - val reason: BadRequestReason - ) : Model() - - sealed class Bolt11InvoiceFlow : Model() { - data class Bolt11InvoiceRequest( - val request: String, - val invoice: Bolt11Invoice, - ): Bolt11InvoiceFlow() - object Sending: Bolt11InvoiceFlow() - } - - data class OfferFlow(val offer: OfferTypes.Offer) : Model() - - data class OnchainFlow(val uri: BitcoinUri): Model() - - object LnurlServiceFetch : Model() - object ResolvingBip353 : Model() - - sealed class LnurlPayFlow : Model() { - abstract val paymentIntent: LnurlPay.Intent - data class LnurlPayRequest( - override val paymentIntent: LnurlPay.Intent, - val error: LnurlPayError? - ) : LnurlPayFlow() - - data class LnurlPayFetch( - override val paymentIntent: LnurlPay.Intent - ) : LnurlPayFlow() - - data class Sending( - override val paymentIntent: LnurlPay.Intent - ) : LnurlPayFlow() - } - - sealed class LnurlWithdrawFlow : Model() { - abstract val lnurlWithdraw: LnurlWithdraw - data class LnurlWithdrawRequest( - override val lnurlWithdraw: LnurlWithdraw, - val error: LnurlWithdrawError? - ) : LnurlWithdrawFlow() - - data class LnurlWithdrawFetch( - override val lnurlWithdraw: LnurlWithdraw, - ) : LnurlWithdrawFlow() - - data class Receiving( - override val lnurlWithdraw: LnurlWithdraw, - val amount: MilliSatoshi, - val description: String?, - val paymentHash: String - ) : LnurlWithdrawFlow() - } - - sealed class LnurlAuthFlow : Model() { - abstract val auth: LnurlAuth - data class LoginRequest( - override val auth: LnurlAuth - ) : LnurlAuthFlow() - - data class LoggingIn( - override val auth: LnurlAuth - ) : LnurlAuthFlow() - - data class LoginResult( - override val auth: LnurlAuth, - val error: LoginError? - ) : LnurlAuthFlow() - } - } - - sealed class Intent : MVI.Intent() { - object Reset: Intent() - - data class Parse( - val request: String - ) : Intent() - - sealed class Bolt11InvoiceFlow : Intent() { - data class SendBolt11Invoice(val invoice: Bolt11Invoice, val amount: MilliSatoshi, val trampolineFees: TrampolineFees) : Bolt11InvoiceFlow() - } - - object CancelLnurlServiceFetch : Intent() - - sealed class LnurlPayFlow : Intent() { - data class RequestInvoice( - val paymentIntent: LnurlPay.Intent, - val amount: MilliSatoshi, - val trampolineFees: TrampolineFees, - val comment: String? - ) : LnurlPayFlow() - - data class CancelLnurlPayment( - val lnurlPay: LnurlPay.Intent - ) : LnurlPayFlow() - } - - sealed class LnurlWithdrawFlow : Intent() { - data class SendLnurlWithdraw( - val lnurlWithdraw: LnurlWithdraw, - val amount: MilliSatoshi, - val description: String? - ) : LnurlWithdrawFlow() - - data class CancelLnurlWithdraw( - val lnurlWithdraw: LnurlWithdraw - ) : LnurlWithdrawFlow() - } - - sealed class LnurlAuthFlow : Intent() { - data class Login( - val auth: LnurlAuth, - val minSuccessDelaySeconds: Double = 0.0, - val scheme: LnurlAuth.Scheme - ) : LnurlAuthFlow() - } - } - - sealed class ClipboardContent { - data class Bolt11InvoiceRequest( - val invoice: Bolt11Invoice - ): ClipboardContent() - - data class BitcoinRequest( - val address: BitcoinUri - ): ClipboardContent() - - data class LnurlRequest( - val url: Url - ) : ClipboardContent() - - data class LoginRequest( - val auth: LnurlAuth - ) : ClipboardContent() - } -} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt deleted file mode 100644 index 09f9926ed..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt +++ /dev/null @@ -1,636 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.controllers.payments - -import fr.acinq.bitcoin.BitcoinError -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.Lightning -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.TrampolineFees -import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.io.PayInvoice -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.payment.Bolt11Invoice -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampSeconds -import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.data.BitcoinUri -import fr.acinq.phoenix.data.BitcoinUriError -import fr.acinq.phoenix.data.LnurlPayMetadata -import fr.acinq.phoenix.data.WalletPaymentId -import fr.acinq.phoenix.data.WalletPaymentMetadata -import fr.acinq.phoenix.data.lnurl.Lnurl -import fr.acinq.phoenix.data.lnurl.LnurlAuth -import fr.acinq.phoenix.data.lnurl.LnurlError -import fr.acinq.phoenix.data.lnurl.LnurlPay -import fr.acinq.phoenix.data.lnurl.LnurlWithdraw -import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow -import fr.acinq.phoenix.managers.DatabaseManager -import fr.acinq.phoenix.managers.LnurlManager -import fr.acinq.phoenix.managers.PeerManager -import fr.acinq.phoenix.utils.DnsResolvers -import fr.acinq.phoenix.utils.EmailLikeAddress -import fr.acinq.phoenix.utils.Parser -import io.ktor.http.Url -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlin.time.Duration -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.seconds -import kotlin.time.TimeSource - -class AppScanController( - loggerFactory: LoggerFactory, - firstModel: Scan.Model?, - private val peerManager: PeerManager, - private val lnurlManager: LnurlManager, - private val databaseManager: DatabaseManager, - private val chain: Chain, -) : AppController( - loggerFactory = loggerFactory, - firstModel = firstModel ?: Scan.Model.Ready -) { - - /** Arbitrary identifier used to track the current lnurl task. Those tasks are asynchronous and can be cancelled. We use this field to track which one is in progress. */ - private var lnurlRequestId = 1 - - /** Tracks the task fetching information for a given lnurl. Use this field to cancel the task (see [cancelLnurlFetch]). */ - private var continueLnurlTask: Deferred? = null - - /** Tracks the task requesting an invoice to a Lnurl service. Use this field to cancel the task (see [cancelLnurlPay]). */ - private var requestPayInvoiceTask: Deferred? = null - - /** Tracks the task that send an invoice we generated to a Lnurl service, in order to make a withdrawal. Use this field to cancel the task (see [cancelLnurlWithdraw]). */ - private var sendWithdrawInvoiceTask: Deferred? = null - - constructor(business: PhoenixBusiness, firstModel: Scan.Model?) : this( - loggerFactory = business.loggerFactory, - firstModel = firstModel, - peerManager = business.peerManager, - lnurlManager = business.lnurlManager, - databaseManager = business.databaseManager, - chain = business.chain, - ) - - override fun process(intent: Scan.Intent) { - when (intent) { - is Scan.Intent.Reset -> launch { model(Scan.Model.Ready) } - is Scan.Intent.Parse -> launch { processScannedInput(intent) } - is Scan.Intent.Bolt11InvoiceFlow.SendBolt11Invoice -> launch { - payBolt11Invoice( - amountToSend = intent.amount, - trampolineFees = intent.trampolineFees, - invoice = intent.invoice, - metadata = null, - ) - model(Scan.Model.Bolt11InvoiceFlow.Sending) - } - is Scan.Intent.CancelLnurlServiceFetch -> launch { cancelLnurlFetch() } - is Scan.Intent.LnurlPayFlow.RequestInvoice -> launch { processLnurlPayRequestInvoice(intent) } - is Scan.Intent.LnurlPayFlow.CancelLnurlPayment -> launch { cancelLnurlPay(intent) } - is Scan.Intent.LnurlWithdrawFlow.SendLnurlWithdraw -> launch { processLnurlWithdraw(intent) } - is Scan.Intent.LnurlWithdrawFlow.CancelLnurlWithdraw -> launch { cancelLnurlWithdraw(intent) } - is Scan.Intent.LnurlAuthFlow.Login -> launch { processLnurlAuth(intent) } - } - } - - private suspend fun processScannedInput( - intent: Scan.Intent.Parse - ) { - val input = Parser.removeExcessInput(intent.request) - - try { - Parser.readBolt11Invoice(input)?.let { - processBolt11Invoice(it) - } ?: Parser.readOffer(input)?.let { - processOffer(it) - } ?: readEmailLikeAddress(input)?.let { - when (it) { - is Either.Left -> processOffer(it.value) - is Either.Right -> processLnurl(it.value) - } - } ?: readLnurl(input)?.let { - processLnurl(it) - } ?: readBitcoinAddress(input)?.let { - processBitcoinAddress(input, it) - } ?: readLNURLFallback(input)?.let { - processLnurl(it) - } ?: run { - model(Scan.Model.BadRequest(request = intent.request, reason = Scan.BadRequestReason.UnknownFormat)) - } - } catch (e: Exception) { - if (e is Scan.BadRequestReason) { - model(Scan.Model.BadRequest(request = intent.request, reason = e)) - } else { - model(Scan.Model.BadRequest(request = intent.request, reason = Scan.BadRequestReason.UnknownFormat)) - } - } - } - - /** Inspects the Lightning invoice for errors and update the model with the adequate value. */ - private suspend fun processBolt11Invoice(invoice: Bolt11Invoice) { - val model = checkForBadBolt11Invoice(invoice)?.let { - Scan.Model.BadRequest(request = invoice.write(), reason = it) - } ?: Scan.Model.Bolt11InvoiceFlow.Bolt11InvoiceRequest( - request = invoice.write(), - invoice = invoice, - ) - model(model) - } - - /** Inspects the offer for errors and update the model with the adequate value. */ - private suspend fun processOffer(offer: OfferTypes.Offer) { - if (!offer.chains.contains(chain.chainHash)) { - model(Scan.Model.BadRequest(request = offer.encode(), reason = Scan.BadRequestReason.ChainMismatch(expected = chain))) - } else { - model(Scan.Model.OfferFlow(offer)) - } - } - - /** Return the adequate model for a Bitcoin address result. */ - private suspend fun processBitcoinAddress( - input: String, - result: Either - ) { - model(when (result) { - is Either.Right -> { - val address = result.value.address - val bolt11 = result.value.paymentRequest - val bolt12 = result.value.offer - when { - address.isNotBlank() -> Scan.Model.OnchainFlow(uri = result.value) - bolt11 != null -> Scan.Model.Bolt11InvoiceFlow.Bolt11InvoiceRequest(request = input, invoice = bolt11) - bolt12 != null -> Scan.Model.OfferFlow(offer = bolt12) - else -> Scan.Model.BadRequest(request = input, reason = Scan.BadRequestReason.UnknownFormat) - } - } - is Either.Left -> { - val error = result.value - if (error is BitcoinUriError.InvalidScript && error.error is BitcoinError.ChainHashMismatch) { - Scan.Model.BadRequest(request = input, reason = Scan.BadRequestReason.ChainMismatch(expected = chain)) - } else { - Scan.Model.BadRequest(request = input, reason = Scan.BadRequestReason.UnknownFormat) - } - } - }) - } - - /** Utility method wrapping a cancellable lnurl task and updating the requestId field. */ - private suspend fun executeLnurlAction(action: suspend () -> Either): Either? { - val requestId = lnurlRequestId - val result = action() - return if (requestId == lnurlRequestId) { - result - } else { - null - } - } - - private suspend fun processLnurl(lnurl: Lnurl) { - when (lnurl) { - is LnurlAuth -> { - model(Scan.Model.LnurlAuthFlow.LoginRequest(auth = lnurl)) - } - // this lnurl is a standard url that must be executed immediately in order to get the actual - // details from the service (the service should return either a LnurlPay or a LnurlWithdraw). - is Lnurl.Request -> { - val url = lnurl.initialUrl - model(Scan.Model.LnurlServiceFetch) - val result = executeLnurlAction { - val task = lnurlManager.executeLnurl(url) - continueLnurlTask = task - try { - Either.Right(task.await()) - } catch (e: Exception) { - logger.error(e) { "failed to process lnurl=$lnurl" } - when (e) { - is LnurlError.RemoteFailure -> Either.Left(Scan.BadRequestReason.ServiceError(url, e)) - else -> Either.Left(Scan.BadRequestReason.InvalidLnurl(url)) - } - } - } - when (result) { - null -> Unit - is Either.Left -> model(Scan.Model.BadRequest(request = url.toString(), reason = result.value)) - is Either.Right -> { // result: Lnurl - when (val res = result.value) { - is LnurlPay.Intent -> { - model(Scan.Model.LnurlPayFlow.LnurlPayRequest(paymentIntent = res, error = null)) - } - is LnurlWithdraw -> { - model(Scan.Model.LnurlWithdrawFlow.LnurlWithdrawRequest(lnurlWithdraw = res, error = null)) - } - else -> { - model(Scan.Model.BadRequest(request = url.toString(), reason = Scan.BadRequestReason.UnsupportedLnurl(url))) - } - } - } - } - } - else -> Unit - } - } - - /** Extract invoice and send it to the Peer to make the payment, attaching custom trampoline fees if needed. */ - private suspend fun payBolt11Invoice( - amountToSend: MilliSatoshi, - trampolineFees: TrampolineFees, - invoice: Bolt11Invoice, - metadata: WalletPaymentMetadata?, - ) { - val paymentId = UUID.randomUUID() - val peer = peerManager.getPeer() - - // save lnurl metadata if any - metadata?.let { WalletPaymentMetadataRow.serialize(it) }?.let { row -> - databaseManager.paymentsDb().enqueueMetadata( - row = row, - id = WalletPaymentId.LightningOutgoingPaymentId(paymentId) - ) - } - - peer.send( - PayInvoice( - paymentId = paymentId, - amount = amountToSend, - paymentDetails = LightningOutgoingPayment.Details.Normal(paymentRequest = invoice), - trampolineFeesOverride = listOf(trampolineFees) - ) - ) - } - - /** Cancel the current lnurl task fetching data from a service. */ - private suspend fun cancelLnurlFetch() { - lnurlRequestId += 1 - continueLnurlTask?.cancel() - continueLnurlTask = null - model(Scan.Model.Ready) - } - - private suspend fun processLnurlPayRequestInvoice( - intent: Scan.Intent.LnurlPayFlow.RequestInvoice - ) { - model(Scan.Model.LnurlPayFlow.LnurlPayFetch(paymentIntent = intent.paymentIntent)) - val result = executeLnurlAction { - val task = lnurlManager.requestPayInvoice( - intent = intent.paymentIntent, - amount = intent.amount, - comment = intent.comment - ) - requestPayInvoiceTask = task - try { - val invoice = task.await() - when (checkForBadBolt11Invoice(invoice.invoice)) { - is Scan.BadRequestReason.ChainMismatch -> Either.Left( - Scan.LnurlPayError.ChainMismatch(expected = chain) - ) - is Scan.BadRequestReason.AlreadyPaidInvoice -> Either.Left( - Scan.LnurlPayError.AlreadyPaidInvoice - ) - else -> Either.Right(invoice) - } - } catch (err: Throwable) { - when (err) { - is LnurlError.RemoteFailure -> Either.Left( - Scan.LnurlPayError.RemoteError(err) - ) - is LnurlError.Pay.Invoice -> Either.Left( - Scan.LnurlPayError.BadResponseError(err) - ) - else -> Either.Left( - Scan.LnurlPayError.RemoteError(LnurlError.RemoteFailure.Unreadable(origin = intent.paymentIntent.callback.host)) - ) - } - } - } - - when (result) { - null -> Unit - is Either.Left -> { - logger.info { "lnurl-pay has failed with result=$result" } - model( - Scan.Model.LnurlPayFlow.LnurlPayRequest( - paymentIntent = intent.paymentIntent, - error = result.value - ) - ) - } - is Either.Right -> { - payBolt11Invoice( - amountToSend = intent.amount, - trampolineFees = intent.trampolineFees, - invoice = result.value.invoice, - metadata = WalletPaymentMetadata( - lnurl = LnurlPayMetadata( - pay = intent.paymentIntent, - description = intent.paymentIntent.metadata.plainText, - successAction = result.value.successAction - ), - userNotes = intent.comment - ), - ) - model(Scan.Model.LnurlPayFlow.Sending(intent.paymentIntent)) - } - } - } - - private suspend fun cancelLnurlPay( - intent: Scan.Intent.LnurlPayFlow.CancelLnurlPayment - ) { - lnurlRequestId += 1 - requestPayInvoiceTask?.cancel() - requestPayInvoiceTask = null - model( - Scan.Model.LnurlPayFlow.LnurlPayRequest( - paymentIntent = intent.lnurlPay, - error = null - ) - ) - } - - private suspend fun processLnurlWithdraw( - intent: Scan.Intent.LnurlWithdrawFlow.SendLnurlWithdraw - ) { - val requestId = lnurlRequestId - run { // scoping - model( - Scan.Model.LnurlWithdrawFlow.LnurlWithdrawFetch( - lnurlWithdraw = intent.lnurlWithdraw - ) - ) - } - - val paymentRequest = peerManager.getPeer().createInvoice( - paymentPreimage = Lightning.randomBytes32(), - amount = intent.amount, - description = Either.Left(intent.description ?: intent.lnurlWithdraw.defaultDescription), - expiry = 7.days - ) - - if (requestId != lnurlRequestId) { - // Intent.LnurlWithdrawFlow.CancelLnurlWithdraw has been issued - return - } - val task = lnurlManager.sendWithdrawInvoice( - lnurlWithdraw = intent.lnurlWithdraw, - paymentRequest = paymentRequest - ) - sendWithdrawInvoiceTask = task - val error: Scan.LnurlWithdrawError? = try { - task.await() - null - } catch (err: Throwable) { - when (err) { - is LnurlError.RemoteFailure -> { - Scan.LnurlWithdrawError.RemoteError(err) - } - else -> { // unexpected exception: map to generic error - Scan.LnurlWithdrawError.RemoteError( - LnurlError.RemoteFailure.Unreadable( - origin = intent.lnurlWithdraw.callback.host - ) - ) - } - } - } - if (requestId != lnurlRequestId) { - // Intent.LnurlWithdrawFlow.CancelLnurlWithdraw has been issued - return - } - if (error != null) { - model( - Scan.Model.LnurlWithdrawFlow.LnurlWithdrawRequest( - lnurlWithdraw = intent.lnurlWithdraw, - error = error - ) - ) - } else { - model( - Scan.Model.LnurlWithdrawFlow.Receiving( - lnurlWithdraw = intent.lnurlWithdraw, - amount = intent.amount, - description = intent.description, - paymentHash = paymentRequest.paymentHash.toHex() - ) - ) - } - } - - private suspend fun cancelLnurlWithdraw( - intent: Scan.Intent.LnurlWithdrawFlow.CancelLnurlWithdraw - ) { - lnurlRequestId += 1 - sendWithdrawInvoiceTask?.cancel() - sendWithdrawInvoiceTask = null - model( - Scan.Model.LnurlWithdrawFlow.LnurlWithdrawRequest( - lnurlWithdraw = intent.lnurlWithdraw, - error = null - ) - ) - } - - private suspend fun processLnurlAuth( - intent: Scan.Intent.LnurlAuthFlow.Login - ) { - withContext(Dispatchers.Default) { - model(Scan.Model.LnurlAuthFlow.LoggingIn(auth = intent.auth)) - val start = TimeSource.Monotonic.markNow() - val error = try { - lnurlManager.signAndSendAuthRequest( - auth = intent.auth, - scheme = intent.scheme - ) - null - } catch (e: LnurlError.RemoteFailure.CouldNotConnect) { - Scan.LoginError.NetworkError(details = e) - } catch (e: LnurlError.RemoteFailure) { - Scan.LoginError.ServerError(details = e) - } catch (e: Throwable) { - Scan.LoginError.OtherError(details = e) - } - if (error != null) { - model(Scan.Model.LnurlAuthFlow.LoginResult(auth = intent.auth, error = error)) - } else { - val pending = intent.minSuccessDelaySeconds.seconds - start.elapsedNow() - if (pending > Duration.ZERO) { - delay(pending) - } - model(Scan.Model.LnurlAuthFlow.LoginResult(auth = intent.auth, error = error)) - } - } - } - - /** Directly called by swift code in iOS app. Parses the data looking for a Lightning invoice, Lnurl, or Bitcoin address. */ - fun inspectClipboard(data: String): Scan.ClipboardContent? { - val input = Parser.removeExcessInput(data) - - return Parser.readBolt11Invoice(input)?.let { - Scan.ClipboardContent.Bolt11InvoiceRequest(it) - } ?: readLnurl(input)?.let { - when (it) { - is LnurlAuth -> Scan.ClipboardContent.LoginRequest(it) - is Lnurl.Request -> Scan.ClipboardContent.LnurlRequest(it.initialUrl) - else -> null - } - } ?: readBitcoinAddress(input)?.let { - when (it) { - is Either.Left -> null - is Either.Right -> Scan.ClipboardContent.BitcoinRequest(it.value) - } - } ?: readLNURLFallback(input)?.let { - when (it) { - is LnurlAuth -> Scan.ClipboardContent.LoginRequest(it) - is Lnurl.Request -> Scan.ClipboardContent.LnurlRequest(it.initialUrl) - else -> null - } - } - } - - /** Checks that the invoice is on same chain and has not already been paid. */ - private suspend fun checkForBadBolt11Invoice( - invoice: Bolt11Invoice - ): Scan.BadRequestReason? { - - val actualChain = invoice.chain - if (chain != actualChain) { - return Scan.BadRequestReason.ChainMismatch(expected = chain) - } - - if (invoice.isExpired(currentTimestampSeconds())) { - return Scan.BadRequestReason.Expired(invoice.timestampSeconds, invoice.expirySeconds ?: Bolt11Invoice.DEFAULT_EXPIRY_SECONDS.toLong()) - } - - val db = databaseManager.databases.filterNotNull().first() - return if (db.payments.listLightningOutgoingPayments(invoice.paymentHash).any { it.status is LightningOutgoingPayment.Status.Completed.Succeeded }) { - Scan.BadRequestReason.AlreadyPaidInvoice - } else { - null - } - } - - private suspend fun readEmailLikeAddress(input: String): Either? { - - val address = Parser.parseEmailLikeAddress(input) ?: return null - - return when (address) { - is EmailLikeAddress.Bip353 -> resolveBip353Offer(address.username, address.domain)?.let { Either.Left(it) } - is EmailLikeAddress.LnurlBased -> Either.Right(address.url) - is EmailLikeAddress.UnknownType -> { - resolveBip353Offer(address.username.dropWhile { it == '₿' }, address.domain)?.let { Either.Left(it) } - ?: Either.Right(EmailLikeAddress.LnurlBased(address.source, address.username, address.domain).url) - } - } - } - - /** Resolve dns-based offers. See https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki. */ - private suspend fun resolveBip353Offer( - username: String, - domain: String, - ): OfferTypes.Offer? { - model(Scan.Model.ResolvingBip353) - val dnsPath = "$username.user._bitcoin-payment.$domain." - - val json = DnsResolvers.getRandom().getTxtRecord(dnsPath) - logger.debug { "dns resolved to ${json.toString().take(100)}" } - - val status = json["Status"]?.jsonPrimitive?.intOrNull - // could be a [BadRequestReason.Bip353NameNotFound] it status == 3 - if (status == null || status > 0) return null - - val records = json["Answer"]?.jsonArray - if (records.isNullOrEmpty()) { - logger.debug { "no answer for $dnsPath" } - // TODO add test (see #599) - return null - } - - // check dnssec - val ad = json["AD"]?.jsonPrimitive?.booleanOrNull - if (ad != true) { - logger.debug { "AD false, abort dns lookup" } - throw Scan.BadRequestReason.Bip353NoDNSSEC(dnsPath) - } - - // check name matches records - val matchingRecord = records.filterIsInstance().firstOrNull { - logger.debug { "inspecting record=$it" } - it["name"]?.jsonPrimitive?.content == dnsPath - } ?: throw Scan.BadRequestReason.Bip353NameNotFound(username, domain) - - val data = matchingRecord["data"]?.jsonPrimitive?.content ?: throw Scan.BadRequestReason.Bip353InvalidUri(dnsPath) - return when (val res = Parser.parseBip21Uri(chain, data)) { - is Either.Left -> { - val error = res.value - if (error is BitcoinUriError.InvalidScript && error.error is BitcoinError.ChainHashMismatch) { - throw Scan.BadRequestReason.ChainMismatch(expected = chain) - } else { - throw Scan.BadRequestReason.Bip353InvalidUri(dnsPath) - } - } - is Either.Right -> res.value.offer ?: throw Scan.BadRequestReason.Bip353InvalidOffer(dnsPath) - } - } - - /** Reads a lnurl and return either a lnurl-auth (i.e. a http query that must not be called automatically), or the actual url embedded in the lnurl (that can be called afterwards). */ - private fun readLnurl(input: String): Lnurl? = try { - Lnurl.extractLnurl(input, logger) - } catch (t: Throwable) { - null - } - - /** Invokes `Parser.readBitcoinAddress`, but maps [BitcoinUriError.InvalidUri] to a null result instead of a fatal error. */ - private fun readBitcoinAddress(input: String): Either? { - return when (val result = Parser.parseBip21Uri(chain, input)) { - is Either.Left -> when (result.left) { - is BitcoinUriError.InvalidUri -> null - else -> result - } - is Either.Right -> result - } - } - - /** - * Support for LNURL Fallback Scheme, - * e.g. as used by Bitcoin Beach Wallet's static Paycode QR. - * https://github.com/ACINQ/phoenix/issues/323 - */ - private fun readLNURLFallback(input: String): Lnurl? = try { - val url = Url(input) - url.parameters["lightning"]?.let { fallback -> - Lnurl.extractLnurl(fallback, logger) - } - } catch (t: Throwable) { - null - } -} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt index e89ae863d..78f8b9f2e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt @@ -183,7 +183,7 @@ sealed interface Lnurl { // > response body as JSON, then interpret it accordingly. Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) } catch (e: Exception) { - logger.error(e) { "unhandled response from url=$url: " } + logger.error { "unhandled response from url=$url: ${e.message}" } throw LnurlError.RemoteFailure.Unreadable(url.host) } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlAuth.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlAuth.kt index ce4279165..1ec287b52 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlAuth.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlAuth.kt @@ -56,13 +56,13 @@ data class LnurlAuth( * The default scheme is spec compliant and that's what should be used on new service. The hashing key and * the linking key are computed by deriving the master key. The iOS app should always use that scheme. */ - object DEFAULT_SCHEME : Scheme(0) + data object DEFAULT_SCHEME : Scheme(0) /** * This is the scheme used by the legacy android wallet. The hashing key is derived from the node key, and * the linking key derived from the hashing key. Only use this when needed. */ - object ANDROID_LEGACY_SCHEME : Scheme(1) + data object ANDROID_LEGACY_SCHEME : Scheme(1) } companion object { @@ -156,12 +156,12 @@ data class LnurlAuth( companion object { /** Return true if this host is eligible to use legacy keys, false otherwise. */ fun isEligible(url: Url): Boolean { - return values().any { it.host == url.host } + return entries.any { it.host == url.host } } /** Get the legacy domain for the given [Url] if eligible, or the full domain name otherwise (i.e. specs compliant). */ fun filterDomain(url: Url): String { - return values().firstOrNull() { it.host == url.host }?.legacyCompatDomain ?: url.host + return entries.firstOrNull() { it.host == url.host }?.legacyCompatDomain ?: url.host } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt index 02a831f80..17be62c8f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt @@ -85,7 +85,7 @@ class LnurlManager( val scheme = url.protocol.name.lowercase() val isWebsite = scheme == "http" || scheme == "https" when { - isWebsite -> throw LnurlError.RemoteFailure.IsWebsite(url.host) + isWebsite -> throw LnurlError.RemoteFailure.IsWebsite(url.toString()) else -> throw e } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt index 80a7b7c1d..9fb0be423 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt @@ -28,6 +28,7 @@ import fr.acinq.phoenix.data.lnurl.LnurlPay import fr.acinq.phoenix.data.lnurl.LnurlWithdraw import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow import fr.acinq.phoenix.utils.DnsResolvers +import fr.acinq.phoenix.utils.EmailLikeAddress import fr.acinq.phoenix.utils.Parser import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope @@ -109,20 +110,21 @@ class SendManager( val reason: BadRequestReason ): ParseResult() + sealed class Success : ParseResult() data class Bolt11Invoice( val request: String, val invoice: fr.acinq.lightning.payment.Bolt11Invoice - ): ParseResult() + ): Success() data class Bolt12Offer( val offer: OfferTypes.Offer - ): ParseResult() + ): Success() data class Uri( val uri: BitcoinUri - ): ParseResult() + ): Success() - sealed class Lnurl: ParseResult() { + sealed class Lnurl: Success() { data class Pay( val paymentIntent: LnurlPay.Intent ): Lnurl() @@ -236,30 +238,21 @@ class SendManager( if (!input.contains("@", ignoreCase = true)) return null - // Ignore excess input, including additional lines, and leading/trailing whitespace - val line = input.lines().firstOrNull { it.isNotBlank() }?.trim() - val token = line?.split("\\s+".toRegex())?.firstOrNull() - - if (token.isNullOrBlank()) return null - - val components = token.split("@") - if (components.size != 2) { - return null - } - - val username = components[0].lowercase() - val domain = components[1] - - val signalBip353 = username.startsWith("₿") - val cleanUsername = username.dropWhile { it == '₿' } + val address = Parser.parseEmailLikeAddress(input) ?: return null - progress(ParseProgress.ResolvingBip353) - val offer = resolveBip353Offer(cleanUsername, domain) - return if (signalBip353) { - offer?.let { Either.Left(it) } // skip lnurl resolution if it's a bip353 address - } else { - offer?.let { Either.Left(it) } - ?: Either.Right(Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay)) + return when (address) { + is EmailLikeAddress.Bip353 -> { + progress(ParseProgress.ResolvingBip353) + resolveBip353Offer(address.username, address.domain)?.let { Either.Left(it) } + } + is EmailLikeAddress.LnurlBased -> { + progress(ParseProgress.LnurlServiceFetch) + Either.Right(address.url) + } + is EmailLikeAddress.UnknownType -> { + resolveBip353Offer(address.username.dropWhile { it == '₿' }, address.domain)?.let { Either.Left(it) } + ?: Either.Right(EmailLikeAddress.LnurlBased(address.source, address.username, address.domain).url) + } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt index b500cb51e..40adc62d8 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt @@ -93,7 +93,7 @@ object Parser { // Ignore excess input, including additional lines, and leading/trailing whitespace val line = input.lines().firstOrNull { it.isNotBlank() }?.trim() val token = line?.split("\\s+".toRegex())?.firstOrNull()?.let { - trimMatchingPrefix(it, Parser.lightningPrefixes + Parser.lnurlPrefixes) + trimMatchingPrefix(it, bitcoinPrefixes + lightningPrefixes + lnurlPrefixes) } if (token.isNullOrBlank()) return null