Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -244,6 +245,7 @@ fun ContentView(
allActivity(activityListViewModel, navController)
activityItem(activityListViewModel, navController)
qrScanner(appViewModel, navController)
authCheck(navController)

// TODO extract transferNavigation
navigation<Routes.TransferRoot>(
Expand Down Expand Up @@ -473,8 +475,11 @@ private fun NavGraphBuilder.generalSettings(navController: NavHostController) {
}

private fun NavGraphBuilder.securitySettings(navController: NavHostController) {
composableWithDefaultTransitions<Routes.SecuritySettings> {
SecuritySettingsScreen(navController)
composableWithDefaultTransitions<Routes.SecuritySettings> { backStackEntry ->
SecuritySettingsScreen(
navController = navController,
savedStateHandle = backStackEntry.savedStateHandle,
)
}
}

Expand Down Expand Up @@ -624,6 +629,18 @@ private fun NavGraphBuilder.qrScanner(
}
}
}

private fun NavGraphBuilder.authCheck(
navController: NavHostController,
) {
composable<Routes.AuthCheck> { navBackEntry ->
val route = navBackEntry.toRoute<Routes.AuthCheck>()
AuthCheckScreen(
route = route,
navController = navController,
)
}
}
// endregion

/**
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
40 changes: 22 additions & 18 deletions app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -48,6 +50,8 @@ fun AuthCheckView(
isBiometrySupported = isBiometrySupported,
showLogoOnPin = showLogoOnPin,
attemptsRemaining = attemptsRemaining,
requireBiometrics = requireBiometrics,
requirePin = requirePin,
validatePin = appViewModel::validatePin,
onSuccess = onSuccess,
)
Expand All @@ -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,
) {
Expand All @@ -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 },
)
}
}
}
Expand Down Expand Up @@ -228,7 +232,7 @@ private fun PreviewPinAttempts() {
onSuccess = {},
isBiometricsEnabled = false,
isBiometrySupported = true,
showLogoOnPin = true,
showLogoOnPin = false,
validatePin = { true },
attemptsRemaining = 6,
)
Expand Down
44 changes: 40 additions & 4 deletions app/src/main/java/to/bitkit/ui/components/BiometricsView.kt
Original file line number Diff line number Diff line change
@@ -1,31 +1,59 @@
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
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,
Expand All @@ -40,3 +68,11 @@ fun BiometricsView(
)
}
}

@Preview(showBackground = true)
@Composable
private fun Preview() {
AppThemeSurface {
BiometricsView()
}
}
36 changes: 34 additions & 2 deletions app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -32,6 +36,7 @@ import to.bitkit.ui.settings.pin.PinNavigationSheet
@Composable
fun SecuritySettingsScreen(
navController: NavController,
savedStateHandle: SavedStateHandle,
) {
val app = appViewModel ?: return

Expand All @@ -40,6 +45,24 @@ fun SecuritySettingsScreen(
val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle()
val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle()

LaunchedEffect(savedStateHandle) {
savedStateHandle.getStateFlow<String?>(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<String>(AuthCheckAction.KEY)
}
}
}

PinNavigationSheet(
showSheet = showPinSheet,
showLaterButton = false,
Expand All @@ -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() },
)
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ 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
import to.bitkit.R
import to.bitkit.utils.BiometricCrypto
import to.bitkit.utils.Logger

private val biometricCrypto = BiometricCrypto()

@Composable
fun BiometricPrompt(
onSuccess: () -> Unit,
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down