From 9bce2acf52e56e49befcd6006f2dc54bf2035dd6 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:08:57 +0200 Subject: [PATCH] (android) Add custom PIN option (#614) A 6-digits PIN code can now be defined to control access to the application, in addition to the System screen lock option. Both can be used at the same time, or just one. This PIN code is specific to Phoenix and can be different from the System PIN. This change is similar to the PIN code update for iOS (#560). --- .../fr/acinq/phoenix/android/AppView.kt | 617 +++++++++--------- .../fr/acinq/phoenix/android/AppViewModel.kt | 50 +- .../fr/acinq/phoenix/android/MainActivity.kt | 30 +- .../components/screenlock/CheckPinFlow.kt | 68 ++ .../screenlock/CheckPinFlowViewModel.kt | 154 +++++ .../components/screenlock/LockPrompt.kt | 142 ++++ .../components/screenlock/NewPinFlow.kt | 129 ++++ .../screenlock/NewPinFlowViewModel.kt | 121 ++++ .../components/screenlock/PinDialog.kt | 154 +++++ .../components/screenlock/PinKeyboard.kt | 86 +++ .../phoenix/android/security/EncryptedPin.kt | 131 ++++ .../phoenix/android/security/EncryptedSeed.kt | 15 +- .../android/security/KeystoreHelper.kt | 23 +- .../android/settings/AppAccessSettings.kt | 208 ++++++ .../phoenix/android/settings/AppLockView.kt | 126 ---- .../phoenix/android/startup/StartupView.kt | 84 +-- .../android/startup/StartupViewModel.kt | 5 - .../android/utils/LegacyMigrationHelper.kt | 2 +- .../utils/datastore/UserPrefsRepository.kt | 21 +- .../src/main/res/drawable/ic_check_circle.xml | 2 +- .../src/main/res/drawable/ic_fingerprint.xml | 85 +++ .../src/main/res/drawable/ic_keyboard.xml | 85 +++ .../src/main/res/drawable/ic_pin.xml | 57 ++ .../src/main/res/values-b+es+419/strings.xml | 9 +- .../src/main/res/values-cs/strings.xml | 7 +- .../src/main/res/values-de/strings.xml | 9 +- .../src/main/res/values-fr/strings.xml | 8 +- .../src/main/res/values-sk/strings.xml | 9 +- .../src/main/res/values-vi/strings.xml | 9 +- .../src/main/res/values/strings.xml | 35 +- 30 files changed, 1915 insertions(+), 566 deletions(-) create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlow.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlowViewModel.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/LockPrompt.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlow.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlowViewModel.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinDialog.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinKeyboard.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedPin.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppAccessSettings.kt delete mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt create mode 100644 phoenix-android/src/main/res/drawable/ic_fingerprint.xml create mode 100644 phoenix-android/src/main/res/drawable/ic_keyboard.xml create mode 100644 phoenix-android/src/main/res/drawable/ic_pin.xml 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 468dd8029..77838005b 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 @@ -63,25 +63,39 @@ import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.components.screenlock.LockPrompt import fr.acinq.phoenix.android.home.HomeView 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.receive.ReceiveView 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.services.NodeServiceState -import fr.acinq.phoenix.android.settings.* +import fr.acinq.phoenix.android.settings.AboutView +import fr.acinq.phoenix.android.settings.AppAccessSettings +import fr.acinq.phoenix.android.settings.DisplayPrefsView +import fr.acinq.phoenix.android.settings.ElectrumView +import fr.acinq.phoenix.android.settings.ExperimentalView +import fr.acinq.phoenix.android.settings.ForceCloseView +import fr.acinq.phoenix.android.settings.LogsView +import fr.acinq.phoenix.android.settings.MutualCloseView +import fr.acinq.phoenix.android.settings.NotificationsView +import fr.acinq.phoenix.android.settings.PaymentSettingsView +import fr.acinq.phoenix.android.settings.ResetWallet +import fr.acinq.phoenix.android.settings.SettingsContactsView +import fr.acinq.phoenix.android.settings.SettingsView +import fr.acinq.phoenix.android.settings.TorConfigView import fr.acinq.phoenix.android.settings.channels.ChannelDetailsView import fr.acinq.phoenix.android.settings.channels.ChannelsView import fr.acinq.phoenix.android.settings.channels.ImportChannelsData import fr.acinq.phoenix.android.settings.displayseed.DisplaySeedView import fr.acinq.phoenix.android.settings.fees.AdvancedIncomingFeePolicy import fr.acinq.phoenix.android.settings.fees.LiquidityPolicyView -import fr.acinq.phoenix.android.payments.liquidity.RequestLiquidityView import fr.acinq.phoenix.android.settings.walletinfo.FinalWalletInfo import fr.acinq.phoenix.android.settings.walletinfo.SendSwapInRefundView import fr.acinq.phoenix.android.settings.walletinfo.SwapInAddresses @@ -168,318 +182,329 @@ fun AppView( .fillMaxWidth() .fillMaxHeight() ) { + val isScreenLocked by appVM.isScreenLocked + val isBiometricLockEnabledState = userPrefs.getIsBiometricLockEnabled.collectAsState(initial = null) + val isBiometricLockEnabled = isBiometricLockEnabledState.value + val isCustomPinLockEnabledState = userPrefs.getIsCustomPinLockEnabled.collectAsState(initial = null) + val isCustomPinLockEnabled = isCustomPinLockEnabledState.value - NavHost( - navController = navController, - startDestination = "${Screen.Startup.route}?next={next}", - ) { - composable( - route = "${Screen.Startup.route}?next={next}", - arguments = listOf( - navArgument("next") { type = NavType.StringType; nullable = true } - ), + if (isBiometricLockEnabled == null || isCustomPinLockEnabled == null) { + // wait for preferences to load + } else if (isScreenLocked && (isBiometricLockEnabled || isCustomPinLockEnabled)) { + LockPrompt(onLock = { appVM.lockScreen() }, onUnlock = { appVM.unlockScreen() }) + } else { + NavHost( + navController = navController, + startDestination = "${Screen.Startup.route}?next={next}", ) { - val intent = try { - it.arguments?.getParcelable(NavController.KEY_DEEP_LINK_INTENT) - } catch (e: Exception) { - null - } - val nextScreenLink = try { - intent?.data?.getQueryParameter("next")?.decodeURLPart() - } catch (e: Exception) { - null - } - StartupView( - appVM = appVM, - onShowIntro = { navController.navigate(Screen.Intro.route) }, - onKeyAbsent = { navController.navigate(Screen.InitWallet.route) }, - onBusinessStarted = { - 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) - } else { - log.debug("redirecting from startup to $next") - navController.navigate(next, navOptions = navOptions { - popUpTo(navController.graph.id) { inclusive = true } - }) - } + composable( + route = "${Screen.Startup.route}?next={next}", + arguments = listOf( + navArgument("next") { type = NavType.StringType; nullable = true } + ), + ) { + val intent = try { + it.arguments?.getParcelable(NavController.KEY_DEEP_LINK_INTENT) + } catch (e: Exception) { + null } - ) - } - composable(Screen.Intro.route) { - IntroView(onFinishClick = { navController.navigate(Screen.Startup.route) }) - } - composable(Screen.InitWallet.route) { - InitWallet( - onCreateWalletClick = { navController.navigate(Screen.CreateWallet.route) }, - onRestoreWalletClick = { navController.navigate(Screen.RestoreWallet.route) }, - ) - } - composable(Screen.CreateWallet.route) { - CreateWalletView(onSeedWritten = { navController.navigate(Screen.Startup.route) }) - } - composable(Screen.RestoreWallet.route) { - RestoreWalletView(onSeedWritten = { navController.navigate(Screen.Startup.route) }) - } - composable(Screen.Home.route) { - RequireStarted(walletState) { - HomeView( - paymentsViewModel = paymentsViewModel, - noticesViewModel = noticesViewModel, - 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 } }, - onPaymentsHistoryClick = { navController.navigate(Screen.PaymentsHistory.route) }, - onTorClick = { navController.navigate(Screen.TorConfig) }, - onElectrumClick = { navController.navigate(Screen.ElectrumServer) }, - onShowSwapInWallet = { navController.navigate(Screen.WalletInfo.SwapInWallet) }, - onShowNotifications = { navController.navigate(Screen.Notifications) }, - onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, + val nextScreenLink = try { + intent?.data?.getQueryParameter("next")?.decodeURLPart() + } catch (e: Exception) { + null + } + StartupView( + appVM = appVM, + onShowIntro = { navController.navigate(Screen.Intro.route) }, + onKeyAbsent = { navController.navigate(Screen.InitWallet.route) }, + onBusinessStarted = { + 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) + } else { + log.debug("redirecting from startup to {}", next) + navController.navigate(next, navOptions = navOptions { + popUpTo(navController.graph.id) { inclusive = true } + }) + } + } ) } - } - composable(Screen.Receive.route) { - ReceiveView( - onSwapInReceived = { popToHome(navController) }, - onBackClick = { navController.popBackStack() }, - onScanDataClick = { navController.navigate(Screen.ScanData.route) }, - onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) }, - ) - } - composable( - route = "${Screen.ScanData.route}?input={input}", - arguments = listOf( - navArgument("input") { type = NavType.StringType ; nullable = true }, - ), - deepLinks = listOf( - navDeepLink { uriPattern = "lightning:{data}" }, - navDeepLink { uriPattern = "bitcoin:{data}" }, - navDeepLink { uriPattern = "lnurl:{data}" }, - navDeepLink { uriPattern = "lnurlp:{data}" }, - navDeepLink { uriPattern = "lnurlw:{data}" }, - navDeepLink { uriPattern = "keyauth:{data}" }, - navDeepLink { uriPattern = "phoenix:lightning:{data}" }, - navDeepLink { uriPattern = "phoenix:bitcoin:{data}" }, - 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 - } - 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, + composable(Screen.Intro.route) { + IntroView(onFinishClick = { navController.navigate(Screen.Startup.route) }) + } + composable(Screen.InitWallet.route) { + InitWallet( + onCreateWalletClick = { navController.navigate(Screen.CreateWallet.route) }, + onRestoreWalletClick = { navController.navigate(Screen.RestoreWallet.route) }, + ) + } + composable(Screen.CreateWallet.route) { + CreateWalletView(onSeedWritten = { navController.navigate(Screen.Startup.route) }) + } + composable(Screen.RestoreWallet.route) { + RestoreWalletView(onSeedWritten = { navController.navigate(Screen.Startup.route) }) + } + composable(Screen.Home.route) { + RequireStarted(walletState) { + HomeView( + paymentsViewModel = paymentsViewModel, + noticesViewModel = noticesViewModel, + 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 } }, + onPaymentsHistoryClick = { navController.navigate(Screen.PaymentsHistory.route) }, + onTorClick = { navController.navigate(Screen.TorConfig) }, + onElectrumClick = { navController.navigate(Screen.ElectrumServer) }, + onShowSwapInWallet = { navController.navigate(Screen.WalletInfo.SwapInWallet) }, + onShowNotifications = { navController.navigate(Screen.Notifications) }, + onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, + ) + } + } + composable(Screen.Receive.route) { + ReceiveView( + onSwapInReceived = { popToHome(navController) }, onBackClick = { navController.popBackStack() }, - onAuthSchemeInfoClick = { navController.navigate("${Screen.PaymentSettings.route}/true") }, + onScanDataClick = { navController.navigate(Screen.ScanData.route) }, onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) }, - onProcessingFinished = { popToHome(navController) }, ) } - } - composable( - route = "${Screen.PaymentDetails.route}?direction={direction}&id={id}&fromEvent={fromEvent}", - arguments = listOf( - navArgument("direction") { type = NavType.LongType }, - navArgument("id") { type = NavType.StringType }, - navArgument("fromEvent") { - type = NavType.BoolType - defaultValue = false + composable( + route = "${Screen.ScanData.route}?input={input}", + arguments = listOf( + navArgument("input") { type = NavType.StringType ; nullable = true }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = "lightning:{data}" }, + navDeepLink { uriPattern = "bitcoin:{data}" }, + navDeepLink { uriPattern = "lnurl:{data}" }, + navDeepLink { uriPattern = "lnurlp:{data}" }, + navDeepLink { uriPattern = "lnurlw:{data}" }, + navDeepLink { uriPattern = "keyauth:{data}" }, + navDeepLink { uriPattern = "phoenix:lightning:{data}" }, + navDeepLink { uriPattern = "phoenix:bitcoin:{data}" }, + 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 } - ), - deepLinks = listOf(navDeepLink { uriPattern = "phoenix:payments/{direction}/{id}" }) - ) { - val direction = it.arguments?.getLong("direction") - val id = it.arguments?.getString("id") - - val paymentId = if (id != null && direction != null) WalletPaymentId.create(direction, id) else null - if (paymentId != null) { - RequireStarted(walletState, nextUri = "phoenix:payments/${direction}/${id}") { - log.debug("navigating to payment-details id=$id") - val fromEvent = it.arguments?.getBoolean("fromEvent") ?: false - PaymentDetailsView( - paymentId = paymentId, - onBackClick = { - val previousNav = navController.previousBackStackEntry - if (fromEvent && previousNav?.destination?.route == Screen.ScanData.route) { - popToHome(navController) - } else if (navController.previousBackStackEntry != null){ - navController.popBackStack() - } else { - popToHome(navController) - } - }, - fromEvent = fromEvent + 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 = { navController.popBackStack() }, + onAuthSchemeInfoClick = { navController.navigate("${Screen.PaymentSettings.route}/true") }, + onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) }, + onProcessingFinished = { popToHome(navController) }, ) } } - } - composable(Screen.PaymentsHistory.route) { - PaymentsHistoryView( - onBackClick = { navController.popBackStack() }, - paymentsViewModel = paymentsViewModel, - onPaymentClick = { navigateToPaymentDetails(navController, id = it, isFromEvent = false) }, - onCsvExportClick = { navController.navigate(Screen.PaymentsCsvExport) }, - ) - } - composable(Screen.PaymentsCsvExport.route) { - CsvExportView(onBackClick = { - navController.navigate(Screen.PaymentsHistory.route) { - popUpTo(Screen.PaymentsHistory.route) { inclusive = true } + composable( + route = "${Screen.PaymentDetails.route}?direction={direction}&id={id}&fromEvent={fromEvent}", + arguments = listOf( + navArgument("direction") { type = NavType.LongType }, + navArgument("id") { type = NavType.StringType }, + navArgument("fromEvent") { + type = NavType.BoolType + defaultValue = false + } + ), + deepLinks = listOf(navDeepLink { uriPattern = "phoenix:payments/{direction}/{id}" }) + ) { + val direction = it.arguments?.getLong("direction") + val id = it.arguments?.getString("id") + + val paymentId = if (id != null && direction != null) WalletPaymentId.create(direction, id) else null + if (paymentId != null) { + RequireStarted(walletState, nextUri = "phoenix:payments/${direction}/${id}") { + log.debug("navigating to payment-details id=$id") + val fromEvent = it.arguments?.getBoolean("fromEvent") ?: false + PaymentDetailsView( + paymentId = paymentId, + onBackClick = { + val previousNav = navController.previousBackStackEntry + if (fromEvent && previousNav?.destination?.route == Screen.ScanData.route) { + popToHome(navController) + } else if (navController.previousBackStackEntry != null){ + navController.popBackStack() + } else { + popToHome(navController) + } + }, + fromEvent = fromEvent + ) + } } - }) - } - composable(Screen.Settings.route) { - SettingsView(noticesViewModel) - } - composable(Screen.DisplaySeed.route) { - DisplaySeedView() - } - composable(Screen.ElectrumServer.route) { - ElectrumView() - } - composable(Screen.TorConfig.route) { - TorConfigView() - } - composable(Screen.Channels.route) { - ChannelsView( - onBackClick = { - navController.navigate(Screen.Settings) { - popUpTo(Screen.Settings.route) { inclusive = true } + } + composable(Screen.PaymentsHistory.route) { + PaymentsHistoryView( + onBackClick = { navController.popBackStack() }, + paymentsViewModel = paymentsViewModel, + onPaymentClick = { navigateToPaymentDetails(navController, id = it, isFromEvent = false) }, + onCsvExportClick = { navController.navigate(Screen.PaymentsCsvExport) }, + ) + } + composable(Screen.PaymentsCsvExport.route) { + CsvExportView(onBackClick = { + navController.navigate(Screen.PaymentsHistory.route) { + popUpTo(Screen.PaymentsHistory.route) { inclusive = true } } - }, - onChannelClick = { navController.navigate("${Screen.ChannelDetails.route}?id=$it") }, - onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData)}, - ) - } - composable( - route = "${Screen.ChannelDetails.route}?id={id}", - arguments = listOf(navArgument("id") { type = NavType.StringType }) - ) { - val channelId = it.arguments?.getString("id") - ChannelDetailsView(onBackClick = { navController.popBackStack() }, channelId = channelId) - } - composable(Screen.ImportChannelsData.route) { - ImportChannelsData(onBackClick = { navController.popBackStack() }) - } - composable(Screen.MutualClose.route) { - MutualCloseView(onBackClick = { navController.popBackStack() }) - } - composable(Screen.ForceClose.route) { - ForceCloseView(onBackClick = { navController.popBackStack() }) - } - composable(Screen.Preferences.route) { - DisplayPrefsView() - } - composable(Screen.About.route) { - AboutView() - } - composable(Screen.PaymentSettings.route) { - PaymentSettingsView( - initialShowLnurlAuthSchemeDialog = false, - ) - } - composable("${Screen.PaymentSettings.route}/{showAuthSchemeDialog}", arguments = listOf( - navArgument("showAuthSchemeDialog") { type = NavType.BoolType } - )) { - val showAuthSchemeDialog = it.arguments?.getBoolean("showAuthSchemeDialog") ?: false - PaymentSettingsView( - initialShowLnurlAuthSchemeDialog = showAuthSchemeDialog, - ) - } - composable(Screen.AppLock.route) { - AppLockView(onBackClick = { navController.popBackStack() }) - } - composable(Screen.Logs.route) { - LogsView() - } - composable(Screen.SwitchToLegacy.route) { - LegacySwitcherView(onProceedNormally = { navController.navigate(Screen.Startup.route) }) - } - composable(Screen.WalletInfo.route) { - WalletInfoView( - onBackClick = { navController.popBackStack() }, - onLightningWalletClick = { navController.navigate(Screen.Channels.route) }, - onSwapInWalletClick = { navController.navigate(Screen.WalletInfo.SwapInWallet.route) }, - onSwapInWalletInfoClick = { navController.navigate(Screen.WalletInfo.SwapInAddresses.route) }, - onFinalWalletClick = { navController.navigate(Screen.WalletInfo.FinalWallet.route) }, - ) - } - composable( - Screen.WalletInfo.SwapInWallet.route, - deepLinks = listOf( - navDeepLink { uriPattern = "phoenix:swapinwallet" } - ) - ) { - SwapInWallet( - onBackClick = { navController.popBackStack() }, - onViewChannelPolicyClick = { navController.navigate(Screen.LiquidityPolicy.route) }, - onAdvancedClick = { navController.navigate(Screen.WalletInfo.SwapInSigner.route) }, - onSpendRefundable = { navController.navigate(Screen.WalletInfo.SwapInRefund.route) }, - ) - } - composable(Screen.WalletInfo.SwapInAddresses.route) { - SwapInAddresses(onBackClick = { navController.popBackStack() }) - } - composable(Screen.WalletInfo.SwapInSigner.route) { - SwapInSignerView(onBackClick = { navController.popBackStack() }) - } - composable(Screen.WalletInfo.SwapInRefund.route) { - SendSwapInRefundView(onBackClick = { navController.popBackStack() }) - } - composable(Screen.WalletInfo.FinalWallet.route) { - FinalWalletInfo(onBackClick = { navController.popBackStack() }) - } - composable(Screen.LiquidityPolicy.route, deepLinks = listOf(navDeepLink { uriPattern ="phoenix:liquiditypolicy" })) { - LiquidityPolicyView( - onBackClick = { navController.popBackStack() }, - onAdvancedClick = { navController.navigate(Screen.AdvancedLiquidityPolicy.route) }, - onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, - ) - } - composable(Screen.LiquidityRequest.route, deepLinks = listOf(navDeepLink { uriPattern ="phoenix:requestliquidity" })) { - RequestLiquidityView(onBackClick = { navController.popBackStack() },) - } - composable(Screen.AdvancedLiquidityPolicy.route) { - AdvancedIncomingFeePolicy(onBackClick = { navController.popBackStack() }) - } - composable(Screen.Notifications.route) { - NotificationsView( - noticesViewModel = noticesViewModel, - onBackClick = { navController.popBackStack() }, - ) - } - composable(Screen.ResetWallet.route) { - appVM.service?.let { nodeService -> - val application = application - ResetWallet( - onShutdownBusiness = application::shutdownBusiness, - onShutdownService = nodeService::shutdown, - onPrefsClear = application::clearPreferences, - onBusinessReset = { - application.resetBusiness() - FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> - if (task.isSuccessful) nodeService.refreshFcmToken() + }) + } + composable(Screen.Settings.route) { + SettingsView(noticesViewModel) + } + composable(Screen.DisplaySeed.route) { + DisplaySeedView() + } + composable(Screen.ElectrumServer.route) { + ElectrumView() + } + composable(Screen.TorConfig.route) { + TorConfigView() + } + composable(Screen.Channels.route) { + ChannelsView( + onBackClick = { + navController.navigate(Screen.Settings) { + popUpTo(Screen.Settings.route) { inclusive = true } } }, - onBackClick = { navController.popBackStack() } + onChannelClick = { navController.navigate("${Screen.ChannelDetails.route}?id=$it") }, + onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData)}, ) } - } - composable(Screen.Contacts.route) { - SettingsContactsView(onBackClick = { navController.popBackStack() }) - } - composable(Screen.Experimental.route) { - ExperimentalView(onBackClick = { navController.popBackStack() }) + composable( + route = "${Screen.ChannelDetails.route}?id={id}", + arguments = listOf(navArgument("id") { type = NavType.StringType }) + ) { + val channelId = it.arguments?.getString("id") + ChannelDetailsView(onBackClick = { navController.popBackStack() }, channelId = channelId) + } + composable(Screen.ImportChannelsData.route) { + ImportChannelsData(onBackClick = { navController.popBackStack() }) + } + composable(Screen.MutualClose.route) { + MutualCloseView(onBackClick = { navController.popBackStack() }) + } + composable(Screen.ForceClose.route) { + ForceCloseView(onBackClick = { navController.popBackStack() }) + } + composable(Screen.Preferences.route) { + DisplayPrefsView() + } + composable(Screen.About.route) { + AboutView() + } + composable(Screen.PaymentSettings.route) { + PaymentSettingsView( + initialShowLnurlAuthSchemeDialog = false, + ) + } + composable("${Screen.PaymentSettings.route}/{showAuthSchemeDialog}", arguments = listOf( + navArgument("showAuthSchemeDialog") { type = NavType.BoolType } + )) { + val showAuthSchemeDialog = it.arguments?.getBoolean("showAuthSchemeDialog") ?: false + PaymentSettingsView( + initialShowLnurlAuthSchemeDialog = showAuthSchemeDialog, + ) + } + composable(Screen.AppLock.route) { + AppAccessSettings(onBackClick = { navController.popBackStack() }) + } + composable(Screen.Logs.route) { + LogsView() + } + composable(Screen.SwitchToLegacy.route) { + LegacySwitcherView(onProceedNormally = { navController.navigate(Screen.Startup.route) }) + } + composable(Screen.WalletInfo.route) { + WalletInfoView( + onBackClick = { navController.popBackStack() }, + onLightningWalletClick = { navController.navigate(Screen.Channels.route) }, + onSwapInWalletClick = { navController.navigate(Screen.WalletInfo.SwapInWallet.route) }, + onSwapInWalletInfoClick = { navController.navigate(Screen.WalletInfo.SwapInAddresses.route) }, + onFinalWalletClick = { navController.navigate(Screen.WalletInfo.FinalWallet.route) }, + ) + } + composable( + Screen.WalletInfo.SwapInWallet.route, + deepLinks = listOf( + navDeepLink { uriPattern = "phoenix:swapinwallet" } + ) + ) { + SwapInWallet( + onBackClick = { navController.popBackStack() }, + onViewChannelPolicyClick = { navController.navigate(Screen.LiquidityPolicy.route) }, + onAdvancedClick = { navController.navigate(Screen.WalletInfo.SwapInSigner.route) }, + onSpendRefundable = { navController.navigate(Screen.WalletInfo.SwapInRefund.route) }, + ) + } + composable(Screen.WalletInfo.SwapInAddresses.route) { + SwapInAddresses(onBackClick = { navController.popBackStack() }) + } + composable(Screen.WalletInfo.SwapInSigner.route) { + SwapInSignerView(onBackClick = { navController.popBackStack() }) + } + composable(Screen.WalletInfo.SwapInRefund.route) { + SendSwapInRefundView(onBackClick = { navController.popBackStack() }) + } + composable(Screen.WalletInfo.FinalWallet.route) { + FinalWalletInfo(onBackClick = { navController.popBackStack() }) + } + composable(Screen.LiquidityPolicy.route, deepLinks = listOf(navDeepLink { uriPattern ="phoenix:liquiditypolicy" })) { + LiquidityPolicyView( + onBackClick = { navController.popBackStack() }, + onAdvancedClick = { navController.navigate(Screen.AdvancedLiquidityPolicy.route) }, + onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, + ) + } + composable(Screen.LiquidityRequest.route, deepLinks = listOf(navDeepLink { uriPattern ="phoenix:requestliquidity" })) { + RequestLiquidityView(onBackClick = { navController.popBackStack() },) + } + composable(Screen.AdvancedLiquidityPolicy.route) { + AdvancedIncomingFeePolicy(onBackClick = { navController.popBackStack() }) + } + composable(Screen.Notifications.route) { + NotificationsView( + noticesViewModel = noticesViewModel, + onBackClick = { navController.popBackStack() }, + ) + } + composable(Screen.ResetWallet.route) { + appVM.service?.let { nodeService -> + val application = application + ResetWallet( + onShutdownBusiness = application::shutdownBusiness, + onShutdownService = nodeService::shutdown, + onPrefsClear = application::clearPreferences, + onBusinessReset = { + application.resetBusiness() + FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> + if (task.isSuccessful) nodeService.refreshFcmToken() + } + }, + onBackClick = { navController.popBackStack() } + ) + } + } + composable(Screen.Contacts.route) { + SettingsContactsView(onBackClick = { navController.popBackStack() }) + } + composable(Screen.Experimental.route) { + ExperimentalView(onBackClick = { navController.popBackStack() }) + } } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppViewModel.kt index 642c2d090..c0df6af0a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppViewModel.kt @@ -19,7 +19,9 @@ package fr.acinq.phoenix.android import android.content.ComponentName import android.content.ServiceConnection +import android.os.Handler import android.os.IBinder +import android.os.Looper import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData @@ -27,14 +29,19 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import fr.acinq.phoenix.android.services.NodeService import fr.acinq.phoenix.android.services.NodeServiceState import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import org.slf4j.LoggerFactory class AppViewModel( - private val internalDataRepository: InternalDataRepository + private val internalData: InternalDataRepository, + private val userPrefs: UserPrefsRepository, ) : ViewModel() { val log = LoggerFactory.getLogger(AppViewModel::class.java) @@ -62,8 +69,38 @@ class AppViewModel( val isScreenLocked = mutableStateOf(true) - fun saveIsScreenLocked(isLocked: Boolean) { - isScreenLocked.value = isLocked + private val autoLockHandler = Handler(Looper.getMainLooper()) + private val autoLockRunnable: Runnable = Runnable { lockScreen() } + + init { + monitorUserLockPrefs() + scheduleAutoLock() + } + + fun scheduleAutoLock() { + autoLockHandler.removeCallbacksAndMessages(null) + autoLockHandler.postDelayed(autoLockRunnable, 10 * 60 * 1000L) + } + + private fun monitorUserLockPrefs() { + viewModelScope.launch { + combine(userPrefs.getIsBiometricLockEnabled, userPrefs.getIsCustomPinLockEnabled) { isBiometricEnabled, isCustomPinEnabled -> + isBiometricEnabled to isCustomPinEnabled + }.collect { (isBiometricEnabled, isCustomPinEnabled) -> + if (!isBiometricEnabled && !isCustomPinEnabled) { + unlockScreen() + } + } + } + } + + fun unlockScreen() { + isScreenLocked.value = false + scheduleAutoLock() + } + + fun lockScreen() { + isScreenLocked.value = true } override fun onCleared() { @@ -76,12 +113,17 @@ class AppViewModel( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[APPLICATION_KEY] as? PhoenixApplication) - return AppViewModel(application.internalDataRepository) as T + return AppViewModel(application.internalDataRepository, application.userPrefs) as T } } } } +sealed class LockState { + data object SettingUp: LockState() + +} + class ServiceStateLiveData(service: MutableLiveData) : MediatorLiveData() { private val log = LoggerFactory.getLogger(this::class.java) private var serviceState: LiveData? = null diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt index 640751ed2..dec556168 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt @@ -16,8 +16,10 @@ package fr.acinq.phoenix.android +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -52,6 +54,15 @@ class MainActivity : AppCompatActivity() { private var navController: NavHostController? = null + private val screenStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_OFF -> appViewModel.lockScreen() + else -> Unit + } + } + } + @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -97,6 +108,11 @@ class MainActivity : AppCompatActivity() { } } + // lock screen when screen is off + val intentFilter = IntentFilter(Intent.ACTION_SCREEN_ON) + intentFilter.addAction(Intent.ACTION_SCREEN_OFF) + registerReceiver(screenStateReceiver, intentFilter) + setContent { navController = rememberNavController() val businessState = (application as PhoenixApplication).business.collectAsState() @@ -112,6 +128,11 @@ class MainActivity : AppCompatActivity() { } } + override fun onUserInteraction() { + super.onUserInteraction() + appViewModel.scheduleAutoLock() + } + override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) // force the intent flag to single top, in order to avoid [handleDeepLink] finish the current activity. @@ -121,7 +142,11 @@ class MainActivity : AppCompatActivity() { intent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP intent?.fixUri() - this.navController?.handleDeepLink(intent) + try { + this.navController?.handleDeepLink(intent) + } catch (e: Exception) { + log.warn("could not handle deeplink: {}", e.localizedMessage) + } } override fun onStart() { @@ -169,6 +194,7 @@ class MainActivity : AppCompatActivity() { } catch (e: Exception) { log.error("failed to unbind activity from node service: {}", e.localizedMessage) } + unregisterReceiver(screenStateReceiver) log.debug("destroyed main activity") } @@ -186,7 +212,7 @@ class MainActivity : AppCompatActivity() { val ssp = initialUri?.schemeSpecificPart if (scheme == "phoenix" && ssp != null && (ssp.startsWith("lnbc") || ssp.startsWith("lntb"))) { this.data = "lightning:$ssp".toUri() - log.debug("rewritten intent uri from $initialUri to ${intent.data}") + log.debug("rewritten intent uri from {} to {}", initialUri, intent.data) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlow.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlow.kt new file mode 100644 index 000000000..3fea16246 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlow.kt @@ -0,0 +1,68 @@ +/* + * 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.components.screenlock + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.android.R + +@Composable +fun CheckPinFlow( + onCancel: () -> Unit, + onPinValid: () -> Unit, +) { + val context = LocalContext.current + val vm = viewModel(factory = CheckPinFlowViewModel.Factory) + + val isUIFrozen = vm.state !is CheckPinFlowState.CanType + + BasePinDialog( + onDismiss = { + onCancel() + }, + initialPin = vm.pinInput, + onPinSubmit = { + vm.pinInput = it + vm.checkPinAndSaveOutcome(context, it, onPinValid) + }, + stateLabel = { + when(val state = vm.state) { + is CheckPinFlowState.Init, is CheckPinFlowState.CanType -> { + PinDialogTitle(text = stringResource(id = R.string.pincode_check_title)) + } + is CheckPinFlowState.Locked -> { + PinDialogTitle(text = stringResource(id = R.string.pincode_locked_label, state.timeToWait.toString()), icon = R.drawable.ic_clock) + } + is CheckPinFlowState.Checking -> { + PinDialogTitle(text = stringResource(id = R.string.pincode_checking_label)) + } + is CheckPinFlowState.MalformedInput -> { + PinDialogError(text = stringResource(id = R.string.pincode_error_malformed)) + } + is CheckPinFlowState.IncorrectPin -> { + PinDialogError(text = stringResource(id = R.string.pincode_failure_label)) + } + is CheckPinFlowState.Error -> { + PinDialogError(text = stringResource(id = R.string.pincode_error_generic)) + } + } + }, + enabled = !isUIFrozen, + ) +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlowViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlowViewModel.kt new file mode 100644 index 000000000..4ba563e8c --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/CheckPinFlowViewModel.kt @@ -0,0 +1,154 @@ +/* + * 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.components.screenlock + +import android.content.Context +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 androidx.lifecycle.viewmodel.CreationExtras +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.android.components.screenlock.PinDialog.PIN_LENGTH +import fr.acinq.phoenix.android.security.EncryptedPin +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** View model tracking the state of the PIN dialog UI. */ +sealed class CheckPinFlowState { + + data object Init : CheckPinFlowState() + + data class Locked(val timeToWait: Duration): CheckPinFlowState() + data object CanType : CheckPinFlowState() + data object Checking : CheckPinFlowState() + data object MalformedInput: CheckPinFlowState() + data object IncorrectPin: CheckPinFlowState() + data class Error(val cause: Throwable) : CheckPinFlowState() +} + +class CheckPinFlowViewModel(private val userPrefsRepository: UserPrefsRepository) : ViewModel() { + private val log = LoggerFactory.getLogger(this::class.java) + var state by mutableStateOf(CheckPinFlowState.Init) + private set + + var pinInput by mutableStateOf("") + + init { + viewModelScope.launch { evaluateLockState() } + } + + private suspend fun evaluateLockState() { + val currentPinCodeAttempt = userPrefsRepository.getPinCodeAttempt.first() + val timeToWait = when (currentPinCodeAttempt) { + 0, 1, 2 -> Duration.ZERO + 3 -> 10.seconds + 4 -> 1.minutes + 5 -> 2.minutes + 6 -> 5.minutes + 7 -> 10.minutes + else -> 30.minutes + } + if (timeToWait > Duration.ZERO) { + state = CheckPinFlowState.Locked(timeToWait) + val countdownJob = viewModelScope.launch { + val countdownFlow = flow { + while (true) { + delay(1_000) + emit(Unit) + } + } + countdownFlow.collect { + val s = state + if (s is CheckPinFlowState.Locked) { + state = CheckPinFlowState.Locked((s.timeToWait.minus(1.seconds)).coerceAtLeast(Duration.ZERO)) + } + } + } + delay(timeToWait) + countdownJob.cancelAndJoin() + state = CheckPinFlowState.CanType + } else { + state = CheckPinFlowState.CanType + } + } + + fun checkPinAndSaveOutcome(context: Context, pin: String, onPinValid: () -> Unit) { + if (state is CheckPinFlowState.Checking || state is CheckPinFlowState.Locked) return + state = CheckPinFlowState.Checking + + viewModelScope.launch(Dispatchers.IO) { + try { + if (pin.isBlank() || pin.length != PIN_LENGTH) { + log.debug("malformed pin") + state = CheckPinFlowState.MalformedInput + delay(1300) + if (state is CheckPinFlowState.MalformedInput) { + evaluateLockState() + } + } + + val expected = EncryptedPin.getPinFromDisk(context) + if (pin == expected) { + log.debug("valid pin") + delay(100) + userPrefsRepository.savePinCodeSuccess() + pinInput = "" + state = CheckPinFlowState.CanType + viewModelScope.launch(Dispatchers.Main) { + onPinValid() + } + } else { + log.debug("incorrect pin") + delay(200) + userPrefsRepository.savePinCodeFailure() + state = CheckPinFlowState.IncorrectPin + delay(1300) + pinInput = "" + evaluateLockState() + } + } catch (e: Exception) { + log.error("error when checking pin code: ", e) + state = CheckPinFlowState.Error(e) + delay(1300) + pinInput = "" + evaluateLockState() + } + } + } + + companion object { + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication) + return CheckPinFlowViewModel(application.userPrefs) as T + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..2c672c686 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/LockPrompt.kt @@ -0,0 +1,142 @@ +/* + * 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.components.screenlock + +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +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 kotlinx.coroutines.launch + +/** + * Screen shown when authentication through biometrics or PIN is required, depending on the user's settings. + */ +@Composable +fun ColumnScope.LockPrompt( + onLock: () -> Unit, + onUnlock: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val userPrefs = userPrefs + val isBiometricLockEnabledState by userPrefs.getIsBiometricLockEnabled.collectAsState(initial = null) + val isCustomPinLockEnabledState by userPrefs.getIsCustomPinLockEnabled.collectAsState(initial = null) + var showPinLockDialog by rememberSaveable { mutableStateOf(false) } + + safeLet(isBiometricLockEnabledState, isCustomPinLockEnabledState) { isBiometricLockEnabled, isCustomPinEnabled -> + + val promptBiometricLock = { + val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { + setTitle(context.getString(R.string.lockprompt_title)) + setAllowedAuthenticators(BiometricsHelper.authCreds) + }.build() + BiometricsHelper.getPrompt( + activity = context.findActivity(), + onSuccess = { + scope.launch { userPrefs.savePinCodeSuccess() } + onUnlock() + }, + onFailure = { onLock() }, + onCancel = { } + ).authenticate(promptInfo) + } + + LaunchedEffect(key1 = true) { + if (isBiometricLockEnabled) { + promptBiometricLock() + } else if (isCustomPinEnabled) { + showPinLockDialog = true + } else { + onUnlock() + } + } + + if (showPinLockDialog) { + CheckPinFlow( + onCancel = { showPinLockDialog = false }, + onPinValid = { onUnlock() } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.ic_phoenix), + contentDescription = "phoenix-icon", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Text(text = stringResource(id = R.string.lockprompt_title), textAlign = TextAlign.Center, modifier = Modifier.align(Alignment.CenterHorizontally).padding(horizontal = 32.dp)) + Spacer(modifier = Modifier.weight(1f)) + Column(modifier = Modifier.padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + if (isBiometricLockEnabled) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + text = stringResource(id = R.string.lockprompt_biometrics_button), + icon = R.drawable.ic_fingerprint, + onClick = promptBiometricLock, + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.colors.surface, + shape = CircleShape, + padding = PaddingValues(16.dp), + ) + } + if (isCustomPinEnabled) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + text = stringResource(id = R.string.lockprompt_pin_button), + icon = R.drawable.ic_pin, + onClick = { showPinLockDialog = true }, + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.colors.surface, + shape = CircleShape, + padding = PaddingValues(16.dp), + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } ?: ProgressView(text = stringResource(id = R.string.utils_loading_prefs)) +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlow.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlow.kt new file mode 100644 index 000000000..0dcb932d9 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlow.kt @@ -0,0 +1,129 @@ +/* + * 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.components.screenlock + +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.userPrefs + +@Composable +fun NewPinFlow( + onCancel: () -> Unit, + onDone: () -> Unit +) { + val context = LocalContext.current + val vm = viewModel(factory = NewPinFlowViewModel.Factory) + + when (val state = vm.state) { + is NewPinFlowState.EnterNewPin -> { + EnterNewPinDialog( + state = state, + onDismiss = onCancel, + onPinEntered = { vm.moveToConfirmPin(it) } + ) + } + is NewPinFlowState.ConfirmNewPin -> { + ConfirmNewPinDialog( + state = state, + onDismiss = { + vm.reset() + onCancel() + }, + onPinConfirmed = { + vm.checkAndSavePin( + context = context, + expectedPin = state.expectedPin, + confirmedPin = it, + onPinWritten = { + vm.reset() + onDone() + } + ) + } + ) + } + } +} + +@Composable +private fun EnterNewPinDialog( + state: NewPinFlowState.EnterNewPin, + onDismiss: () -> Unit, + onPinEntered: (String) -> Unit, +) { + BasePinDialog( + onDismiss = onDismiss, + onPinSubmit = onPinEntered, + stateLabel = { + when (state) { + is NewPinFlowState.EnterNewPin.Init, is NewPinFlowState.EnterNewPin.Frozen.Validating -> { + PinDialogTitle(text = stringResource(id = R.string.pincode_new_title)) + } + is NewPinFlowState.EnterNewPin.Frozen.InvalidPin -> { + PinDialogError(text = stringResource(id = R.string.pincode_error_malformed)) + } + } + }, + enabled = state is NewPinFlowState.EnterNewPin.Init + ) +} + +@Composable +private fun ConfirmNewPinDialog( + state: NewPinFlowState.ConfirmNewPin, + onDismiss: () -> Unit, + onPinConfirmed: (String) -> Unit, +) { + var pin by remember { mutableStateOf("") } + BasePinDialog( + onDismiss = onDismiss, + onPinSubmit = { + pin = it + onPinConfirmed(it) + }, + stateLabel = { + when (state) { + is NewPinFlowState.ConfirmNewPin.Init -> { + PinDialogTitle(text = stringResource(id = R.string.pincode_confirm_title)) + LaunchedEffect(key1 = Unit) { + pin = "" + } + } + is NewPinFlowState.ConfirmNewPin.Frozen.PinsDoNoMatch -> { + PinDialogError(text = stringResource(id = R.string.pincode_error_confirm_do_not_match)) + } + is NewPinFlowState.ConfirmNewPin.Frozen.Writing -> { + PinDialogTitle(text = stringResource(id = R.string.pincode_checking_label)) + } + is NewPinFlowState.ConfirmNewPin.Frozen.CannotWriteToDisk -> { + PinDialogError(text = stringResource(id = R.string.pincode_error_write)) + } + } + }, + initialPin = pin, + enabled = state is NewPinFlowState.ConfirmNewPin.Init + ) +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlowViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlowViewModel.kt new file mode 100644 index 000000000..d099269e1 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/NewPinFlowViewModel.kt @@ -0,0 +1,121 @@ +/* + * 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.components.screenlock + +import android.content.Context +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 androidx.lifecycle.viewmodel.CreationExtras +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.android.components.screenlock.PinDialog.PIN_LENGTH +import fr.acinq.phoenix.android.security.EncryptedPin +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +sealed class NewPinFlowState { + + sealed class EnterNewPin : NewPinFlowState() { + data object Init : EnterNewPin() + + sealed class Frozen : EnterNewPin() { + // transient state to add a short pause before moving to step 2, for better UX + data object Validating: Frozen() + data object InvalidPin: Frozen() + } + } + + sealed class ConfirmNewPin() : NewPinFlowState() { + abstract val expectedPin: String + data class Init(override val expectedPin: String): ConfirmNewPin() + sealed class Frozen : ConfirmNewPin() { + data class Writing(override val expectedPin: String) : Frozen() + + data class PinsDoNoMatch(override val expectedPin: String, val confirmedPin: String): Frozen() + data class CannotWriteToDisk(override val expectedPin: String, val confirmedPin: String, val cause: Throwable): Frozen() + } + } +} + +class NewPinFlowViewModel(val userPrefsRepository: UserPrefsRepository): ViewModel() { + + val log = LoggerFactory.getLogger(this::class.java) + + var state by mutableStateOf(NewPinFlowState.EnterNewPin.Init) + private set + + fun reset() { + state = NewPinFlowState.EnterNewPin.Init + } + + fun moveToConfirmPin(newPin: String) { + if (state is NewPinFlowState.EnterNewPin.Frozen.Validating) return + state = NewPinFlowState.EnterNewPin.Frozen.Validating + + viewModelScope.launch { + if (newPin.isNotBlank() && newPin.length == PIN_LENGTH) { + delay(300) + state = NewPinFlowState.ConfirmNewPin.Init(newPin) + } else { + state = NewPinFlowState.EnterNewPin.Frozen.InvalidPin + delay(2_000) + reset() + } + } + } + + fun checkAndSavePin(context: Context, expectedPin: String, confirmedPin: String, onPinWritten: () -> Unit) { + if (state is NewPinFlowState.ConfirmNewPin.Frozen) return + state = NewPinFlowState.ConfirmNewPin.Frozen.Writing(confirmedPin) + + viewModelScope.launch(Dispatchers.IO) { + if (confirmedPin != expectedPin || confirmedPin.length != PIN_LENGTH) { + state = NewPinFlowState.ConfirmNewPin.Frozen.PinsDoNoMatch(expectedPin = expectedPin, confirmedPin = confirmedPin) + delay(2_000) + state = NewPinFlowState.ConfirmNewPin.Init(expectedPin) + } else { + try { + EncryptedPin.writePinToDisk(context, confirmedPin) + viewModelScope.launch(Dispatchers.Main) { + onPinWritten() + } + } catch (e: Exception) { + log.error("failed to write pin to disk: ", e) + state = NewPinFlowState.ConfirmNewPin.Frozen.CannotWriteToDisk(expectedPin, confirmedPin, e) + delay(2_000) + reset() + } + } + } + } + + companion object { + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication) + return NewPinFlowViewModel(application.userPrefs) as T + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinDialog.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinDialog.kt new file mode 100644 index 000000000..60c36a5e3 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinDialog.kt @@ -0,0 +1,154 @@ +/* + * 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.components.screenlock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.components.screenlock.PinDialog.PIN_LENGTH +import fr.acinq.phoenix.android.utils.mutedTextColor +import fr.acinq.phoenix.android.utils.negativeColor + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BasePinDialog( + stateLabel: @Composable () -> Unit, + onDismiss: () -> Unit, + onPinSubmit: (String) -> Unit, + enabled: Boolean, + initialPin: String = "", +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var pinValue by remember(initialPin) { mutableStateOf(initialPin) } + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + // executed when user click outside the sheet, and after sheet has been hidden thru state. + onDismiss() + }, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.3f), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(12.dp)) + stateLabel() + Spacer(modifier = Modifier.height(36.dp)) + Box { + PinDisplay(cursorPosition = -1) + PinDisplay(cursorPosition = pinValue.length) + } + Spacer(modifier = Modifier.height(40.dp)) + PinKeyboard( + onPinPress = { digit -> + when (pinValue.length + 1) { + in 0.. { + pinValue += digit + } + + PIN_LENGTH -> { + pinValue += digit + onPinSubmit(pinValue) + } + + else -> { + // ignore or error + } + } + }, + onResetPress = { pinValue = "" }, + isEnabled = enabled && pinValue.length in 0..6 + ) + Spacer(modifier = Modifier.height(40.dp)) + } + } +} + +@Composable +private fun PinDisplay(cursorPosition: Int) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(PIN_LENGTH) { shapePosition -> + Surface( + shape = CircleShape, + color = if (cursorPosition > shapePosition) MaterialTheme.colors.onSurface else mutedTextColor, + modifier = Modifier + .padding(horizontal = 2.dp) + .size(10.dp) + .alpha(if (cursorPosition > shapePosition) 1f else 0.2f) + ) {} + } + } +} + +@Composable +fun PinDialogTitle(text: String, icon: Int? = null, tint: Color = MaterialTheme.colors.onSurface) { + if (icon == null) { + Text(text = text, style = MaterialTheme.typography.h4) + } else { + TextWithIcon(text = text, icon = icon, textStyle = MaterialTheme.typography.h4, iconTint = tint) + } +} + +@Composable +fun PinDialogError(text: String) { + TextWithIcon( + text = text, + textStyle = MaterialTheme.typography.h4, + icon = R.drawable.ic_cross_circle, + iconTint = negativeColor + ) +} + +object PinDialog { + const val PIN_LENGTH = 6 +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinKeyboard.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinKeyboard.kt new file mode 100644 index 000000000..6cc677adf --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/screenlock/PinKeyboard.kt @@ -0,0 +1,86 @@ +/* + * 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.components.screenlock + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Button + +@Composable +fun PinKeyboard( + onPinPress: (Int) -> Unit, + onResetPress: () -> Unit, + isEnabled: Boolean, +) { + Column( + modifier = Modifier.widthIn(max = 400.dp).padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row { + PinButton(pin = 1, onClick = onPinPress, isEnabled = isEnabled) + PinButton(pin = 2, onClick = onPinPress, isEnabled = isEnabled) + PinButton(pin = 3, onClick = onPinPress, isEnabled = isEnabled) + } + Row { + PinButton(pin = 4, onClick = onPinPress, isEnabled = isEnabled) + PinButton(pin = 5, onClick = onPinPress, isEnabled = isEnabled) + PinButton(pin = 6, onClick = onPinPress, isEnabled = isEnabled) + } + Row { + PinButton(pin = 7, onClick = onPinPress, isEnabled = isEnabled) + PinButton(pin = 8, onClick = onPinPress, isEnabled = isEnabled) + PinButton(pin = 9, onClick = onPinPress, isEnabled = isEnabled) + } + Row { + Spacer(modifier = Modifier.weight(1f)) + PinButton(pin = 0, onClick = onPinPress, isEnabled = isEnabled) + ResetButton(onClick = onResetPress, isEnabled = isEnabled) + } + } +} + +@Composable +private fun RowScope.PinButton(pin: Int, onClick: (Int) -> Unit, isEnabled: Boolean) { + Button( + text = pin.toString(), + onClick = { onClick(pin) }, + modifier = Modifier.height(76.dp).weight(1f), + enabled = isEnabled, + textStyle = MaterialTheme.typography.h3, + ) +} +@Composable +private fun RowScope.ResetButton(onClick: () -> Unit, isEnabled: Boolean) { + Button( + icon = R.drawable.ic_trash, + onClick = onClick, + modifier = Modifier.height(76.dp).weight(1f), + enabled = isEnabled, + textStyle = MaterialTheme.typography.h3, + ) +} 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 new file mode 100644 index 000000000..a193e04f5 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedPin.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2021 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.security + +import android.content.Context +import fr.acinq.phoenix.android.utils.tryWith +import fr.acinq.secp256k1.Hex +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 + + +/** + * This object represents an encrypted PIN data. + * + * Similar to the [EncryptedSeed] data: it contains a version, IV, and the encrypted payload and uses a key + * from the Android Keystore to encrypt the PIN. + */ +sealed class EncryptedPin { + + abstract val name: String + override fun toString(): String = name + + abstract fun serialize(): ByteArray + + /** + * Version 1 encrypts the PIN with a [SecretKey] from the Android Keystore which does not + * require user authentication in order to be used. + */ + data class V1(val iv: ByteArray, val ciphertext: ByteArray) : EncryptedPin() { + override val name: String = "ENCRYPTED_PIN_V1" + + fun decrypt(): ByteArray = KeystoreHelper.getDecryptionCipher(KeystoreHelper.KEY_FOR_PINCODE_V1, iv).doFinal(ciphertext) + + override fun serialize(): ByteArray { + if (iv.size != IV_LENGTH) { + throw RuntimeException("cannot serialize $name: iv not of the correct length (${iv.size}/$IV_LENGTH)") + } + val array = ByteArrayOutputStream() + array.write(version.toInt()) + array.write(iv) + array.write(ciphertext) + return array.toByteArray() + } + + companion object { + /** Version byte written at the start of the encrypted pin file. */ + const val version: Byte = 1 + + fun encrypt(pin: ByteArray): V1 = tryWith(GeneralSecurityException()) { + val cipher = KeystoreHelper.getEncryptionCipher(KeystoreHelper.KEY_FOR_PINCODE_V1) + val ciphertext = cipher.doFinal(pin) + V1(cipher.iv, ciphertext) + } + } + } + + companion object { + val log: Logger = LoggerFactory.getLogger(this::class.java) + private const val IV_LENGTH = 16 + private const val FILE_NAME = "pin.dat" + + /** Reads an array of byte and de-serializes it as an [EncryptedPin] object. */ + private fun deserialize(serialized: ByteArray): EncryptedPin { + val stream = ByteArrayInputStream(serialized) + return when (val version = stream.read()) { + V1.version.toInt() -> { + val iv = ByteArray(IV_LENGTH) + stream.read(iv, 0, IV_LENGTH) + val cipherText = ByteArray(stream.available()) + stream.read(cipherText, 0, stream.available()) + V1(iv, cipherText) + } + + else -> throw UnsupportedOperationException("unhandled version=$version") + } + } + + private fun getDataDir(context: Context): File { + val datadir = SeedManager.getDatadir(context) + if (!datadir.exists()) datadir.mkdirs() + return datadir + } + + fun getPinFromDisk(context: Context): String? { + val encryptedPinFile = File(getDataDir(context), FILE_NAME) + return if (!encryptedPinFile.exists()) { + null + } else if (!encryptedPinFile.isFile || !encryptedPinFile.canRead() || !encryptedPinFile.canWrite()) { + log.warn("pin.dat exists but is not usable") + null + } else { + encryptedPinFile.readBytes().takeIf { it.isNotEmpty() }?.let { + deserialize(it) + }?.let { + when (it) { + is V1 -> it.decrypt().decodeToString() + } + } + } + } + + fun writePinToDisk(context: Context, pin: String) { + val encryptedPin = V1.encrypt(pin.encodeToByteArray()) + val datadir = getDataDir(context) + val temp = File(datadir, "temporary_pin.dat") + temp.writeBytes(encryptedPin.serialize()) + temp.copyTo(File(datadir, FILE_NAME), overwrite = true) + temp.delete() + } + } +} 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 380f420f2..7bb4d4f49 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 @@ -50,17 +50,6 @@ sealed class EncryptedSeed { } } - /** This seed is encrypted with a key that requires user authentication. */ - class WithAuth(override val iv: ByteArray, override val ciphertext: ByteArray) : V2(KeystoreHelper.KEY_WITH_AUTH) { - fun decrypt(cipher: Cipher?): ByteArray = tryWith(GeneralSecurityException()) { cipher!!.doFinal(ciphertext) } - - companion object { - fun encrypt(seed: ByteArray, cipher: Cipher): WithAuth = tryWith(GeneralSecurityException()) { - WithAuth(cipher.iv, cipher.doFinal(seed)) - } - } - } - fun getDecryptionCipher() = KeystoreHelper.getDecryptionCipher(keyAlias, iv) /** Serialize to a V2 ByteArray. */ @@ -72,7 +61,6 @@ sealed class EncryptedSeed { array.write(SEED_FILE_VERSION_2.toInt()) array.write(when (keyAlias) { KeystoreHelper.KEY_NO_AUTH -> NO_AUTH_KEY_VERSION - KeystoreHelper.KEY_WITH_AUTH -> REQUIRED_AUTH_KEY_VERSION else -> throw UnhandledEncryptionKeyAlias(keyAlias) }.toInt()) array.write(iv) @@ -83,7 +71,7 @@ sealed class EncryptedSeed { companion object { private const val IV_LENGTH = 16 private const val NO_AUTH_KEY_VERSION = 1 - private const val REQUIRED_AUTH_KEY_VERSION = 2 + private const val REMOVED_DO_NOT_USE = 2 fun deserialize(stream: ByteArrayInputStream): V2 { val keyVersion = stream.read() @@ -93,7 +81,6 @@ sealed class EncryptedSeed { stream.read(cipherText, 0, stream.available()) return when (keyVersion) { NO_AUTH_KEY_VERSION -> NoAuth(iv, cipherText) - REQUIRED_AUTH_KEY_VERSION -> WithAuth(iv, cipherText) else -> throw UnhandledEncryptionKeyVersion(keyVersion) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/KeystoreHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/KeystoreHelper.kt index 1ca68e232..9d20cd7ca 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/KeystoreHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/KeystoreHelper.kt @@ -31,11 +31,11 @@ object KeystoreHelper { private val log: Logger = LoggerFactory.getLogger(this::class.java) - /** This key does not require the user to be authenticated */ + /** The alias of the key used to encrypt an [EncryptedSeed.V2] seed. */ const val KEY_NO_AUTH = "PHOENIX_KEY_NO_AUTH" - /** This key requires the user to be authenticated (with schema, pin, fingerprint...) */ - const val KEY_WITH_AUTH = "PHOENIX_KEY_REQUIRE_AUTH" + /** The alias of the key used to encrypt a [EncryptedPin.V1] PIN. */ + const val KEY_FOR_PINCODE_V1 = "PHOENIX_KEY_FOR_PINCODE_V1" private val ENC_ALGO = KeyProperties.KEY_ALGORITHM_AES private val ENC_BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC @@ -58,21 +58,6 @@ object KeystoreHelper { return generateKeyWithSpec(spec) } - private fun getOrCreateKeyWithAuth(): SecretKey { - keyStore.getKey(KEY_WITH_AUTH, null)?.let { return it as SecretKey } - val spec = KeyGenParameterSpec.Builder(KEY_WITH_AUTH, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).apply { - setBlockModes(ENC_BLOCK_MODE) - setEncryptionPaddings(ENC_PADDING) - setRandomizedEncryptionRequired(true) - setKeySize(256) - setUserAuthenticationRequired(true) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { - setUnlockedDeviceRequired(true) - } - } - return generateKeyWithSpec(spec) - } - /** Generate key from key gen specs. If possible, store the key in strongbox. */ private fun generateKeyWithSpec(spec: KeyGenParameterSpec.Builder): SecretKey { val keygen = KeyGenerator.getInstance(ENC_ALGO, keyStore.provider) @@ -96,7 +81,7 @@ object KeystoreHelper { private fun getKeyForName(keyName: String): SecretKey = when (keyName) { KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired() - KEY_WITH_AUTH -> getOrCreateKeyWithAuth() + KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired() else -> throw IllegalArgumentException("unhandled key=$keyName") } 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 new file mode 100644 index 000000000..280a0ed31 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppAccessSettings.kt @@ -0,0 +1,208 @@ +/* + * 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.settings + +import android.content.Intent +import android.provider.Settings +import android.widget.Toast +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.screenlock.CheckPinFlow +import fr.acinq.phoenix.android.components.screenlock.NewPinFlow +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 kotlinx.coroutines.launch + + +@Composable +fun AppAccessSettings( + onBackClick: () -> Unit, +) { + val context = LocalContext.current + val biometricAuthStatus = BiometricsHelper.authStatus(context) + val userPrefs = userPrefs + val isBiometricLockEnabled by userPrefs.getIsBiometricLockEnabled.collectAsState(null) + val isCustomPinLockEnabled by userPrefs.getIsCustomPinLockEnabled.collectAsState(null) + + DefaultScreenLayout { + DefaultScreenHeader(onBackClick = onBackClick, title = stringResource(id = R.string.accessctrl_title)) + + Card { + isBiometricLockEnabled?.let { + if (biometricAuthStatus == BiometricManager.BIOMETRIC_SUCCESS) { + BiometricScreenLockView( + isBiometricLockEnabled = it, + onBiometricLockChange = { userPrefs.saveIsBiometricLockEnabled(it) } + ) + } else { + CannotUseBiometrics(status = biometricAuthStatus) + } + } ?: ProgressView(text = stringResource(id = R.string.utils_loading_prefs)) + } + + Card { + isCustomPinLockEnabled?.let { pinEnabled -> + CustomPinLockView( + isCustomPinLockEnabled = pinEnabled, + ) + } ?: ProgressView(text = stringResource(id = R.string.utils_loading_prefs)) + } + } +} + +@Composable +private fun CannotUseBiometrics( + status: Int, +) { + val context = LocalContext.current + Setting( + title = stringResource(id = R.string.accessctrl_auth_error_header), + subtitle = { Text(text = BiometricsHelper.getAuthErrorMessage(context, code = status)) }, + leadingIcon = { PhoenixIcon(resourceId = R.drawable.ic_fingerprint, tint = MaterialTheme.colors.onSurface) }, + onClick = { context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) } + ) +} + +@Composable +private fun BiometricScreenLockView( + isBiometricLockEnabled: Boolean, + onBiometricLockChange: suspend (Boolean) -> Unit, +) { + val context = LocalContext.current + val activity = context.findActivity() + val scope = rememberCoroutineScope() + var errorMessage by remember { mutableStateOf("") } + + SettingSwitch( + title = stringResource(id = R.string.accessctrl_biometric_lock_switch_label), + description = stringResource(id = R.string.accessctrl_biometric_lock_switch_desc), + icon = R.drawable.ic_fingerprint, + enabled = true, + isChecked = isBiometricLockEnabled, + onCheckChangeAttempt = { + errorMessage = "" + scope.launch { + if (it) { + BiometricsHelper + // if user wants to enable screen lock, we don't need to check authentication + onBiometricLockChange(true) + } else { + // if user wants to disable screen lock, we must first check his credentials + val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { + setTitle(context.getString(R.string.lockprompt_title)) + setAllowedAuthenticators(BiometricsHelper.authCreds) + }.build() + BiometricsHelper.getPrompt( + activity = activity, + onSuccess = { + scope.launch { onBiometricLockChange(false) } + }, + onFailure = { errorCode -> + errorMessage = errorCode?.let { BiometricsHelper.getAuthErrorMessage(context, code = it) } ?: "" + }, + onCancel = { } + ).authenticate(promptInfo) + } + } + } + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + modifier = Modifier.padding(start = 46.dp, top = 0.dp, bottom = 16.dp, end = 16.dp), + color = negativeColor, + ) + } +} + + +@Composable +private fun CustomPinLockView( + isCustomPinLockEnabled: Boolean, +) { + val context = LocalContext.current + var errorMessage by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val userPrefs = userPrefs + + var isInNewPinFlow by rememberSaveable { mutableStateOf(false) } + var isInDisablingCustomPinFlow by rememberSaveable { mutableStateOf(false) } + + SettingSwitch( + title = stringResource(id = R.string.accessctrl_pin_lock_switch_label), + description = stringResource(id = R.string.accessctrl_pin_lock_switch_desc), + icon = R.drawable.ic_pin, + enabled = !isInDisablingCustomPinFlow && !isInNewPinFlow, + isChecked = isCustomPinLockEnabled, + onCheckChangeAttempt = { isChecked -> + errorMessage = "" + if (isChecked) { + // user is enabling custom PIN + isInNewPinFlow = true + } else { + isInDisablingCustomPinFlow = true + } + } + ) + + if (isInNewPinFlow) { + NewPinFlow( + onCancel = { isInNewPinFlow = false }, + onDone = { + scope.launch { + userPrefs.saveIsCustomPinLockEnabled(true) + isInNewPinFlow = false + } + Toast.makeText(context, "Pin code saved!", Toast.LENGTH_SHORT).show() + } + ) + } + + if (isInDisablingCustomPinFlow) { + CheckPinFlow( + onCancel = { isInDisablingCustomPinFlow = false }, + onPinValid = { + scope.launch { + userPrefs.saveIsCustomPinLockEnabled(false) + isInDisablingCustomPinFlow = false + } + Toast.makeText(context, "Pin disabled", Toast.LENGTH_SHORT).show() + } + ) + } + + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + modifier = Modifier.padding(start = 46.dp, top = 0.dp, bottom = 16.dp, end = 16.dp), + color = negativeColor, + ) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt deleted file mode 100644 index 682f895d0..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt +++ /dev/null @@ -1,126 +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.settings - -import android.content.Intent -import android.provider.Settings -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.components.* -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 kotlinx.coroutines.launch - - -@Composable -fun AppLockView( - onBackClick: () -> Unit, -) { - val context = LocalContext.current - val authStatus = BiometricsHelper.authStatus(context) - val isScreenLockActive by userPrefs.getIsScreenLockActive.collectAsState(null) - - DefaultScreenLayout { - DefaultScreenHeader(onBackClick = onBackClick, title = stringResource(id = R.string.accessctrl_title)) - Card { - when (authStatus) { - BiometricManager.BIOMETRIC_SUCCESS -> AuthSwitch(isScreenLockActive = isScreenLockActive) - else -> CanNotAuthenticate(status = authStatus) - } - } - } -} - -@Composable -private fun CanNotAuthenticate( - status: Int, -) { - val context = LocalContext.current - Setting( - title = stringResource(id = R.string.accessctrl_auth_error_header), - leadingIcon = { PhoenixIcon(resourceId = R.drawable.ic_alert_triangle, tint = negativeColor) }, - subtitle = { Text(text = BiometricsHelper.getAuthErrorMessage(context, code = status)) }, - onClick = { context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) } - ) -} - -@Composable -private fun AuthSwitch( - isScreenLockActive: Boolean?, -) { - val context = LocalContext.current - val userPrefs = userPrefs - val activity = context.findActivity() - val scope = rememberCoroutineScope() - var errorMessage by remember { mutableStateOf("") } - - if (isScreenLockActive == null) { - Text(text = stringResource(id = R.string.accessctrl_loading)) - } else { - SettingSwitch( - title = stringResource(id = R.string.accessctrl_screen_lock_switch), - description = stringResource(id = R.string.accessctrl_screen_lock_switch_desc), - icon = R.drawable.ic_lock, - enabled = true, - isChecked = isScreenLockActive, - onCheckChangeAttempt = { - errorMessage = "" - scope.launch { - if (it) { - BiometricsHelper - // if user wants to enable screen lock, we don't need to check authentication - userPrefs.saveIsScreenLockActive(true) - } else { - // if user wants to disable screen lock, we must first check his credentials - val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { - setTitle(context.getString(R.string.authprompt_title)) - setAllowedAuthenticators(BiometricsHelper.authCreds) - }.build() - BiometricsHelper.getPrompt( - activity = activity, - onSuccess = { - scope.launch { userPrefs.saveIsScreenLockActive(false) } - }, - onFailure = { errorCode -> - errorMessage = errorCode?.let { BiometricsHelper.getAuthErrorMessage(context, code = it) } ?: "" - }, - onCancel = { } - ).authenticate(promptInfo) - } - } - } - ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - modifier = Modifier.padding(start = 46.dp, top = 0.dp, bottom = 16.dp, end = 16.dp), - color = negativeColor, - ) - } - } - -} \ No newline at end of file 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 4111587dd..d75d81723 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 @@ -68,6 +68,7 @@ import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.HSeparator import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.components.screenlock.LockPrompt import fr.acinq.phoenix.android.internalData import fr.acinq.phoenix.android.security.SeedFileState import fr.acinq.phoenix.android.security.SeedManager @@ -94,8 +95,9 @@ fun StartupView( ) { val context = LocalContext.current val serviceState by appVM.serviceState.observeAsState() - val showIntro by internalData.getShowIntro.collectAsState(initial = null) - val isLockActiveState by userPrefs.getIsScreenLockActive.collectAsState(initial = null) + + val showIntroState = internalData.getShowIntro.collectAsState(initial = null) + val showIntro = showIntroState.value Column( modifier = Modifier @@ -109,55 +111,33 @@ fun StartupView( painter = painterResource(id = R.drawable.ic_phoenix), contentDescription = "phoenix-icon", ) - val isScreenLockEnabled = isLockActiveState - val isScreenLocked by appVM.isScreenLocked - if (isScreenLockEnabled == null || showIntro == null) { - // wait for preferences to load - } else if (showIntro!!) { - LaunchedEffect(key1 = Unit) { onShowIntro() } - } else if (isScreenLockEnabled && isScreenLocked) { - val promptScreenLock = { - val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { - setTitle(context.getString(R.string.authprompt_title)) - setAllowedAuthenticators(BiometricsHelper.authCreds) - }.build() - BiometricsHelper.getPrompt( - activity = context.findActivity(), - onSuccess = { appVM.saveIsScreenLocked(false) }, - onFailure = { appVM.saveIsScreenLocked(true) }, - onCancel = { } - ).authenticate(promptInfo) - } - LaunchedEffect(key1 = true) { - promptScreenLock() - } - BorderButton( - text = stringResource(id = R.string.startup_manual_unlock_button), - icon = R.drawable.ic_shield, - onClick = promptScreenLock - ) - } else { - when (val currentState = serviceState) { - null, is NodeServiceState.Disconnected -> Text(stringResource(id = R.string.startup_binding_service)) - is NodeServiceState.Off -> DecryptSeedAndStartBusiness(appVM = appVM, onKeyAbsent = onKeyAbsent) - is NodeServiceState.Init -> Text(stringResource(id = R.string.startup_starting)) - is NodeServiceState.Error -> { - ErrorMessage( - header = stringResource(id = R.string.startup_error_generic), - details = currentState.cause.message - ) - } - is NodeServiceState.Running -> { - val legacyAppStatus by LegacyPrefsDatastore.getLegacyAppStatus(context).collectAsState(null) - when (legacyAppStatus) { - LegacyAppStatus.Unknown -> { - Text(stringResource(id = R.string.startup_wait_legacy_check)) - } - LegacyAppStatus.NotRequired -> { - LaunchedEffect(true) { onBusinessStarted() } - } - else -> { - Text(stringResource(id = R.string.startup_starting)) + + when (showIntro) { + null -> Unit // wait for preference to load + true -> LaunchedEffect(key1 = Unit) { onShowIntro() } + false -> { + when (val currentState = serviceState) { + null, is NodeServiceState.Disconnected -> Text(stringResource(id = R.string.startup_binding_service)) + is NodeServiceState.Off -> DecryptSeedAndStartBusiness(appVM = appVM, onKeyAbsent = onKeyAbsent) + is NodeServiceState.Init -> Text(stringResource(id = R.string.startup_starting)) + is NodeServiceState.Error -> { + ErrorMessage( + header = stringResource(id = R.string.startup_error_generic), + details = currentState.cause.message + ) + } + is NodeServiceState.Running -> { + val legacyAppStatus by LegacyPrefsDatastore.getLegacyAppStatus(context).collectAsState(null) + when (legacyAppStatus) { + LegacyAppStatus.Unknown -> { + Text(stringResource(id = R.string.startup_wait_legacy_check)) + } + LegacyAppStatus.NotRequired -> { + LaunchedEffect(true) { onBusinessStarted() } + } + else -> { + Text(stringResource(id = R.string.startup_starting)) + } } } } @@ -246,12 +226,10 @@ private fun DecryptionFailure( val context = LocalContext.current ErrorMessage( header = when (state) { - is StartupDecryptionState.DecryptionError.UnhandledVersion -> stringResource(id = R.string.startup_unlock_failure_unhandled_version, state.name) is StartupDecryptionState.DecryptionError.Other -> stringResource(id = R.string.startup_unlock_failure) is StartupDecryptionState.DecryptionError.KeystoreFailure -> stringResource(id = R.string.startup_unlock_failure_keystore) }, details = when (state) { - is StartupDecryptionState.DecryptionError.UnhandledVersion -> stringResource(id = R.string.startup_unlock_failure_unhandled_version) is StartupDecryptionState.DecryptionError.Other -> "[${state.cause::class.java.simpleName}] ${state.cause.localizedMessage ?: ""}" is StartupDecryptionState.DecryptionError.KeystoreFailure -> "[${state.cause::class.java.simpleName}] ${state.cause.localizedMessage ?: ""}" + (state.cause.cause?.localizedMessage?.take(80) ?: "") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt index e7ceac9a7..02cc30771 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt @@ -44,7 +44,6 @@ sealed class StartupDecryptionState { sealed class DecryptionError : StartupDecryptionState() { data class Other(val cause: Throwable): DecryptionError() data class KeystoreFailure(val cause: Throwable): DecryptionError() - data class UnhandledVersion(val name: String): DecryptionError() } sealed class SeedInputFallback : StartupDecryptionState() { object Init: SeedInputFallback() @@ -84,10 +83,6 @@ class StartupViewModel : ViewModel() { decryptionState.value = StartupDecryptionState.DecryptionSuccess service.startBusiness(seed, checkLegacyChannels) } - is EncryptedSeed.V2.WithAuth -> { - log.error("decryption failed, unsupported type=${encryptedSeed.name()}") - decryptionState.value = StartupDecryptionState.DecryptionError.UnhandledVersion(encryptedSeed.name()) - } } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt index 45e69bb04..b271c424c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt @@ -111,7 +111,7 @@ object LegacyMigrationHelper { // -- security & tor - userPrefs.saveIsScreenLockActive(Prefs.isScreenLocked(context)) + userPrefs.saveIsBiometricLockEnabled(Prefs.isScreenLocked(context)) Prefs.isTorEnabled(context).let { userPrefs.saveIsTorEnabled(it) appConfigurationManager.updateTorUsage(it) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt index 113592063..9de2b5bda 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt @@ -69,7 +69,9 @@ class UserPrefsRepository(private val data: DataStore) { val PREFS_ELECTRUM_ADDRESS_PORT = intPreferencesKey("PREFS_ELECTRUM_ADDRESS_PORT") val PREFS_ELECTRUM_ADDRESS_PINNED_KEY = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_PINNED_KEY") // access control - val PREFS_SCREEN_LOCK = booleanPreferencesKey("PREFS_SCREEN_LOCK") + val PREFS_SCREEN_LOCK_BIOMETRICS = booleanPreferencesKey("PREFS_SCREEN_LOCK") + val PREFS_SCREEN_LOCK_CUSTOM_PIN_ENABLED = booleanPreferencesKey("PREFS_SCREEN_LOCK_CUSTOM_PIN_ENABLED") + val PREFS_CUSTOM_PIN_ATTEMPT_COUNT = intPreferencesKey("PREFS_CUSTOM_PIN_ATTEMPT_COUNT") // payments options private val INVOICE_DEFAULT_DESC = stringPreferencesKey("INVOICE_DEFAULT_DESC") private val INVOICE_DEFAULT_EXPIRY = longPreferencesKey("INVOICE_DEFAULT_EXPIRY") @@ -148,8 +150,21 @@ class UserPrefsRepository(private val data: DataStore) { // -- security - val getIsScreenLockActive: Flow = safeData.map { it[PREFS_SCREEN_LOCK] ?: false } - suspend fun saveIsScreenLockActive(isScreenLockActive: Boolean) = data.edit { it[PREFS_SCREEN_LOCK] = isScreenLockActive } + val getIsBiometricLockEnabled: Flow = safeData.map { it[PREFS_SCREEN_LOCK_BIOMETRICS] ?: false } + suspend fun saveIsBiometricLockEnabled(isEnabled: Boolean) = data.edit { it[PREFS_SCREEN_LOCK_BIOMETRICS] = isEnabled } + + val getIsCustomPinLockEnabled: Flow = safeData.map { it[PREFS_SCREEN_LOCK_CUSTOM_PIN_ENABLED] ?: false } + suspend fun saveIsCustomPinLockEnabled(isEnabled: Boolean) = data.edit { it[PREFS_SCREEN_LOCK_CUSTOM_PIN_ENABLED] = isEnabled } + + val getPinCodeAttempt: Flow = safeData.map { + it[PREFS_CUSTOM_PIN_ATTEMPT_COUNT] ?: 0 + } + suspend fun savePinCodeFailure() = data.edit { + it[PREFS_CUSTOM_PIN_ATTEMPT_COUNT] = (it[PREFS_CUSTOM_PIN_ATTEMPT_COUNT] ?: 0) + 1 + } + suspend fun savePinCodeSuccess() = data.edit { + it[PREFS_CUSTOM_PIN_ATTEMPT_COUNT] = 0 + } val getInvoiceDefaultDesc: Flow = safeData.map { it[INVOICE_DEFAULT_DESC]?.takeIf { it.isNotBlank() } ?: "" } suspend fun saveInvoiceDefaultDesc(description: String) = data.edit { it[INVOICE_DEFAULT_DESC] = description } diff --git a/phoenix-android/src/main/res/drawable/ic_check_circle.xml b/phoenix-android/src/main/res/drawable/ic_check_circle.xml index 88170d199..af357757f 100644 --- a/phoenix-android/src/main/res/drawable/ic_check_circle.xml +++ b/phoenix-android/src/main/res/drawable/ic_check_circle.xml @@ -29,7 +29,7 @@ diff --git a/phoenix-android/src/main/res/drawable/ic_fingerprint.xml b/phoenix-android/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..d6e69727c --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + diff --git a/phoenix-android/src/main/res/drawable/ic_keyboard.xml b/phoenix-android/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 000000000..ad56e413d --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + diff --git a/phoenix-android/src/main/res/drawable/ic_pin.xml b/phoenix-android/src/main/res/drawable/ic_pin.xml new file mode 100644 index 000000000..74d4c3b2f --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_pin.xml @@ -0,0 +1,57 @@ + + + + + + + + + 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 a5cf19f1f..f737205ab 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 @@ -47,19 +47,17 @@ - Desbloquear para continuar + Desbloquear para continuar Vinculando el servicio… Preparando la billetera… Comprobando la billetera heredada… - Desbloquear Desbloqueando… Acceso concedido… Error al desbloquear Error de Android Keystore - Versión de semilla no controlada (%1$s) Desbloquear con semilla Ingresa tus 12 palabras de recuperación para desbloquear la billetera.\n\nLas palabras deben ingresarse en el orden correcto y estar separadas por un solo espacio. @@ -418,7 +416,6 @@ Acceso a la aplicación - Obteniendo datos… No se puede activar el bloqueo de pantalla No hay hardware de autenticación adecuado en este dispositivo. @@ -433,8 +430,8 @@ Esta versión de Android no es compatible. Código de error no controlado: %1$d - Bloqueo de pantalla - Bloquea el acceso a la aplicación con el bloqueo de pantalla o la huella dactilar de Android. + Bloqueo de pantalla del sistema + Bloquea el acceso a la aplicación con el bloqueo de pantalla o la huella dactilar de Android. diff --git a/phoenix-android/src/main/res/values-cs/strings.xml b/phoenix-android/src/main/res/values-cs/strings.xml index 1ae810026..d847e2baa 100644 --- a/phoenix-android/src/main/res/values-cs/strings.xml +++ b/phoenix-android/src/main/res/values-cs/strings.xml @@ -45,7 +45,7 @@ - Pro pokračování odemkněte + Pro pokračování odemkněte @@ -425,7 +425,6 @@ Přístup k aplikaci - Získávání dat… Zámek obrazovky nelze povolit V tomto zařízení není vhodný ověřovací hardware. @@ -440,8 +439,8 @@ Tato verze systému Android není kompatibilní. Neošetřená chyba: %1$d - Zámek obrazovky - Zamknout přístup k aplikaci pomocí zámku obrazoky/otisku prstu + Zámek obrazovky systému + Zamknout přístup k aplikaci pomocí zámku obrazoky/otisku prstu diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml index 34f6250fd..55efb98da 100644 --- a/phoenix-android/src/main/res/values-de/strings.xml +++ b/phoenix-android/src/main/res/values-de/strings.xml @@ -46,7 +46,7 @@ - Entsperren, um fortzufahren + Entsperren, um fortzufahren @@ -55,12 +55,10 @@ Dienst wird eingebunden… Wallet wird vorbereitet… Überprüfe Legacy-Wallet… - Freischalten Entsperren… Zugang gewährt.. Entsperren fehlgeschlagen Android Keystore-Fehler - Unbehandelte Seed-Version (%1$s) Mit Saatgut freischalten Gib deine 12 Wörter ein, um die Brieftasche zu entsperren.\n\nDie Wörter müssen in der richtigen Reihenfolge und mit einem Leerzeichen getrennt eingegeben werden. @@ -430,7 +428,6 @@ App-Zugriff - Daten werden abgerufen… Bildschirmsperre kann nicht aktiviert werden Keine geeignete Authentifizierungshardware auf diesem Gerät. @@ -445,8 +442,8 @@ Diese Android-Version ist nicht kompatibel. Unbehandelter Fehlercode: %1$d - Bildschirmsperre - Sperren Sie den Zugriff auf die App mit der Android-Bildschirmsperre/Fingerabdruck + System-Bildschirmsperre + Sperren Sie den Zugriff auf die App mit der Android-Bildschirmsperre/Fingerabdruck diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml index eb0ed431d..9856c974b 100644 --- a/phoenix-android/src/main/res/values-fr/strings.xml +++ b/phoenix-android/src/main/res/values-fr/strings.xml @@ -45,14 +45,13 @@ - Déverrouiller pour continuer + Déverrouillez pour continuer Liaison au service… Préparation du portefeuille… Vérification de l\'ancien portefeuille… - Déverrouiller Déverrouillage… Démarrage… L\'application n\'a pas pu démarrer. @@ -446,7 +445,6 @@ Contrôle d\'accès - Récupération des données… Le verrouillage ne peut pas être activé Cet appareil ne dispose pas d\'équipement biométrique adéquat. @@ -461,8 +459,8 @@ Cette version d\'Android est incompatible. Code d\'erreur non géré : %1$d - Verrouillage écran - Contrôle l\'accès à l\'application via le système de verrouillage système d\'Android. + Verrouillage écran système + Contrôle l\'accès à l\'application via le système de verrouillage système d\'Android. diff --git a/phoenix-android/src/main/res/values-sk/strings.xml b/phoenix-android/src/main/res/values-sk/strings.xml index 13f6c45c2..4c65ccc00 100644 --- a/phoenix-android/src/main/res/values-sk/strings.xml +++ b/phoenix-android/src/main/res/values-sk/strings.xml @@ -48,19 +48,17 @@ - Pre pokračovanie odomknite + Pre pokračovanie odomknite Pripájanie služby… Pripravovanie peňaženky… Kontrola staršej peňaženky… - Odomknúť Odomykanie… Prístup povolený… Odomknutie zlyhalo Chyba Android keystore - Neznáma verzia seedu (%1$s) Odomknutie pomocou seedu Zadajte svojich 12 slov obnovovacej frázy na odomknutie peňaženky.\n\nSlová musia byť zadané v správnom poradí a oddelené jednou medzerou. @@ -472,7 +470,6 @@ Prístup k aplikácii - Získavanie dát… Zámok obrazovky nie je možné povoliť V tomto zariadení nie je vhodný overovací hardvér. @@ -487,8 +484,8 @@ Táto verzia systému Android nie je kompatibilná. Neošetrená chyba: %1$d - Zámok obrazovky - Zamknúť prístup k aplikácii pomocou zámku obrazovky/odtlačku prsta + Zámok obrazovky systému + Zamknúť prístup k aplikácii pomocou zámku obrazovky/odtlačku prsta diff --git a/phoenix-android/src/main/res/values-vi/strings.xml b/phoenix-android/src/main/res/values-vi/strings.xml index a94638325..cbd184fc9 100644 --- a/phoenix-android/src/main/res/values-vi/strings.xml +++ b/phoenix-android/src/main/res/values-vi/strings.xml @@ -49,19 +49,17 @@ - Mở khóa để tiếp tục + Mở khóa để tiếp tục Binding dịch vụ… Đang chuẩn bị ví… Đang kiểm tra ví cũ… - Mở khoá Đang mở khoá… Quyền truy cập được chấp nhận… Mở khóa không thành công Lỗi kho khóa Android - Phiên bản hạt giống chưa được xử lý (%1$s) Mở khóa bằng hạt giống Nhập 12 từ khôi phục để mở khóa ví của bạn.\n\nCác từ phải được nhập theo đúng thứ tự và cách nhau bằng một dấu cách. @@ -431,7 +429,6 @@ Quyền truy cập ứng dụng - Đang tải dữ liệu… Không thể bật chế độ khóa màn hình Thiết bị này không có phần cứng xác thực nào phù hợp. @@ -446,8 +443,8 @@ Phiên bản Android này không tương thích. Lỗi mã chưa được xử lý: %1$d - Khóa màn hình - Khóa quyền truy cập vào ứng dụng bằng khóa màn hình/khoá vân tay của Android + Khóa màn hình + Khóa quyền truy cập vào ứng dụng bằng khóa màn hình/khoá vân tay của Android diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 8af9340d1..d1813b371 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -46,10 +46,6 @@ Next Restoring your wallet… - - - Unlock to continue - Etiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. @@ -61,17 +57,21 @@ \nNam felis felix, tristique commodo odio eget, imperdiet viverra erat. Donec venenatis magna pulvinar, finibus leo id, gravida augue. Integer ante leo, bibendum ac nibh quis, auctor commodo quam. Sed luctus vitae quam vel condimentum. Mauris eu rhoncus mauris. Fusce enim diam, consequat a odio sit amet, accumsan cursus nisl. Etiam lectus nunc, lacinia id purus sit amet, pulvinar auctor odio. Maecenas vitae arcu sit amet est cursus maximus. Nullam ac sapien non nibh tempor rhoncus. Mauris dignissim cursus libero quis egestas. + + + Unlock to continue + PIN code + System lock + Binding service… Preparing wallet… Checking legacy wallet… - Unlock Unlocking… Access granted… Unlock failed Android keystore error - Unhandled seed version (%1$s) Unlock with seed Enter your 12-words recovery to unlock the wallet.\n\nWords must be entered in the correct order, and separated with a single space. @@ -496,7 +496,6 @@ Application access - Retrieving data… Screen lock cannot be enabled No suitable authentication hardware on this device. @@ -511,8 +510,10 @@ This version of Android is not compatible. Unhandled error code: %1$d - Screen lock - Lock access to the app with Android\'s screen lock/fingerprint + System Screen Lock + Access to the app is controlled with the Android\'s system Screen Lock/Fingerprint + Custom PIN + Access to the app is controlled with a 6-digits PIN code specific to Phoenix @@ -859,4 +860,20 @@ Claiming address… Failed to claim address + + + Enter PIN + Enter new PIN + Confirm new PIN + + Checking PIN… + Authenticated + Incorrect + Locked for %1$s + + An error occurred + Malformed PIN + PIN mismatch! + Error when saving PIN +