Skip to content

Commit 39aacc2

Browse files
authored
Merge pull request #100 from synonymdev/feat/biometrics-setup
Biometrics & Pin on Launch Setup
2 parents ac731cd + 1d1c91a commit 39aacc2

39 files changed

+2190
-45
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ dependencies {
142142
implementation(libs.material)
143143
implementation(libs.datastore.preferences)
144144
implementation(libs.kotlinx.datetime)
145+
implementation(libs.biometric)
145146
implementation(libs.zxing)
146147
implementation(libs.barcode.scanning)
147148
// CameraX

app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ class KeychainTest : BaseAndroidTest() {
9999
assertTrue { sut.snapshot.asMap().isEmpty() }
100100
}
101101

102+
@Test
103+
fun pinAttemptsRemaining_shouldReturnDecryptedValue() = test {
104+
val attemptsRemaining = "3"
105+
sut.saveString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, attemptsRemaining)
106+
107+
val result = sut.pinAttemptsRemaining().first()
108+
109+
assertEquals(attemptsRemaining, result.toString())
110+
}
111+
102112
@After
103113
fun tearDown() {
104114
db.close()

app/src/main/java/to/bitkit/data/SettingsStore.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,17 @@ class SettingsStore @Inject constructor(
4747
store.edit { it[SELECTED_CURRENCY_KEY] = currency }
4848
}
4949

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

55-
val hasSeenSpendingIntro: Flow<Boolean> = store.data.map { it[HAS_SEEN_SPENDING_INTRO] ?: false }
55+
val hasSeenSpendingIntro: Flow<Boolean> = store.data.map { it[HAS_SEEN_SPENDING_INTRO] == true }
5656
suspend fun setHasSeenSpendingIntro(value: Boolean) {
5757
store.edit { it[HAS_SEEN_SPENDING_INTRO] = value }
5858
}
5959

60-
val hasSeenSavingsIntro: Flow<Boolean> = store.data.map { it[HAS_SEEN_SAVINGS_INTRO] ?: false }
60+
val hasSeenSavingsIntro: Flow<Boolean> = store.data.map { it[HAS_SEEN_SAVINGS_INTRO] == true }
6161
suspend fun setHasSeenSavingsIntro(value: Boolean) {
6262
store.edit { it[HAS_SEEN_SAVINGS_INTRO] = value }
6363
}
@@ -67,6 +67,15 @@ class SettingsStore @Inject constructor(
6767
store.edit { it[LIGHTNING_SETUP_STEP] = value }
6868
}
6969

70+
val isPinEnabled: Flow<Boolean> = store.data.map { it[IS_PIN_ENABLED] == true }
71+
suspend fun setIsPinEnabled(value: Boolean) { store.edit { it[IS_PIN_ENABLED] = value } }
72+
73+
val isPinOnLaunchEnabled: Flow<Boolean> = store.data.map { it[IS_PIN_ON_LAUNCH_ENABLED] == true }
74+
suspend fun setIsPinOnLaunchEnabled(value: Boolean) { store.edit { it[IS_PIN_ON_LAUNCH_ENABLED] = value } }
75+
76+
val isBiometricEnabled: Flow<Boolean> = store.data.map { it[IS_BIOMETRIC_ENABLED] == true }
77+
suspend fun setIsBiometricEnabled(value: Boolean) { store.edit { it[IS_BIOMETRIC_ENABLED] = value } }
78+
7079
private companion object {
7180
private val PRIMARY_DISPLAY_UNIT_KEY = stringPreferencesKey("primary_display_unit")
7281
private val BTC_DISPLAY_UNIT_KEY = stringPreferencesKey("btc_display_unit")
@@ -75,5 +84,8 @@ class SettingsStore @Inject constructor(
7584
private val HAS_SEEN_SPENDING_INTRO = booleanPreferencesKey("has_seen_spending_intro")
7685
private val HAS_SEEN_SAVINGS_INTRO = booleanPreferencesKey("has_seen_savings_intro")
7786
private val LIGHTNING_SETUP_STEP = intPreferencesKey("lightning_setup_step")
87+
private val IS_PIN_ENABLED = booleanPreferencesKey("is_pin_enabled")
88+
private val IS_PIN_ON_LAUNCH_ENABLED = booleanPreferencesKey("is_pin_on_launch_enabled")
89+
private val IS_BIOMETRIC_ENABLED = booleanPreferencesKey("is_biometric_enabled")
7890
}
7991
}

app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ class AndroidKeyStore(
3232
if (!keyStore.containsAlias(alias)) {
3333
try {
3434
val generator = KeyGenerator.getInstance(algorithm, type)
35-
generator.init(buildSpec(true))
35+
generator.init(buildSpec(isStrongboxBacked = true))
3636
generator.generateKey()
37-
} catch (e: StrongBoxUnavailableException) {
37+
} catch (_: StrongBoxUnavailableException) {
3838
val generator = KeyGenerator.getInstance(algorithm, type)
39-
generator.init(buildSpec(false))
39+
generator.init(buildSpec(isStrongboxBacked = false))
4040
generator.generateKey()
4141
}
4242
}

app/src/main/java/to/bitkit/data/keychain/Keychain.kt

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.preferencesDataStore
88
import dagger.hilt.android.qualifiers.ApplicationContext
99
import kotlinx.coroutines.CoroutineDispatcher
1010
import kotlinx.coroutines.flow.Flow
11+
import kotlinx.coroutines.flow.distinctUntilChanged
1112
import kotlinx.coroutines.flow.first
1213
import kotlinx.coroutines.flow.map
1314
import org.lightningdevkit.ldknode.Network
@@ -32,7 +33,9 @@ class Keychain @Inject constructor(
3233
private val keyStore by lazy { AndroidKeyStore(alias) }
3334

3435
private val Context.keychain by preferencesDataStore(alias, scope = this)
35-
val snapshot get() = runBlocking(this.coroutineContext) { context.keychain.data.first() }
36+
private val keychain = context.keychain
37+
38+
val snapshot get() = runBlocking(this.coroutineContext) { keychain.data.first() }
3639

3740
fun loadString(key: String): String? = load(key)?.decodeToString()
3841

@@ -41,7 +44,7 @@ class Keychain @Inject constructor(
4144
return snapshot[key.indexed]?.fromBase64()?.let {
4245
keyStore.decrypt(it)
4346
}
44-
} catch (e: Exception) {
47+
} catch (_: Exception) {
4548
throw KeychainError.FailedToLoad(key)
4649
}
4750
}
@@ -53,17 +56,28 @@ class Keychain @Inject constructor(
5356

5457
try {
5558
val encryptedValue = keyStore.encrypt(value)
56-
context.keychain.edit { it[key.indexed] = encryptedValue.toBase64() }
57-
} catch (e: Exception) {
59+
keychain.edit { it[key.indexed] = encryptedValue.toBase64() }
60+
} catch (_: Exception) {
5861
throw KeychainError.FailedToSave(key)
5962
}
6063
Logger.info("Saved to keychain: $key")
6164
}
6265

66+
/** Inserts or replaces a string value associated with a given key in the keychain. */
67+
suspend fun upsertString(key: String, value: String) {
68+
try {
69+
val encryptedValue = keyStore.encrypt(value.toByteArray())
70+
keychain.edit { it[key.indexed] = encryptedValue.toBase64() }
71+
} catch (_: Exception) {
72+
throw KeychainError.FailedToSave(key)
73+
}
74+
Logger.info("Saved/updated in keychain: $key")
75+
}
76+
6377
suspend fun delete(key: String) {
6478
try {
65-
context.keychain.edit { it.remove(key.indexed) }
66-
} catch (e: Exception) {
79+
keychain.edit { it.remove(key.indexed) }
80+
} catch (_: Exception) {
6781
throw KeychainError.FailedToDelete(key)
6882
}
6983
Logger.debug("Deleted from keychain: $key")
@@ -73,13 +87,11 @@ class Keychain @Inject constructor(
7387
return snapshot.contains(key.indexed)
7488
}
7589

76-
fun observeExists(key: Key): Flow<Boolean> = context.keychain.data.map { it.contains(key.name.indexed) }
77-
7890
suspend fun wipe() {
7991
if (Env.network != Network.REGTEST) throw KeychainError.KeychainWipeNotAllowed()
8092

8193
val keys = snapshot.asMap().keys
82-
context.keychain.edit { it.clear() }
94+
keychain.edit { it.clear() }
8395

8496
Logger.info("Deleted all keychain entries: ${keys.joinToString()}")
8597
}
@@ -90,10 +102,24 @@ class Keychain @Inject constructor(
90102
return "${this}_$walletIndex".let(::stringPreferencesKey)
91103
}
92104

105+
fun pinAttemptsRemaining(): Flow<Int?> {
106+
return keychain.data
107+
.map { it[Key.PIN_ATTEMPTS_REMAINING.name.indexed] }
108+
.distinctUntilChanged()
109+
.map { encrypted ->
110+
encrypted?.fromBase64()?.let { bytes ->
111+
keyStore.decrypt(bytes).decodeToString()
112+
}
113+
}
114+
.map { string -> string?.toIntOrNull() }
115+
}
116+
93117
enum class Key {
94118
PUSH_NOTIFICATION_TOKEN,
95119
PUSH_NOTIFICATION_PRIVATE_KEY,
96120
BIP39_MNEMONIC,
97-
BIP39_PASSPHRASE;
121+
BIP39_PASSPHRASE,
122+
PIN,
123+
PIN_ATTEMPTS_REMAINING,
98124
}
99125
}

app/src/main/java/to/bitkit/env/Env.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,7 @@ internal object Env {
102102
address = "34.65.86.104:9400",
103103
)
104104
}
105+
106+
const val PIN_LENGTH = 4
107+
const val PIN_ATTEMPTS = 8
105108
}

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import to.bitkit.ui.settings.GeneralSettingsScreen
7575
import to.bitkit.ui.settings.LightningSettingsScreen
7676
import to.bitkit.ui.settings.LocalCurrencySettingsScreen
7777
import to.bitkit.ui.settings.OrderDetailScreen
78+
import to.bitkit.ui.settings.SecuritySettingsScreen
7879
import to.bitkit.ui.settings.SettingsScreen
7980
import to.bitkit.ui.settings.backups.BackupWalletScreen
8081
import to.bitkit.ui.settings.backups.RestoreWalletScreen
@@ -228,6 +229,7 @@ fun ContentView(
228229
settings(walletViewModel, navController)
229230
nodeState(walletViewModel, navController)
230231
generalSettings(navController)
232+
securitySettings(navController)
231233
defaultUnitSettings(currencyViewModel, navController)
232234
localCurrencySettings(currencyViewModel, navController)
233235
backupSettings(navController)
@@ -470,6 +472,12 @@ private fun NavGraphBuilder.generalSettings(navController: NavHostController) {
470472
}
471473
}
472474

475+
private fun NavGraphBuilder.securitySettings(navController: NavHostController) {
476+
composableWithDefaultTransitions<Routes.SecuritySettings> {
477+
SecuritySettingsScreen(navController)
478+
}
479+
}
480+
473481
private fun NavGraphBuilder.defaultUnitSettings(
474482
currencyViewModel: CurrencyViewModel,
475483
navController: NavHostController,
@@ -659,6 +667,10 @@ fun NavController.navigateToGeneralSettings() = navigate(
659667
route = Routes.GeneralSettings,
660668
)
661669

670+
fun NavController.navigateToSecuritySettings() = navigate(
671+
route = Routes.SecuritySettings,
672+
)
673+
662674
fun NavController.navigateToDefaultUnitSettings() = navigate(
663675
route = Routes.DefaultUnitSettings,
664676
)
@@ -749,6 +761,9 @@ object Routes {
749761
@Serializable
750762
data object GeneralSettings
751763

764+
@Serializable
765+
data object SecuritySettings
766+
752767
@Serializable
753768
data object DefaultUnitSettings
754769

app/src/main/java/to/bitkit/ui/MainActivity.kt

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package to.bitkit.ui
22

33
import android.os.Bundle
4-
import androidx.activity.ComponentActivity
54
import androidx.activity.compose.setContent
65
import androidx.activity.viewModels
6+
import androidx.compose.runtime.getValue
77
import androidx.compose.runtime.rememberCoroutineScope
88
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
9+
import androidx.fragment.app.FragmentActivity
10+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
911
import androidx.navigation.compose.NavHost
1012
import androidx.navigation.compose.composable
1113
import androidx.navigation.compose.rememberNavController
1214
import androidx.navigation.toRoute
1315
import dagger.hilt.android.AndroidEntryPoint
1416
import kotlinx.coroutines.launch
1517
import kotlinx.serialization.Serializable
18+
import to.bitkit.ui.components.AuthCheckView
1619
import to.bitkit.ui.components.ToastOverlay
1720
import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen
1821
import to.bitkit.ui.onboarding.IntroScreen
@@ -36,7 +39,7 @@ import to.bitkit.viewmodels.TransferViewModel
3639
import to.bitkit.viewmodels.WalletViewModel
3740

3841
@AndroidEntryPoint
39-
class MainActivity : ComponentActivity() {
42+
class MainActivity : FragmentActivity() {
4043
private val appViewModel by viewModels<AppViewModel>()
4144
private val walletViewModel by viewModels<WalletViewModel>()
4245
private val blocktankViewModel by viewModels<BlocktankViewModel>()
@@ -168,14 +171,24 @@ class MainActivity : ComponentActivity() {
168171
}
169172
}
170173
} else {
171-
ContentView(
172-
appViewModel = appViewModel,
173-
walletViewModel = walletViewModel,
174-
blocktankViewModel = blocktankViewModel,
175-
currencyViewModel = currencyViewModel,
176-
activityListViewModel = activityListViewModel,
177-
transferViewModel = transferViewModel,
178-
)
174+
val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle()
175+
176+
if (!isAuthenticated) {
177+
AuthCheckView(
178+
showLogoOnPin = true,
179+
appViewModel = appViewModel,
180+
onSuccess = { appViewModel.setIsAuthenticated(true) },
181+
)
182+
} else {
183+
ContentView(
184+
appViewModel = appViewModel,
185+
walletViewModel = walletViewModel,
186+
blocktankViewModel = blocktankViewModel,
187+
currencyViewModel = currencyViewModel,
188+
activityListViewModel = activityListViewModel,
189+
transferViewModel = transferViewModel,
190+
)
191+
}
179192
}
180193

181194
ToastOverlay(

0 commit comments

Comments
 (0)