diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 5ea7e6a5f..1d21c6bfd 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NodeLifecycleState +import to.bitkit.ui.components.AuthCheckScreen import to.bitkit.ui.components.BottomSheetType import to.bitkit.ui.onboarding.InitializingWalletView import to.bitkit.ui.onboarding.WalletInitResult @@ -244,6 +245,7 @@ fun ContentView( allActivity(activityListViewModel, navController) activityItem(activityListViewModel, navController) qrScanner(appViewModel, navController) + authCheck(navController) // TODO extract transferNavigation navigation( @@ -473,8 +475,11 @@ private fun NavGraphBuilder.generalSettings(navController: NavHostController) { } private fun NavGraphBuilder.securitySettings(navController: NavHostController) { - composableWithDefaultTransitions { - SecuritySettingsScreen(navController) + composableWithDefaultTransitions { backStackEntry -> + SecuritySettingsScreen( + navController = navController, + savedStateHandle = backStackEntry.savedStateHandle, + ) } } @@ -624,6 +629,18 @@ private fun NavGraphBuilder.qrScanner( } } } + +private fun NavGraphBuilder.authCheck( + navController: NavHostController, +) { + composable { navBackEntry -> + val route = navBackEntry.toRoute() + AuthCheckScreen( + route = route, + navController = navController, + ) + } +} // endregion /** @@ -671,6 +688,20 @@ fun NavController.navigateToSecuritySettings() = navigate( route = Routes.SecuritySettings, ) +fun NavController.navigateToAuthCheck( + showLogoOnPin: Boolean = false, + requirePin: Boolean = false, + requireBiometrics: Boolean = false, + onSuccessActionId: String, +) = navigate( + route = Routes.AuthCheck( + showLogoOnPin = showLogoOnPin, + requirePin = requirePin, + requireBiometrics = requireBiometrics, + onSuccessActionId = onSuccessActionId, + ), +) + fun NavController.navigateToDefaultUnitSettings() = navigate( route = Routes.DefaultUnitSettings, ) @@ -764,6 +795,14 @@ object Routes { @Serializable data object SecuritySettings + @Serializable + data class AuthCheck( + val showLogoOnPin: Boolean = false, + val requirePin: Boolean = false, + val requireBiometrics: Boolean = false, + val onSuccessActionId: String, + ) + @Serializable data object DefaultUnitSettings diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt new file mode 100644 index 000000000..f515999b0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt @@ -0,0 +1,37 @@ +package to.bitkit.ui.components + +import androidx.compose.runtime.Composable +import androidx.navigation.NavController +import to.bitkit.ui.Routes +import to.bitkit.ui.appViewModel + +@Composable +fun AuthCheckScreen( + navController: NavController, + route: Routes.AuthCheck, +) { + val app = appViewModel ?: return + + AuthCheckView( + showLogoOnPin = route.showLogoOnPin, + appViewModel = app, + requireBiometrics = route.requireBiometrics, + requirePin = route.requirePin, + onSuccess = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(AuthCheckAction.KEY, route.onSuccessActionId) + + navController.popBackStack() + }, + ) +} + +object AuthCheckAction { + const val KEY = "auth_check_action_key" + + object Id { + const val TOGGLE_PIN_ON_LAUNCH = "toggle_pin_on_launch" + const val TOGGLE_BIOMETRICS = "toggle_biometrics" + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index 7aa66b06f..c87366c55 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -35,9 +35,11 @@ import to.bitkit.viewmodels.AppViewModel @Composable fun AuthCheckView( - showLogoOnPin: Boolean, + showLogoOnPin: Boolean = false, appViewModel: AppViewModel, isBiometrySupported: Boolean = rememberBiometricAuthSupported(), + requireBiometrics: Boolean = false, + requirePin: Boolean = false, onSuccess: (() -> Unit)? = null, ) { val isBiometricsEnabled by appViewModel.isBiometricEnabled.collectAsStateWithLifecycle() @@ -48,6 +50,8 @@ fun AuthCheckView( isBiometrySupported = isBiometrySupported, showLogoOnPin = showLogoOnPin, attemptsRemaining = attemptsRemaining, + requireBiometrics = requireBiometrics, + requirePin = requirePin, validatePin = appViewModel::validatePin, onSuccess = onSuccess, ) @@ -59,6 +63,8 @@ private fun AuthCheckViewContent( isBiometrySupported: Boolean, showLogoOnPin: Boolean, attemptsRemaining: Int, + requireBiometrics: Boolean = false, + requirePin: Boolean = false, validatePin: (String) -> Boolean, onSuccess: (() -> Unit)? = null, ) { @@ -74,22 +80,20 @@ private fun AuthCheckViewContent( .fillMaxSize() .navigationBarsPadding() ) { - Column { - if (showBio && isBiometrySupported) { - BiometricsView( - onSuccess = { onSuccess?.invoke() }, - onFailure = { showBio = false }, - ) - } else { - PinPad( - showLogo = showLogoOnPin, - validatePin = validatePin, - onSuccess = onSuccess, - attemptsRemaining = attemptsRemaining, - allowBiometrics = isBiometricsEnabled && isBiometrySupported, - onShowBiometrics = { showBio = true }, - ) - } + if ((showBio && isBiometrySupported && !requirePin) || requireBiometrics) { + BiometricsView( + onSuccess = { onSuccess?.invoke() }, + onFailure = { showBio = false }, + ) + } else { + PinPad( + showLogo = showLogoOnPin, + validatePin = validatePin, + onSuccess = onSuccess, + attemptsRemaining = attemptsRemaining, + allowBiometrics = isBiometricsEnabled && isBiometrySupported && !requirePin, + onShowBiometrics = { showBio = true }, + ) } } } @@ -228,7 +232,7 @@ private fun PreviewPinAttempts() { onSuccess = {}, isBiometricsEnabled = false, isBiometrySupported = true, - showLogoOnPin = true, + showLogoOnPin = false, validatePin = { true }, attemptsRemaining = 6, ) diff --git a/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt b/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt index 87e36fbc3..9603c36c0 100644 --- a/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt @@ -1,17 +1,29 @@ package to.bitkit.ui.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.utils.BiometricPrompt @Composable @@ -19,13 +31,29 @@ fun BiometricsView( onSuccess: (() -> Unit)? = null, onFailure: (() -> Unit)? = null, ) { + var shouldShowPrompt by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .clickableAlpha { + // trick to show biometric prompt again on UI click + scope.launch { + shouldShowPrompt = false + delay(5) + shouldShowPrompt = true + } + } ) { - BiometricPrompt( - onSuccess = { onSuccess?.invoke() }, - onError = { onFailure?.invoke() }, - ) + if (shouldShowPrompt) { + BiometricPrompt( + onSuccess = { onSuccess?.invoke() }, + onError = { onFailure?.invoke() }, + ) + } Icon( painter = painterResource(R.drawable.ic_fingerprint), contentDescription = null, @@ -40,3 +68,11 @@ fun BiometricsView( ) } } + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + BiometricsView() + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt index e688c4730..5ac31da12 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -13,13 +14,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.AuthCheckAction import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.navigateToAuthCheck import to.bitkit.ui.navigateToHome import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.CloseNavIcon @@ -32,6 +36,7 @@ import to.bitkit.ui.settings.pin.PinNavigationSheet @Composable fun SecuritySettingsScreen( navController: NavController, + savedStateHandle: SavedStateHandle, ) { val app = appViewModel ?: return @@ -40,6 +45,24 @@ fun SecuritySettingsScreen( val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() + LaunchedEffect(savedStateHandle) { + savedStateHandle.getStateFlow(AuthCheckAction.KEY, null) + .collect { actionId -> + if (actionId != null) { + when (actionId) { + AuthCheckAction.Id.TOGGLE_BIOMETRICS -> { + app.setIsBiometricEnabled(!isBiometricEnabled) + } + AuthCheckAction.Id.TOGGLE_PIN_ON_LAUNCH -> { + app.setIsPinOnLaunchEnabled(!isPinOnLaunchEnabled) + } + } + // cleanup + savedStateHandle.remove(AuthCheckAction.KEY) + } + } + } + PinNavigationSheet( showSheet = showPinSheet, showLaterButton = false, @@ -58,8 +81,17 @@ fun SecuritySettingsScreen( app.removePin() } }, - onPinOnLaunchClick = { app.setIsPinOnLaunchEnabled(!isPinOnLaunchEnabled) }, // TODO auth check - onUseBiometricsClick = { app.setIsBiometricEnabled(!isBiometricEnabled) }, // TODO auth check + onPinOnLaunchClick = { + navController.navigateToAuthCheck( + onSuccessActionId = AuthCheckAction.Id.TOGGLE_PIN_ON_LAUNCH, + ) + }, + onUseBiometricsClick = { + navController.navigateToAuthCheck( + requireBiometrics = true, + onSuccessActionId = AuthCheckAction.Id.TOGGLE_BIOMETRICS, + ) + }, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, ) diff --git a/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt b/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt index 64fb3247a..c6a18f7f1 100644 --- a/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt +++ b/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity @@ -14,8 +15,6 @@ import to.bitkit.R import to.bitkit.utils.BiometricCrypto import to.bitkit.utils.Logger -private val biometricCrypto = BiometricCrypto() - @Composable fun BiometricPrompt( onSuccess: () -> Unit, @@ -25,6 +24,8 @@ fun BiometricPrompt( cancelButtonText: String = stringResource(R.string.security__use_pin), ) { val context = LocalContext.current + val isPreview = LocalInspectionMode.current + if (isPreview) return // no UI to preview here, it's all system UI val title = run { val name = stringResource(R.string.security__bio) @@ -100,6 +101,7 @@ private fun launchBiometricPrompt( onAuthFailed: (() -> Unit), onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit), ) { + val biometricCrypto = BiometricCrypto() val executor = ContextCompat.getMainExecutor(activity) val promptInfo = BiometricPrompt.PromptInfo.Builder()