Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
51f905f
feat: Biometric prompt setup
ovitrif Mar 24, 2025
0ebaa52
feat: Security settings setup with pin and biometrics options
ovitrif Mar 25, 2025
574b70c
feat: Conditions for biometry settings wip
ovitrif Mar 26, 2025
b85799c
feat: Require biometrics auth on launch WIP
ovitrif Mar 26, 2025
0ca80b6
Merge branch 'master' into feat/biometrics-setup
ovitrif Mar 26, 2025
dba706b
fix: Condition for bio settings footer
ovitrif Mar 26, 2025
df605cb
Merge branch 'feat/small-fixes' into feat/biometrics-setup
ovitrif Apr 14, 2025
6034889
Merge branch 'master' into feat/biometrics-setup
ovitrif Apr 14, 2025
84c4cd3
refactor: Rename unlock to pinOnLaunch
ovitrif Apr 14, 2025
d651dac
ui: Fix tab bar scan button ripple
ovitrif Apr 14, 2025
d11dc6d
ui: Add alpha transitions on press
ovitrif Apr 14, 2025
ec629c4
feat: toggle to enable pin check
ovitrif Apr 14, 2025
5df1906
refactor: extract BiometricsView component
ovitrif Apr 15, 2025
9a47445
feat: Pin pad WIP
ovitrif Apr 15, 2025
8c8e831
ui: Dim clickable elements on tap, not only long press
ovitrif Apr 15, 2025
8cd4ae0
wip: Validate pin input against keychain
ovitrif Apr 16, 2025
04ee255
feat: Pin attempts remaining
ovitrif Apr 16, 2025
2019909
Merge branch 'master' into feat/biometrics-setup
ovitrif Apr 16, 2025
01dfcbc
feat: Bio prompt cancel button logic
ovitrif Apr 16, 2025
8f0bde4
feat: Button on pin pad to use biometrics
ovitrif Apr 16, 2025
da26633
feat: Use CryptoObject validation to prevent bypass via scripts
ovitrif Apr 16, 2025
c54edbe
feat: wip Enable pin & bio UI flow
ovitrif Apr 17, 2025
78d3263
feat: Ask for Biometrics Screen functional
ovitrif Apr 18, 2025
a2aaa0c
chore: Cleanup & fix comments
ovitrif Apr 18, 2025
bebae48
refactor: Confirm Pin screen
ovitrif Apr 18, 2025
14b5326
feat: Result screen in PIN flow
ovitrif Apr 18, 2025
54cbc25
chore: Cleanup
ovitrif Apr 18, 2025
1d1c91a
Merge branch 'master' into feat/biometrics-setup
ovitrif Apr 21, 2025
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 15 additions & 3 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,17 @@ class SettingsStore @Inject constructor(
store.edit { it[SELECTED_CURRENCY_KEY] = currency }
}

val showEmptyState: Flow<Boolean> = store.data.map { it[SHOW_EMPTY_STATE] ?: false }
val showEmptyState: Flow<Boolean> = store.data.map { it[SHOW_EMPTY_STATE] == true }
suspend fun setShowEmptyState(show: Boolean) {
store.edit { it[SHOW_EMPTY_STATE] = show }
}

val hasSeenSpendingIntro: Flow<Boolean> = store.data.map { it[HAS_SEEN_SPENDING_INTRO] ?: false }
val hasSeenSpendingIntro: Flow<Boolean> = 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<Boolean> = store.data.map { it[HAS_SEEN_SAVINGS_INTRO] ?: false }
val hasSeenSavingsIntro: Flow<Boolean> = store.data.map { it[HAS_SEEN_SAVINGS_INTRO] == true }
suspend fun setHasSeenSavingsIntro(value: Boolean) {
store.edit { it[HAS_SEEN_SAVINGS_INTRO] = value }
}
Expand All @@ -67,6 +67,15 @@ class SettingsStore @Inject constructor(
store.edit { it[LIGHTNING_SETUP_STEP] = value }
}

val isPinEnabled: Flow<Boolean> = store.data.map { it[IS_PIN_ENABLED] == true }
suspend fun setIsPinEnabled(value: Boolean) { store.edit { it[IS_PIN_ENABLED] = value } }

val isPinOnLaunchEnabled: Flow<Boolean> = 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<Boolean> = 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")
Expand All @@ -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")
}
}
6 changes: 3 additions & 3 deletions app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
46 changes: 36 additions & 10 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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)
}
}
Expand All @@ -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")
Expand All @@ -73,13 +87,11 @@ class Keychain @Inject constructor(
return snapshot.contains(key.indexed)
}

fun observeExists(key: Key): Flow<Boolean> = 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()}")
}
Expand All @@ -90,10 +102,24 @@ class Keychain @Inject constructor(
return "${this}_$walletIndex".let(::stringPreferencesKey)
}

fun pinAttemptsRemaining(): Flow<Int?> {
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,
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,7 @@ internal object Env {
address = "34.65.86.104:9400",
)
}

const val PIN_LENGTH = 4
const val PIN_ATTEMPTS = 8
}
15 changes: 15 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -228,6 +229,7 @@ fun ContentView(
settings(walletViewModel, navController)
nodeState(walletViewModel, navController)
generalSettings(navController)
securitySettings(navController)
defaultUnitSettings(currencyViewModel, navController)
localCurrencySettings(currencyViewModel, navController)
backupSettings(navController)
Expand Down Expand Up @@ -470,6 +472,12 @@ private fun NavGraphBuilder.generalSettings(navController: NavHostController) {
}
}

private fun NavGraphBuilder.securitySettings(navController: NavHostController) {
composableWithDefaultTransitions<Routes.SecuritySettings> {
SecuritySettingsScreen(navController)
}
}

private fun NavGraphBuilder.defaultUnitSettings(
currencyViewModel: CurrencyViewModel,
navController: NavHostController,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -749,6 +761,9 @@ object Routes {
@Serializable
data object GeneralSettings

@Serializable
data object SecuritySettings

@Serializable
data object DefaultUnitSettings

Expand Down
33 changes: 23 additions & 10 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
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
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
Expand All @@ -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<AppViewModel>()
private val walletViewModel by viewModels<WalletViewModel>()
private val blocktankViewModel by viewModels<BlocktankViewModel>()
Expand Down Expand Up @@ -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(
Expand Down
Loading