diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cddec1481..578d5b897 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,6 +142,7 @@ dependencies { implementation(libs.material) implementation(libs.datastore.preferences) implementation(libs.kotlinx.datetime) + implementation(libs.biometric) implementation(libs.zxing) implementation(libs.barcode.scanning) // CameraX diff --git a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt index c1b2eb8b6..1e0a53d9d 100644 --- a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt +++ b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt @@ -99,6 +99,16 @@ class KeychainTest : BaseAndroidTest() { assertTrue { sut.snapshot.asMap().isEmpty() } } + @Test + fun pinAttemptsRemaining_shouldReturnDecryptedValue() = test { + val attemptsRemaining = "3" + sut.saveString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, attemptsRemaining) + + val result = sut.pinAttemptsRemaining().first() + + assertEquals(attemptsRemaining, result.toString()) + } + @After fun tearDown() { db.close() diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 979f47393..cbb617c1b 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -47,17 +47,17 @@ class SettingsStore @Inject constructor( store.edit { it[SELECTED_CURRENCY_KEY] = currency } } - val showEmptyState: Flow = store.data.map { it[SHOW_EMPTY_STATE] ?: false } + val showEmptyState: Flow = store.data.map { it[SHOW_EMPTY_STATE] == true } suspend fun setShowEmptyState(show: Boolean) { store.edit { it[SHOW_EMPTY_STATE] = show } } - val hasSeenSpendingIntro: Flow = store.data.map { it[HAS_SEEN_SPENDING_INTRO] ?: false } + val hasSeenSpendingIntro: Flow = store.data.map { it[HAS_SEEN_SPENDING_INTRO] == true } suspend fun setHasSeenSpendingIntro(value: Boolean) { store.edit { it[HAS_SEEN_SPENDING_INTRO] = value } } - val hasSeenSavingsIntro: Flow = store.data.map { it[HAS_SEEN_SAVINGS_INTRO] ?: false } + val hasSeenSavingsIntro: Flow = store.data.map { it[HAS_SEEN_SAVINGS_INTRO] == true } suspend fun setHasSeenSavingsIntro(value: Boolean) { store.edit { it[HAS_SEEN_SAVINGS_INTRO] = value } } @@ -67,6 +67,15 @@ class SettingsStore @Inject constructor( store.edit { it[LIGHTNING_SETUP_STEP] = value } } + val isPinEnabled: Flow = store.data.map { it[IS_PIN_ENABLED] == true } + suspend fun setIsPinEnabled(value: Boolean) { store.edit { it[IS_PIN_ENABLED] = value } } + + val isPinOnLaunchEnabled: Flow = store.data.map { it[IS_PIN_ON_LAUNCH_ENABLED] == true } + suspend fun setIsPinOnLaunchEnabled(value: Boolean) { store.edit { it[IS_PIN_ON_LAUNCH_ENABLED] = value } } + + val isBiometricEnabled: Flow = store.data.map { it[IS_BIOMETRIC_ENABLED] == true } + suspend fun setIsBiometricEnabled(value: Boolean) { store.edit { it[IS_BIOMETRIC_ENABLED] = value } } + private companion object { private val PRIMARY_DISPLAY_UNIT_KEY = stringPreferencesKey("primary_display_unit") private val BTC_DISPLAY_UNIT_KEY = stringPreferencesKey("btc_display_unit") @@ -75,5 +84,8 @@ class SettingsStore @Inject constructor( private val HAS_SEEN_SPENDING_INTRO = booleanPreferencesKey("has_seen_spending_intro") private val HAS_SEEN_SAVINGS_INTRO = booleanPreferencesKey("has_seen_savings_intro") private val LIGHTNING_SETUP_STEP = intPreferencesKey("lightning_setup_step") + private val IS_PIN_ENABLED = booleanPreferencesKey("is_pin_enabled") + private val IS_PIN_ON_LAUNCH_ENABLED = booleanPreferencesKey("is_pin_on_launch_enabled") + private val IS_BIOMETRIC_ENABLED = booleanPreferencesKey("is_biometric_enabled") } } diff --git a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt index 46d5b2dc2..8f9226669 100644 --- a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt @@ -32,11 +32,11 @@ class AndroidKeyStore( if (!keyStore.containsAlias(alias)) { try { val generator = KeyGenerator.getInstance(algorithm, type) - generator.init(buildSpec(true)) + generator.init(buildSpec(isStrongboxBacked = true)) generator.generateKey() - } catch (e: StrongBoxUnavailableException) { + } catch (_: StrongBoxUnavailableException) { val generator = KeyGenerator.getInstance(algorithm, type) - generator.init(buildSpec(false)) + generator.init(buildSpec(isStrongboxBacked = false)) generator.generateKey() } } diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index b832e910e..230eb0da3 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import org.lightningdevkit.ldknode.Network @@ -32,7 +33,9 @@ class Keychain @Inject constructor( private val keyStore by lazy { AndroidKeyStore(alias) } private val Context.keychain by preferencesDataStore(alias, scope = this) - val snapshot get() = runBlocking(this.coroutineContext) { context.keychain.data.first() } + private val keychain = context.keychain + + val snapshot get() = runBlocking(this.coroutineContext) { keychain.data.first() } fun loadString(key: String): String? = load(key)?.decodeToString() @@ -41,7 +44,7 @@ class Keychain @Inject constructor( return snapshot[key.indexed]?.fromBase64()?.let { keyStore.decrypt(it) } - } catch (e: Exception) { + } catch (_: Exception) { throw KeychainError.FailedToLoad(key) } } @@ -53,17 +56,28 @@ class Keychain @Inject constructor( try { val encryptedValue = keyStore.encrypt(value) - context.keychain.edit { it[key.indexed] = encryptedValue.toBase64() } - } catch (e: Exception) { + keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + } catch (_: Exception) { throw KeychainError.FailedToSave(key) } Logger.info("Saved to keychain: $key") } + /** Inserts or replaces a string value associated with a given key in the keychain. */ + suspend fun upsertString(key: String, value: String) { + try { + val encryptedValue = keyStore.encrypt(value.toByteArray()) + keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + } catch (_: Exception) { + throw KeychainError.FailedToSave(key) + } + Logger.info("Saved/updated in keychain: $key") + } + suspend fun delete(key: String) { try { - context.keychain.edit { it.remove(key.indexed) } - } catch (e: Exception) { + keychain.edit { it.remove(key.indexed) } + } catch (_: Exception) { throw KeychainError.FailedToDelete(key) } Logger.debug("Deleted from keychain: $key") @@ -73,13 +87,11 @@ class Keychain @Inject constructor( return snapshot.contains(key.indexed) } - fun observeExists(key: Key): Flow = context.keychain.data.map { it.contains(key.name.indexed) } - suspend fun wipe() { if (Env.network != Network.REGTEST) throw KeychainError.KeychainWipeNotAllowed() val keys = snapshot.asMap().keys - context.keychain.edit { it.clear() } + keychain.edit { it.clear() } Logger.info("Deleted all keychain entries: ${keys.joinToString()}") } @@ -90,10 +102,24 @@ class Keychain @Inject constructor( return "${this}_$walletIndex".let(::stringPreferencesKey) } + fun pinAttemptsRemaining(): Flow { + return keychain.data + .map { it[Key.PIN_ATTEMPTS_REMAINING.name.indexed] } + .distinctUntilChanged() + .map { encrypted -> + encrypted?.fromBase64()?.let { bytes -> + keyStore.decrypt(bytes).decodeToString() + } + } + .map { string -> string?.toIntOrNull() } + } + enum class Key { PUSH_NOTIFICATION_TOKEN, PUSH_NOTIFICATION_PRIVATE_KEY, BIP39_MNEMONIC, - BIP39_PASSPHRASE; + BIP39_PASSPHRASE, + PIN, + PIN_ATTEMPTS_REMAINING, } } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 017ca29e5..ff2f5e1a7 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -102,4 +102,7 @@ internal object Env { address = "34.65.86.104:9400", ) } + + const val PIN_LENGTH = 4 + const val PIN_ATTEMPTS = 8 } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b6cc35039..5ea7e6a5f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -75,6 +75,7 @@ import to.bitkit.ui.settings.GeneralSettingsScreen import to.bitkit.ui.settings.LightningSettingsScreen import to.bitkit.ui.settings.LocalCurrencySettingsScreen import to.bitkit.ui.settings.OrderDetailScreen +import to.bitkit.ui.settings.SecuritySettingsScreen import to.bitkit.ui.settings.SettingsScreen import to.bitkit.ui.settings.backups.BackupWalletScreen import to.bitkit.ui.settings.backups.RestoreWalletScreen @@ -228,6 +229,7 @@ fun ContentView( settings(walletViewModel, navController) nodeState(walletViewModel, navController) generalSettings(navController) + securitySettings(navController) defaultUnitSettings(currencyViewModel, navController) localCurrencySettings(currencyViewModel, navController) backupSettings(navController) @@ -470,6 +472,12 @@ private fun NavGraphBuilder.generalSettings(navController: NavHostController) { } } +private fun NavGraphBuilder.securitySettings(navController: NavHostController) { + composableWithDefaultTransitions { + SecuritySettingsScreen(navController) + } +} + private fun NavGraphBuilder.defaultUnitSettings( currencyViewModel: CurrencyViewModel, navController: NavHostController, @@ -659,6 +667,10 @@ fun NavController.navigateToGeneralSettings() = navigate( route = Routes.GeneralSettings, ) +fun NavController.navigateToSecuritySettings() = navigate( + route = Routes.SecuritySettings, +) + fun NavController.navigateToDefaultUnitSettings() = navigate( route = Routes.DefaultUnitSettings, ) @@ -749,6 +761,9 @@ object Routes { @Serializable data object GeneralSettings + @Serializable + data object SecuritySettings + @Serializable data object DefaultUnitSettings diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 409fcca31..f60018dd3 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -1,11 +1,13 @@ package to.bitkit.ui import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -13,6 +15,7 @@ import androidx.navigation.toRoute import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.ToastOverlay import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen import to.bitkit.ui.onboarding.IntroScreen @@ -36,7 +39,7 @@ import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { private val appViewModel by viewModels() private val walletViewModel by viewModels() private val blocktankViewModel by viewModels() @@ -168,14 +171,24 @@ class MainActivity : ComponentActivity() { } } } else { - ContentView( - appViewModel = appViewModel, - walletViewModel = walletViewModel, - blocktankViewModel = blocktankViewModel, - currencyViewModel = currencyViewModel, - activityListViewModel = activityListViewModel, - transferViewModel = transferViewModel, - ) + val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() + + if (!isAuthenticated) { + AuthCheckView( + showLogoOnPin = true, + appViewModel = appViewModel, + onSuccess = { appViewModel.setIsAuthenticated(true) }, + ) + } else { + ContentView( + appViewModel = appViewModel, + walletViewModel = walletViewModel, + blocktankViewModel = blocktankViewModel, + currencyViewModel = currencyViewModel, + activityListViewModel = activityListViewModel, + transferViewModel = transferViewModel, + ) + } } ToastOverlay( diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt new file mode 100644 index 000000000..7aa66b06f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -0,0 +1,251 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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 +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberBiometricAuthSupported +import to.bitkit.viewmodels.AppViewModel + +@Composable +fun AuthCheckView( + showLogoOnPin: Boolean, + appViewModel: AppViewModel, + isBiometrySupported: Boolean = rememberBiometricAuthSupported(), + onSuccess: (() -> Unit)? = null, +) { + val isBiometricsEnabled by appViewModel.isBiometricEnabled.collectAsStateWithLifecycle() + val attemptsRemaining by appViewModel.pinAttemptsRemaining.collectAsStateWithLifecycle() + + AuthCheckViewContent( + isBiometricsEnabled = isBiometricsEnabled, + isBiometrySupported = isBiometrySupported, + showLogoOnPin = showLogoOnPin, + attemptsRemaining = attemptsRemaining, + validatePin = appViewModel::validatePin, + onSuccess = onSuccess, + ) +} + +@Composable +private fun AuthCheckViewContent( + isBiometricsEnabled: Boolean, + isBiometrySupported: Boolean, + showLogoOnPin: Boolean, + attemptsRemaining: Int, + validatePin: (String) -> Boolean, + onSuccess: (() -> Unit)? = null, +) { + var showBio by rememberSaveable { mutableStateOf(isBiometricsEnabled) } + + LaunchedEffect(isBiometricsEnabled) { + showBio = isBiometricsEnabled + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .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 }, + ) + } + } + } +} + +@Composable +private fun PinPad( + showLogo: Boolean = false, + validatePin: (String) -> Boolean, + onSuccess: (() -> Unit)?, + attemptsRemaining: Int, + allowBiometrics: Boolean, + onShowBiometrics: () -> Unit, +) { + var pin by remember { mutableStateOf("") } + val isLastAttempt = attemptsRemaining == 1 + + LaunchedEffect(pin) { + if (pin.length == Env.PIN_LENGTH) { + if (validatePin(pin)) { + onSuccess?.invoke() + } + pin = "" + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier.weight(1f) + ) { + if (showLogo) { + Image( + painter = painterResource(R.drawable.bitkit_logo), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(280.dp) + ) + } + } + Subtitle(text = stringResource(R.string.security__pin_enter), modifier = Modifier.padding(bottom = 32.dp)) + + if (attemptsRemaining < Env.PIN_ATTEMPTS) { + if (isLastAttempt) { + BodyS( + text = stringResource(R.string.security__pin_last_attempt), + color = Colors.Brand, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } else { + // TODO: onClick: show forgotPin sheet + BodyS( + text = stringResource(R.string.security__pin_attempts).replace("{attemptsRemaining}", "$attemptsRemaining"), + color = Colors.Brand, + textAlign = TextAlign.Center, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (allowBiometrics) { + val biometricsName = stringResource(R.string.security__bio) + PrimaryButton( + text = stringResource(R.string.security__pin_use_biometrics).replace("{biometricsName}", biometricsName), + onClick = onShowBiometrics, + fullWidth = false, + size = ButtonSize.Small, + icon = { + Icon( + painter = painterResource(R.drawable.ic_fingerprint), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(16.dp) + ) + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + PinDots( + pin = pin, + modifier = Modifier.padding(vertical = 16.dp), + ) + PinNumberPad( + modifier = Modifier.height(310.dp), + onPress = { key -> + if (key == KEY_DELETE) { + if (pin.isNotEmpty()) { + pin = pin.dropLast(1) + } + } else if (pin.length < Env.PIN_LENGTH) { + pin += key + } + }, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewBio() { + AppThemeSurface { + AuthCheckViewContent( + onSuccess = {}, + isBiometricsEnabled = true, + isBiometrySupported = true, + showLogoOnPin = true, + validatePin = { true }, + attemptsRemaining = 8, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPin() { + AppThemeSurface { + AuthCheckViewContent( + onSuccess = {}, + isBiometricsEnabled = false, + isBiometrySupported = true, + showLogoOnPin = true, + validatePin = { true }, + attemptsRemaining = 8, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPinAttempts() { + AppThemeSurface { + AuthCheckViewContent( + onSuccess = {}, + isBiometricsEnabled = false, + isBiometrySupported = true, + showLogoOnPin = true, + validatePin = { true }, + attemptsRemaining = 6, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPinAttemptLast() { + AppThemeSurface { + AuthCheckViewContent( + onSuccess = {}, + isBiometricsEnabled = false, + isBiometrySupported = true, + showLogoOnPin = true, + validatePin = { true }, + attemptsRemaining = 1, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt b/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt new file mode 100644 index 000000000..87e36fbc3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/BiometricsView.kt @@ -0,0 +1,42 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.utils.BiometricPrompt + +@Composable +fun BiometricsView( + onSuccess: (() -> Unit)? = null, + onFailure: (() -> Unit)? = null, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BiometricPrompt( + onSuccess = { onSuccess?.invoke() }, + onError = { onFailure?.invoke() }, + ) + Icon( + painter = painterResource(R.drawable.ic_fingerprint), + contentDescription = null, + modifier = Modifier.size(64.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Subtitle( + text = run { + val biometricsName = stringResource(R.string.security__bio) + stringResource(R.string.security__bio_auth_with).replace("{biometricsName}", biometricsName) + } + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/PinDots.kt b/app/src/main/java/to/bitkit/ui/components/PinDots.kt new file mode 100644 index 000000000..cdebbbe57 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PinDots.kt @@ -0,0 +1,40 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import to.bitkit.env.Env +import to.bitkit.ui.theme.Colors + +@Composable +fun PinDots( + pin: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + repeat(Env.PIN_LENGTH) { index -> + Box( + modifier = Modifier + .padding(horizontal = 12.dp) + .size(20.dp) + .clip(CircleShape) + .border(1.dp, Colors.Brand, CircleShape) + .background(if (index < pin.length) Colors.Brand else Colors.Brand08) + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/PinNumberPad.kt b/app/src/main/java/to/bitkit/ui/components/PinNumberPad.kt new file mode 100644 index 000000000..8cef870df --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PinNumberPad.kt @@ -0,0 +1,131 @@ +package to.bitkit.ui.components + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +const val KEY_DELETE = "delete" + +private val matrix = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), +) + +@Composable +private fun NumberButton( + text: String, + onPress: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .clickableAlpha(0.2f) { onPress() } + ) { + Text( + text = text, + fontSize = 24.sp, + textAlign = TextAlign.Center, + color = Colors.White, + ) + } +} + +@Composable +fun PinNumberPad( + onPress: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + + Column( + modifier = modifier.fillMaxSize() + ) { + matrix.forEach { row -> + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + row.forEach { number -> + NumberButton( + text = number, + onPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onPress(number) + }, + modifier = Modifier.weight(1f) + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Box(modifier = Modifier.weight(1f)) + NumberButton( + text = "0", + onPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onPress("0") + }, + modifier = Modifier.weight(1f) + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .clickableAlpha(0.2f) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onPress(KEY_DELETE) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_backspace), + contentDescription = stringResource(R.string.common__delete), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + PinNumberPad( + onPress = {}, + modifier = Modifier.height(310.dp) + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index 7147f5f8e..10c15df95 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -203,11 +203,13 @@ fun BodyS( text: String, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, + textAlign: TextAlign = TextAlign.Start, ) { BodyS( text = AnnotatedString(text), modifier = modifier, - color = color + color = color, + textAlign = textAlign, ) } @@ -216,6 +218,7 @@ fun BodyS( text: AnnotatedString, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, + textAlign: TextAlign = TextAlign.Start, ) { Text( text = text, @@ -226,7 +229,7 @@ fun BodyS( letterSpacing = 0.4.sp, fontFamily = InterFontFamily, color = color, - textAlign = TextAlign.Start, + textAlign = textAlign, ), modifier = modifier, ) diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt new file mode 100644 index 000000000..21f5166b6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt @@ -0,0 +1,81 @@ +package to.bitkit.ui.components.settings + +import androidx.compose.foundation.layout.Arrangement +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.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun SettingsButtonRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, +) { + Column( + modifier = Modifier.height(52.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .clickableAlpha { onClick() } + ) { + BodyM(text = title, color = Colors.White) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + value?.let { + BodyM(text = it, color = Colors.White) + Spacer(modifier = Modifier.width(4.dp)) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp) + ) + } + } + HorizontalDivider(color = Colors.White10) + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Column(modifier = Modifier.padding(16.dp)) { + SettingsButtonRow( + title = "Setting Button", + value = "Enabled", + onClick = {}, + ) + SettingsButtonRow( + title = "Setting Button Without Value", + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt new file mode 100644 index 000000000..790e827e3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt @@ -0,0 +1,69 @@ +package to.bitkit.ui.components.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppSwitchDefaults +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun SettingsSwitchRow( + title: String, + isChecked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier.height(52.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickableAlpha { onClick() } + .padding(vertical = 16.dp) + ) { + BodyM(text = title, color = Colors.White) + + Switch( + checked = isChecked, + onCheckedChange = null, // handled by parent + colors = AppSwitchDefaults.colors, + ) + } + HorizontalDivider(color = Colors.White10) + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Column(modifier = Modifier.padding(16.dp)) { + SettingsSwitchRow( + title = "Setting 1", + isChecked = true, + onClick = {}, + ) + SettingsSwitchRow( + title = "Setting 2", + isChecked = false, + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt index c2067ffd9..d3b2bdeba 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -67,3 +68,14 @@ private fun backNavIcon(onBackClick: () -> Unit) = @Composable { ) } } + +// TODO use everywhere +@Composable +fun CloseNavIcon(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.common__close), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt index 5f1d1800c..f4f9a485b 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt @@ -32,10 +32,11 @@ import to.bitkit.ui.theme.AppThemeSurface @OptIn(ExperimentalMaterial3Api::class) fun SheetTopBar( titleText: String, + modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, ) { Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(42.dp) ) { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt index 577ef0e6c..dff67611d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt @@ -119,7 +119,7 @@ private fun ExternalConfirmContent( .fillMaxHeight() .weight(1f) .padding(top = 16.dp) - .clickableAlpha(onNetworkFeeClick) + .clickableAlpha(onClick = onNetworkFeeClick) ) { Caption13Up( text = stringResource(R.string.lightning__spending_confirm__network_fee), diff --git a/app/src/main/java/to/bitkit/ui/settings/GeneralSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/GeneralSettingsScreen.kt index 496125823..adf307adc 100644 --- a/app/src/main/java/to/bitkit/ui/settings/GeneralSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/GeneralSettingsScreen.kt @@ -22,7 +22,7 @@ fun GeneralSettingsScreen( navController: NavController, ) { ScreenColumn { - AppTopBar(stringResource(R.string.general), onBackClick = { navController.popBackStack() }) + AppTopBar(stringResource(R.string.settings__general_title), onBackClick = { navController.popBackStack() }) Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt new file mode 100644 index 000000000..e688c4730 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt @@ -0,0 +1,141 @@ +package to.bitkit.ui.settings + +import androidx.compose.foundation.layout.Column +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import to.bitkit.R +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.navigateToHome +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.CloseNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberBiometricAuthSupported +import to.bitkit.ui.settings.pin.PinNavigationSheet + +@Composable +fun SecuritySettingsScreen( + navController: NavController, +) { + val app = appViewModel ?: return + + var showPinSheet by remember { mutableStateOf(false) } + val isPinEnabled by app.isPinEnabled.collectAsStateWithLifecycle() + val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle() + val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() + + PinNavigationSheet( + showSheet = showPinSheet, + showLaterButton = false, + onDismiss = { showPinSheet = false }, + ) { + SecuritySettingsContent( + isPinEnabled = isPinEnabled, + isPinOnLaunchEnabled = isPinOnLaunchEnabled, + isBiometricEnabled = isBiometricEnabled, + isBiometrySupported = rememberBiometricAuthSupported(), + onPinClick = { + if (!isPinEnabled) { + showPinSheet = true + } else { + // TODO show Disable Pin screen + app.removePin() + } + }, + onPinOnLaunchClick = { app.setIsPinOnLaunchEnabled(!isPinOnLaunchEnabled) }, // TODO auth check + onUseBiometricsClick = { app.setIsBiometricEnabled(!isBiometricEnabled) }, // TODO auth check + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.navigateToHome() }, + ) + } +} + +@Composable +private fun SecuritySettingsContent( + isPinEnabled: Boolean, + isPinOnLaunchEnabled: Boolean, + isBiometricEnabled: Boolean, + isBiometrySupported: Boolean, + onPinClick: () -> Unit = {}, + onPinOnLaunchClick: () -> Unit = {}, + onUseBiometricsClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onCloseClick: () -> Unit = {}, +) { + ScreenColumn( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + AppTopBar( + titleText = stringResource(R.string.settings__security_title), + onBackClick = onBackClick, + actions = { CloseNavIcon(onClick = onCloseClick) }, + ) + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SettingsButtonRow( + title = stringResource(R.string.settings__security__pin), + value = stringResource( + if (isPinEnabled) R.string.settings__security__pin_enabled else R.string.settings__security__pin_disabled + ), + onClick = onPinClick, + ) + if (isPinEnabled) { + SettingsSwitchRow( + title = stringResource(R.string.settings__security__pin_launch), + isChecked = isPinOnLaunchEnabled, + onClick = onPinOnLaunchClick, + ) + } + if (isPinEnabled && isBiometrySupported) { + SettingsSwitchRow( + title = let { + val bioTypeName = stringResource(R.string.security__bio) + stringResource(R.string.settings__security__use_bio).replace("{biometryTypeName}", bioTypeName) + }, + isChecked = isBiometricEnabled, + onClick = onUseBiometricsClick, + ) + } + if (isPinEnabled && isBiometrySupported) { + BodyS( + text = let { + val bioTypeName = stringResource(R.string.security__bio) + stringResource(R.string.settings__security__footer).replace("{biometryTypeName}", bioTypeName) + }, + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + } + } +} + +@Preview +@Composable +fun Preview() { + AppThemeSurface { + SecuritySettingsContent( + isPinEnabled = true, + isPinOnLaunchEnabled = true, + isBiometricEnabled = false, + isBiometrySupported = true, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index cb06791d8..a183de1df 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -23,6 +23,7 @@ import to.bitkit.ui.navigateToDevSettings import to.bitkit.ui.navigateToGeneralSettings import to.bitkit.ui.navigateToLightning import to.bitkit.ui.navigateToRegtestSettings +import to.bitkit.ui.navigateToSecuritySettings import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScreenColumn @@ -40,7 +41,8 @@ fun SettingsScreen( .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) { - NavButton(stringResource(R.string.general)) { navController.navigateToGeneralSettings() } + NavButton(stringResource(R.string.settings__general_title)) { navController.navigateToGeneralSettings() } + NavButton(stringResource(R.string.settings__security_title)) { navController.navigateToSecuritySettings() } NavButton(stringResource(R.string.button_backup_settings)) { navController.navigateToBackupSettings() } NavButton("Lightning") { navController.navigateToLightning() } NavButton("Channel Orders") { navController.navigateToChannelOrdersSettings() } diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/AskForBiometricsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/AskForBiometricsScreen.kt new file mode 100644 index 000000000..d0998db3b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/pin/AskForBiometricsScreen.kt @@ -0,0 +1,241 @@ +package to.bitkit.ui.settings.pin + +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +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.platform.LocalContext +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 to.bitkit.R +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppSwitchDefaults +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.BiometricPrompt +import to.bitkit.ui.utils.rememberBiometricAuthSupported + +@Composable +fun AskForBiometricsScreen( + onContinue: (isBioOn: Boolean) -> Unit, + onSkip: () -> Unit, + onBack: () -> Unit, +) { + val app = appViewModel ?: return + val isBiometrySupported = rememberBiometricAuthSupported() + var showBiometricPrompt by remember { mutableStateOf(false) } + + BackHandler { onBack() } + + AskForBiometricsContent( + isBiometrySupported = isBiometrySupported, + onSkip = onSkip, + onContinue = { shouldEnableBiometrics -> + if (shouldEnableBiometrics) { + showBiometricPrompt = true + } else { + onContinue(false) + } + }, + ) + + if (showBiometricPrompt) { + BiometricPrompt( + onSuccess = { + app.setIsBiometricEnabled(true) + onContinue(true) + }, + onError = { + showBiometricPrompt = false + }, + cancelButtonText = stringResource(R.string.common__cancel), + ) + } +} + +@Composable +private fun AskForBiometricsContent( + isBiometrySupported: Boolean, + onContinue: (Boolean) -> Unit, + onSkip: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + SheetTopBar(stringResource(R.string.security__bio)) + + Spacer(modifier = Modifier.height(16.dp)) + + if (!isBiometrySupported) { + BioNotAvailableView(onSkip = onSkip) + } else { + var shouldEnableBiometrics by remember { mutableStateOf(false) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f) + ) { + BodyM( + text = run { + val biometricsName = stringResource(R.string.security__bio) + stringResource(R.string.security__bio_ask).replace("{biometricsName}", biometricsName) + }, + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + painter = painterResource(R.drawable.ic_touch_id), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(134.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickableAlpha { shouldEnableBiometrics = !shouldEnableBiometrics } + ) { + BodyMSB( + text = run { + val biometricsName = stringResource(R.string.security__bio) + stringResource(R.string.security__bio_use).replace("{biometricsName}", biometricsName) + }, + ) + Switch( + checked = shouldEnableBiometrics, + onCheckedChange = null, // handled by parent + colors = AppSwitchDefaults.colors, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { + onContinue(shouldEnableBiometrics) + }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun ColumnScope.BioNotAvailableView( + onSkip: () -> Unit, +) { + val context = LocalContext.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f) + ) { + BodyM( + text = stringResource(R.string.security__bio_not_available), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.cog), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .aspectRatio(1f), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.common__skip), + onClick = onSkip, + modifier = Modifier.weight(1f), + ) + + PrimaryButton( + text = stringResource(R.string.security__bio_phone_settings), + onClick = { + val intent = Intent(Settings.ACTION_SETTINGS) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + AskForBiometricsContent( + isBiometrySupported = true, + onContinue = {}, + onSkip = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewNoBio() { + AppThemeSurface { + AskForBiometricsContent( + isBiometrySupported = false, + onContinue = {}, + onSkip = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ChoosePinScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ChoosePinScreen.kt new file mode 100644 index 000000000..23d08281f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ChoosePinScreen.kt @@ -0,0 +1,95 @@ +package to.bitkit.ui.settings.pin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.PinNumberPad +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun ChoosePinScreen( + onPinChosen: (String) -> Unit, + onBack: () -> Unit, +) { + var pin by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxWidth() + .gradientBackground() + .navigationBarsPadding() + + ) { + SheetTopBar(titleText = stringResource(R.string.security__pin_choose_header), onBack = onBack) + + Spacer(modifier = Modifier.height(16.dp)) + + BodyM( + text = stringResource(R.string.security__pin_choose_text), + color = Colors.White64, + modifier = Modifier.padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.weight(1f)) + + PinDots( + pin = pin, + modifier = Modifier.padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + PinNumberPad( + onPress = { key -> + if (key == KEY_DELETE) { + if (pin.isNotEmpty()) { + pin = pin.dropLast(1) + } + } else if (pin.length < Env.PIN_LENGTH) { + pin += key + if (pin.length == Env.PIN_LENGTH) { + onPinChosen(pin) + } + } + }, + modifier = Modifier + .height(350.dp) + .background(Colors.Black) + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + ChoosePinScreen( + onPinChosen = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ConfirmPinScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ConfirmPinScreen.kt new file mode 100644 index 000000000..ae9907fa6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ConfirmPinScreen.kt @@ -0,0 +1,161 @@ +package to.bitkit.ui.settings.pin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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 +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.PinNumberPad +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun ConfirmPinScreen( + originalPin: String, + onPinConfirmed: () -> Unit, + onBack: () -> Unit, +) { + val app = appViewModel ?: return + + var pin by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + + LaunchedEffect(pin) { + if (pin.length == Env.PIN_LENGTH) { + if (pin == originalPin) { + app.addPin(pin) + onPinConfirmed() + } else { + showError = true + delay(500) + pin = "" + } + } + } + + ConfirmPinContent( + pin = pin, + showError = showError, + onKeyPress = { key -> + if (key == KEY_DELETE) { + if (pin.isNotEmpty()) { + pin = pin.dropLast(1) + } + } else if (pin.length < Env.PIN_LENGTH) { + pin = pin + key + } + }, + onBack = onBack, + ) +} + +@Composable +private fun ConfirmPinContent( + pin: String, + showError: Boolean, + onKeyPress: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .gradientBackground() + .navigationBarsPadding() + ) { + SheetTopBar( + stringResource(R.string.security__pin_retype_header), + onBack = onBack, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BodyM( + text = stringResource(R.string.security__pin_retype_text), + color = Colors.White64, + modifier = Modifier.padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.weight(1f)) + + AnimatedVisibility(visible = showError) { + BodyS( + text = stringResource(R.string.security__pin_not_match), + textAlign = TextAlign.Center, + color = Colors.Brand, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + PinDots( + pin = pin, + modifier = Modifier.padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + PinNumberPad( + onPress = onKeyPress, + modifier = Modifier + .height(350.dp) + .background(Colors.Black) + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + ConfirmPinContent( + pin = "", + showError = false, + onKeyPress = {}, + onBack = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewRetry() { + AppThemeSurface { + ConfirmPinContent( + pin = "123", + showError = true, + onKeyPress = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt new file mode 100644 index 000000000..3f106e89e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt @@ -0,0 +1,107 @@ +package to.bitkit.ui.settings.pin + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.SheetHost + +@Composable +fun PinNavigationSheet( + showSheet: Boolean, + showLaterButton: Boolean = true, + onDismiss: () -> Unit = {}, + content: @Composable () -> Unit, +) { + val app = appViewModel ?: return + val navController = rememberNavController() + + SheetHost( + shouldExpand = showSheet, + onDismiss = { + navController.popBackStack(PinRoute.PinPrompt, inclusive = false) + onDismiss() + }, + sheets = { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(.725f) + ) { + NavHost( + navController = navController, + startDestination = PinRoute.PinPrompt, + ) { + composable { + PinPromptScreen( + showLaterButton = showLaterButton, + onContinue = { navController.navigate(PinRoute.ChoosePin) }, + onLater = onDismiss, + ) + } + composable { + ChoosePinScreen( + onPinChosen = { pin -> + navController.navigate(PinRoute.ConfirmPin(pin)) + }, + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + ConfirmPinScreen( + originalPin = route.pin, + onPinConfirmed = { + navController.navigate(PinRoute.AskForBiometrics) + }, + onBack = { navController.popBackStack() }, + ) + } + composable { + AskForBiometricsScreen( + onContinue = { isBioOn -> + navController.navigate(PinRoute.Result(isBioOn)) + }, + onSkip = { navController.navigate(PinRoute.Result(isBioOn = false)) }, + onBack = onDismiss, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + PinResultScreen( + isBioOn = route.isBioOn, + onDismiss = onDismiss, + onBack = onDismiss, + ) + } + } + } + }, + content = content, + ) +} + +@Serializable +sealed class PinRoute { + @Serializable + data object PinPrompt : PinRoute() + + @Serializable + data object ChoosePin : PinRoute() + + @Serializable + data class ConfirmPin(val pin: String) : PinRoute() + + @Serializable + data object AskForBiometrics : PinRoute() + + @Serializable + data class Result(val isBioOn: Boolean) : PinRoute() +} diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt new file mode 100644 index 000000000..faa1247c4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt @@ -0,0 +1,128 @@ +package to.bitkit.ui.settings.pin + +import androidx.compose.foundation.Image +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +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 to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun PinPromptScreen( + showLaterButton: Boolean = true, + onContinue: () -> Unit, + onLater: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + SheetTopBar(stringResource(R.string.security__pin_security_header)) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth(0.8f) + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) + .weight(1f) + ) { + Image( + painter = painterResource(id = R.drawable.shield), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } + + Display(text = stringResource(R.string.security__pin_security_title).withAccent(accentColor = Colors.Green)) + + Spacer(modifier = Modifier.height(8.dp)) + + BodyM( + text = stringResource(R.string.security__pin_security_text), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth() + ) { + if (showLaterButton) { + SecondaryButton( + text = stringResource(R.string.common__later), + onClick = onLater, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.width(16.dp)) + } + + PrimaryButton( + text = stringResource(R.string.security__pin_security_button), + onClick = onContinue, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + PinPromptScreen( + showLaterButton = false, + onContinue = {}, + onLater = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewWithLater() { + AppThemeSurface { + PinPromptScreen( + showLaterButton = true, + onContinue = {}, + onLater = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt new file mode 100644 index 000000000..744b2ffb1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt @@ -0,0 +1,156 @@ +package to.bitkit.ui.settings.pin + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Switch +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppSwitchDefaults +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun PinResultScreen( + isBioOn: Boolean, + onDismiss: () -> Unit, + onBack: () -> Unit, +) { + val app = appViewModel ?: return + var pinForPayments by remember { mutableStateOf(false) } + + BackHandler { onBack() } + + PinResultContent( + bio = isBioOn, + pinForPayments = pinForPayments, + onTogglePinForPayments = { + pinForPayments = !pinForPayments + // TODO: set pin for payments in settings store, etc. + app.toast(Exception("Pin for payments is not yet implemented")) + }, + onContinueClick = onDismiss, + ) +} + +@Composable +private fun PinResultContent( + bio: Boolean, + pinForPayments: Boolean, + onTogglePinForPayments: () -> Unit, + onContinueClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + SheetTopBar(stringResource(R.string.security__success_title)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f) + ) { + BodyM( + text = if (bio) { + stringResource(R.string.security__success_bio) + .replace("{biometricsName}", stringResource(R.string.security__bio)) + } else { + stringResource(R.string.security__success_no_bio) + }, + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier + .size(256.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickableAlpha { onTogglePinForPayments() } + ) { + BodyMSB(text = stringResource(R.string.security__success_payments)) + Switch( + checked = pinForPayments, + onCheckedChange = null, // handled by parent + colors = AppSwitchDefaults.colors, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onContinueClick, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + PinResultContent( + bio = true, + pinForPayments = true, + onTogglePinForPayments = {}, + onContinueClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewNoBio() { + AppThemeSurface { + PinResultContent( + bio = false, + pinForPayments = false, + onTogglePinForPayments = {}, + onContinueClick = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/shared/TabBar.kt b/app/src/main/java/to/bitkit/ui/shared/TabBar.kt index 83fbd5ce6..e6863738e 100644 --- a/app/src/main/java/to/bitkit/ui/shared/TabBar.kt +++ b/app/src/main/java/to/bitkit/ui/shared/TabBar.kt @@ -1,12 +1,10 @@ package to.bitkit.ui.shared -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,7 +16,6 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -88,20 +85,20 @@ fun TabBar( Text(text = stringResource(R.string.wallet__receive)) } } - IconButton( + Button( onClick = onScanClick, + shape = MaterialTheme.shapes.extraLarge, + colors = ButtonDefaults.buttonColors(containerColor = Colors.Gray6), + contentPadding = PaddingValues(0.dp), modifier = Modifier .size(80.dp) - .border(2.dp, buttonColors.containerColor, MaterialTheme.shapes.extraLarge) - .background(Colors.Gray6, MaterialTheme.shapes.extraLarge) + .border(2.dp, buttonColors.containerColor, MaterialTheme.shapes.extraLarge), ) { Icon( painter = painterResource(R.drawable.ic_scan), contentDescription = stringResource(R.string.wallet__recipient_scan), tint = Colors.Gray2, - modifier = Modifier - .fillMaxSize() - .padding(24.dp) + modifier = Modifier.size(32.dp) ) } } diff --git a/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt b/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt index 4a9283c92..1d99bb466 100644 --- a/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt +++ b/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt @@ -1,10 +1,13 @@ package to.bitkit.ui.shared.util +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -20,22 +23,41 @@ import to.bitkit.ui.theme.Colors * Analogue of `TouchableOpacity` in React Native. */ fun Modifier.clickableAlpha( + pressedAlpha: Float = 0.7f, onClick: (() -> Unit)?, ): Modifier = composed { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() + val wasClicked = remember { mutableStateOf(false) } + + LaunchedEffect(isPressed) { + if (!isPressed) { + wasClicked.value = false + } + } + + val alpha by animateFloatAsState( + targetValue = if (isPressed || wasClicked.value) pressedAlpha else 1f, + finishedListener = { + // Reset the clicked state after animation completes + wasClicked.value = false + } + ) + this - .graphicsLayer { this.alpha = if (isPressed) 0.7f else 1f } + .graphicsLayer { this.alpha = alpha } .clickable( enabled = onClick != null, - onClick = { onClick?.invoke() }, + onClick = { + wasClicked.value = true + onClick?.invoke() + }, interactionSource = interactionSource, indication = null, ) } - fun Modifier.gradientBackground(): Modifier { return this.background( brush = Brush.verticalGradient( diff --git a/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt b/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt new file mode 100644 index 000000000..64fb3247a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt @@ -0,0 +1,143 @@ +package to.bitkit.ui.utils + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +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.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, + onFailed: (() -> Unit)? = null, + onError: (() -> Unit)? = null, + onUnsupported: (() -> Unit)? = null, + cancelButtonText: String = stringResource(R.string.security__use_pin), +) { + val context = LocalContext.current + + val title = run { + val name = stringResource(R.string.security__bio) + stringResource(R.string.security__bio_confirm).replace("{biometricsName}", name) + } + + LaunchedEffect(Unit) { + verifyBiometric( + activity = context, + title = title, + cancelButtonText = cancelButtonText, + onAuthSucceeded = { + Logger.debug("Biometric auth succeeded") + onSuccess() + }, + onAuthFailed = { + Logger.debug("Biometric auth failed") + onFailed?.invoke() + }, + onAuthError = { errorCode, errorMessage -> + Logger.debug("Biometric auth error: code = '$errorCode', message = '$errorMessage'") + onError?.invoke() + }, + onUnsupported = { + Logger.debug("Biometric auth unsupported") + onUnsupported?.invoke() + }, + ) + } +} + +fun verifyBiometric( + activity: Context, + title: String, + cancelButtonText: String, + onAuthSucceeded: () -> Unit, + onAuthFailed: (() -> Unit), + onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit), + onUnsupported: () -> Unit, +) { + if (isBiometricAuthSupported(activity)) { + launchBiometricPrompt( + activity = activity, + title = title, + cancelButtonText = cancelButtonText, + onAuthSucceed = onAuthSucceeded, + onAuthFailed = onAuthFailed, + onAuthError = onAuthError, + ) + } else { + onUnsupported() + } +} + +fun isBiometricAuthSupported(context: Context): Boolean { + val biometricManager = BiometricManager.from(context) + return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> true + else -> false + } +} + +@Composable +fun rememberBiometricAuthSupported(context: Context = LocalContext.current): Boolean { + return remember(context) { isBiometricAuthSupported(context) } +} + +private fun launchBiometricPrompt( + activity: Context, + title: String, + cancelButtonText: String, + onAuthSucceed: () -> Unit, + onAuthFailed: (() -> Unit), + onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit), +) { + val executor = ContextCompat.getMainExecutor(activity) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setDescription(null) + .setConfirmationRequired(true) + .setNegativeButtonText(cancelButtonText) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + val biometricPrompt = BiometricPrompt( + activity as FragmentActivity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + if (biometricCrypto.validateCipher(result.cryptoObject)) { + onAuthSucceed() + } else { + onAuthFailed() + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onAuthError(errorCode, errString) + } + + override fun onAuthenticationFailed() { + onAuthFailed() + } + }, + ) + + try { + val cryptoObject = biometricCrypto.getCryptoObject() + biometricPrompt.authenticate(promptInfo, cryptoObject) + } catch (e: Exception) { + Logger.error("Failed to create crypto object", e) + onAuthError(-1, "Failed to initialize secure biometric prompt") + } +} diff --git a/app/src/main/java/to/bitkit/utils/BiometricCrypto.kt b/app/src/main/java/to/bitkit/utils/BiometricCrypto.kt new file mode 100644 index 000000000..78ae71881 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/BiometricCrypto.kt @@ -0,0 +1,60 @@ +package to.bitkit.utils + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.biometric.BiometricPrompt +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +class BiometricCrypto { + private val keystoreType = "AndroidKeyStore" + private val keyAlias = "bitkit_bio_key" + private val keyStore = KeyStore.getInstance(keystoreType).apply { load(null) } + + private val algorithm = KeyProperties.KEY_ALGORITHM_AES + private val blockMode = KeyProperties.BLOCK_MODE_GCM + private val padding = KeyProperties.ENCRYPTION_PADDING_NONE + private val transformation = "$algorithm/$blockMode/$padding" + + fun getCryptoObject(): BiometricPrompt.CryptoObject { + val cipher = Cipher.getInstance(transformation) + val key = getOrCreateKey() + + cipher.init(Cipher.ENCRYPT_MODE, key) + + return BiometricPrompt.CryptoObject(cipher) + } + + fun validateCipher(cryptoObject: BiometricPrompt.CryptoObject?): Boolean { + return try { + val cipher = cryptoObject?.cipher ?: return false + + // Try to update with empty data to verify cipher is properly initialized + cipher.updateAAD(ByteArray(0)) + true + } catch (_: Exception) { + false + } + } + + private fun getOrCreateKey(): SecretKey { + val existingKey = keyStore.getKey(keyAlias, null) as? SecretKey + if (existingKey != null) return existingKey + + val keyGenerator = KeyGenerator.getInstance(algorithm, keystoreType) + + val keyGenSpec = KeyGenParameterSpec + .Builder(keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(blockMode) + .setEncryptionPaddings(padding) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .setKeySize(256) + .build() + + keyGenerator.init(keyGenSpec) + return keyGenerator.generateKey() + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0eaf5da91..081792bb6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -22,6 +24,7 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.Txid import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env import to.bitkit.ext.WatchResult import to.bitkit.ext.removeSpaces import to.bitkit.ext.watchUntil @@ -105,6 +108,44 @@ class AppViewModel @Inject constructor( } } + val isPinEnabled: StateFlow = settingsStore.isPinEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun setIsPinEnabled(value: Boolean) { + viewModelScope.launch { + settingsStore.setIsPinEnabled(value) + } + } + + val isPinOnLaunchEnabled: StateFlow = settingsStore.isPinOnLaunchEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun setIsPinOnLaunchEnabled(value: Boolean) { + viewModelScope.launch { + settingsStore.setIsPinOnLaunchEnabled(value) + } + } + + val isBiometricEnabled: StateFlow = settingsStore.isBiometricEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun setIsBiometricEnabled(value: Boolean) { + viewModelScope.launch { + settingsStore.setIsBiometricEnabled(value) + } + } + + private val _isAuthenticated = MutableStateFlow(false) + val isAuthenticated = _isAuthenticated.asStateFlow() + + fun setIsAuthenticated(value: Boolean) { + _isAuthenticated.value = value + } + + val pinAttemptsRemaining = keychain.pinAttemptsRemaining() + .map { attempts -> attempts ?: Env.PIN_ATTEMPTS } + .stateIn(viewModelScope, SharingStarted.Lazily, Env.PIN_ATTEMPTS) + fun addTagToSelected(newTag: String) { _sendUiState.update { it.copy( @@ -132,6 +173,12 @@ class AppViewModel @Inject constructor( viewModelScope.launch { delay(1500) splashVisible = false + + // Check if auth is needed after splash screen + val needsAuth = isPinEnabled.first() && isPinOnLaunchEnabled.first() + if (!needsAuth) { + _isAuthenticated.value = true + } } observeLdkNodeEvents() @@ -730,6 +777,55 @@ class AppViewModel @Inject constructor( fun loadMnemonic(): String? { return keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) } + + // region security + fun validatePin(pin: String): Boolean { + val storedPin = keychain.loadString(Keychain.Key.PIN.name) + val isValid = storedPin == pin + + if (isValid) { + viewModelScope.launch { + keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) + } + return true + } + + viewModelScope.launch { + val newAttempts = pinAttemptsRemaining.value - 1 + keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, newAttempts.toString()) + + if (newAttempts <= 0) { + // TODO: wipeStorage() & return to onboarding + toast( + type = Toast.ToastType.WARNING, + title = "TODO: Wipe App data", + ) + } + } + return false + } + + fun addPin(pin: String) { + setIsPinEnabled(true) + + viewModelScope.launch { + keychain.upsertString(Keychain.Key.PIN.name, pin) + keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) + } + } + + fun removePin() { + setIsPinEnabled(false) + setIsPinOnLaunchEnabled(true) + setIsBiometricEnabled(false) + + viewModelScope.launch { + keychain.delete(Keychain.Key.PIN.name) + keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) + } + + } + // endregion } // region send contract diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 706db0971..74f329995 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -382,6 +382,7 @@ class WalletViewModel @Inject constructor( lightningService.wipeStorage(walletIndex = 0) appStorage.clear() keychain.wipe() + coreService.activity.removeAll() // todo: extract to repo & syncState after, like in removeAllActivities setWalletExistsState() }.onFailure { ToastEventBus.send(it) diff --git a/app/src/main/res/drawable/bitkit_logo.xml b/app/src/main/res/drawable/bitkit_logo.xml new file mode 100644 index 000000000..b08d6e241 --- /dev/null +++ b/app/src/main/res/drawable/bitkit_logo.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..34fa3bd19 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_fingerprint.xml b/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..0c94718e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_touch_id.xml b/app/src/main/res/drawable/ic_touch_id.xml new file mode 100644 index 000000000..ef45ab4de --- /dev/null +++ b/app/src/main/res/drawable/ic_touch_id.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings_app.xml b/app/src/main/res/values/strings_app.xml index aa60df720..3e6ae371f 100644 --- a/app/src/main/res/values/strings_app.xml +++ b/app/src/main/res/values/strings_app.xml @@ -25,7 +25,6 @@ Dev Settings Disconnect Enter Manually - General Home Invoice AMOUNT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80d6b026c..ae8ed836b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ activityCompose = "1.10.1" agp = "8.9.1" appcompat = "1.7.0" barcodeScanning = "17.3.0" +biometric = "1.4.0-alpha02" bouncyCastle = "1.79" camera = "1.4.2" composeBom = "2025.03.01" # https://developer.android.com/develop/ui/compose/bom/bom-mapping @@ -43,6 +44,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } +biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }