mirror of
https://github.com/bitwarden/android.git
synced 2025-02-13 18:39:56 +03:00
PM-13988 observe changes to unlock status on settings screen (#4180)
This commit is contained in:
parent
8df4292e08
commit
f3916b4ef6
9 changed files with 246 additions and 0 deletions
app/src
main/java/com/x8bit/bitwarden
data
auth/datasource/disk
platform/repository
ui/platform/feature/settings/accountsecurity
test/java/com/x8bit/bitwarden
data
auth/datasource/disk
platform/repository
ui/platform/feature/settings/accountsecurity
|
@ -181,6 +181,11 @@ interface AuthDiskSource {
|
|||
*/
|
||||
fun storeUserBiometricUnlockKey(userId: String, biometricsKey: String?)
|
||||
|
||||
/**
|
||||
* Gets the flow for the biometrics key for the given [userId].
|
||||
*/
|
||||
fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?>
|
||||
|
||||
/**
|
||||
* Retrieves a pin-protected user key for the given [userId].
|
||||
*/
|
||||
|
@ -198,6 +203,11 @@ interface AuthDiskSource {
|
|||
inMemoryOnly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves a flow for the pin-protected user key for the given [userId].
|
||||
*/
|
||||
fun getPinProtectedUserKeyFlow(userId: String): Flow<String?>
|
||||
|
||||
/**
|
||||
* Gets a two-factor auth token using a user's [email].
|
||||
*/
|
||||
|
|
|
@ -74,6 +74,10 @@ class AuthDiskSourceImpl(
|
|||
private val mutableOnboardingStatusFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<OnboardingStatus?>>()
|
||||
private val mutableShowImportLoginsFlowMap = mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
private val mutableBiometricUnlockKeyFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<String?>>()
|
||||
private val mutablePinProtectedUserKeyFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<String?>>()
|
||||
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
|
||||
|
||||
override var userState: UserStateJson?
|
||||
|
@ -284,8 +288,13 @@ class AuthDiskSourceImpl(
|
|||
key = BIOMETRICS_UNLOCK_KEY.appendIdentifier(userId),
|
||||
value = biometricsKey,
|
||||
)
|
||||
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
|
||||
}
|
||||
|
||||
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
|
||||
getMutableBiometricUnlockKeyFlow(userId)
|
||||
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }
|
||||
|
||||
override fun getPinProtectedUserKey(userId: String): String? =
|
||||
inMemoryPinProtectedUserKeys[userId]
|
||||
?: getString(key = PIN_PROTECTED_USER_KEY_KEY.appendIdentifier(userId))
|
||||
|
@ -301,8 +310,13 @@ class AuthDiskSourceImpl(
|
|||
key = PIN_PROTECTED_USER_KEY_KEY.appendIdentifier(userId),
|
||||
value = pinProtectedUserKey,
|
||||
)
|
||||
getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey)
|
||||
}
|
||||
|
||||
override fun getPinProtectedUserKeyFlow(userId: String): Flow<String?> =
|
||||
getMutablePinProtectedUserKeyFlow(userId)
|
||||
.onSubscription { emit(getPinProtectedUserKey(userId = userId)) }
|
||||
|
||||
override fun getTwoFactorToken(email: String): String? =
|
||||
getString(key = TWO_FACTOR_TOKEN_KEY.appendIdentifier(email))
|
||||
|
||||
|
@ -506,6 +520,18 @@ class AuthDiskSourceImpl(
|
|||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableBiometricUnlockKeyFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<String?> = mutableBiometricUnlockKeyFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutablePinProtectedUserKeyFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<String?> = mutablePinProtectedUserKeyFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun migrateAccountTokens() {
|
||||
userState
|
||||
?.accounts
|
||||
|
|
|
@ -108,11 +108,21 @@ interface SettingsRepository {
|
|||
*/
|
||||
val isUnlockWithBiometricsEnabled: Boolean
|
||||
|
||||
/**
|
||||
* Emits updates whenever there is a change in the user status for biometric unlocking.
|
||||
*/
|
||||
val isUnlockWithBiometricsEnabledFlow: Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Whether or not PIN unlocking is enabled for the current user.
|
||||
*/
|
||||
val isUnlockWithPinEnabled: Boolean
|
||||
|
||||
/**
|
||||
* Emits updates whenever there is a change in the user status for PIN unlocking.
|
||||
*/
|
||||
val isUnlockWithPinEnabledFlow: Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Whether or not inline autofill is enabled for the current user.
|
||||
*/
|
||||
|
|
|
@ -245,11 +245,29 @@ class SettingsRepositoryImpl(
|
|||
?.let { authDiskSource.getUserBiometricUnlockKey(userId = it) != null }
|
||||
?: false
|
||||
|
||||
override val isUnlockWithBiometricsEnabledFlow: Flow<Boolean>
|
||||
get() = activeUserId
|
||||
?.let { userId ->
|
||||
authDiskSource
|
||||
.getUserBiometicUnlockKeyFlow(userId)
|
||||
.map { it != null }
|
||||
}
|
||||
?: flowOf(false)
|
||||
|
||||
override val isUnlockWithPinEnabled: Boolean
|
||||
get() = activeUserId
|
||||
?.let { authDiskSource.getEncryptedPin(userId = it) != null }
|
||||
?: false
|
||||
|
||||
override val isUnlockWithPinEnabledFlow: Flow<Boolean>
|
||||
get() = activeUserId
|
||||
?.let { userId ->
|
||||
authDiskSource
|
||||
.getPinProtectedUserKeyFlow(userId)
|
||||
.map { it != null }
|
||||
}
|
||||
?: flowOf(false)
|
||||
|
||||
override var isInlineAutofillEnabled: Boolean
|
||||
get() = activeUserId
|
||||
?.let { settingsDiskSource.getInlineAutofillEnabled(userId = it) }
|
||||
|
|
|
@ -126,6 +126,26 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
settingsRepository
|
||||
.isUnlockWithBiometricsEnabledFlow
|
||||
.map {
|
||||
AccountSecurityAction.Internal.BiometricLockUpdate(
|
||||
isBiometricEnabled = it,
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
settingsRepository
|
||||
.isUnlockWithPinEnabledFlow
|
||||
.map {
|
||||
AccountSecurityAction.Internal.PinProtectedLockUpdate(
|
||||
isPinProtected = it,
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
viewModelScope.launch {
|
||||
trySendAction(
|
||||
AccountSecurityAction.Internal.FingerprintResultReceive(
|
||||
|
@ -358,6 +378,34 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
is AccountSecurityAction.Internal.ShowUnlockBadgeUpdated -> {
|
||||
handleShowUnlockBadgeUpdated(action)
|
||||
}
|
||||
|
||||
is AccountSecurityAction.Internal.BiometricLockUpdate -> {
|
||||
hanleBiometricUnlockUpdate(action)
|
||||
}
|
||||
|
||||
is AccountSecurityAction.Internal.PinProtectedLockUpdate -> {
|
||||
handlePinProtectedLockUpdate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinProtectedLockUpdate(
|
||||
action: AccountSecurityAction.Internal.PinProtectedLockUpdate,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isUnlockWithPinEnabled = action.isPinProtected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hanleBiometricUnlockUpdate(
|
||||
action: AccountSecurityAction.Internal.BiometricLockUpdate,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isUnlockWithBiometricsEnabled = action.isBiometricEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -734,5 +782,19 @@ sealed class AccountSecurityAction {
|
|||
* The show unlock badge update has been received.
|
||||
*/
|
||||
data class ShowUnlockBadgeUpdated(val showUnlockBadge: Boolean) : Internal()
|
||||
|
||||
/**
|
||||
* The user's biometric unlock status has been updated.
|
||||
*/
|
||||
data class BiometricLockUpdate(
|
||||
val isBiometricEnabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The user's pin unlock status has been updated.
|
||||
*/
|
||||
data class PinProtectedLockUpdate(
|
||||
val isPinProtected: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -684,6 +684,22 @@ class AuthDiskSourceTest {
|
|||
assertFalse(fakeEncryptedSharedPreferences.contains(biometricsKeyKey))
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometicUnlockKeyFlow`() =
|
||||
runTest {
|
||||
val topSecretKey = "topsecret"
|
||||
val mockUserId = "mockUserId"
|
||||
authDiskSource.getUserBiometicUnlockKeyFlow(mockUserId).test {
|
||||
assertNull(awaitItem())
|
||||
authDiskSource.storeUserBiometricUnlockKey(
|
||||
userId = mockUserId,
|
||||
biometricsKey = topSecretKey,
|
||||
)
|
||||
assertEquals(topSecretKey, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPinProtectedUserKey should pull from SharedPreferences`() {
|
||||
val pinProtectedUserKeyBaseKey = "bwPreferencesStorage:pinKeyEncryptedUserKey"
|
||||
|
@ -723,6 +739,21 @@ class AuthDiskSourceTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storePinProtectedUserKey should update result flow from getPinProtectedUserKeyFlow`() =
|
||||
runTest {
|
||||
val topSecretKey = "topsecret"
|
||||
val mockUserId = "mockUserId"
|
||||
authDiskSource.getPinProtectedUserKeyFlow(mockUserId).test {
|
||||
assertNull(awaitItem())
|
||||
authDiskSource.storePinProtectedUserKey(
|
||||
userId = mockUserId,
|
||||
pinProtectedUserKey = topSecretKey,
|
||||
)
|
||||
assertEquals(topSecretKey, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getEncryptedPin should pull from SharedPreferences`() {
|
||||
val encryptedPinBaseKey = "bwPreferencesStorage:protectedPin"
|
||||
|
|
|
@ -30,6 +30,9 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
private val mutableAccountTokensFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
|
||||
private val mutableShowImportLoginsFlowMap = mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
private val mutableBiometricKeysFlowMap = mutableMapOf<String, MutableSharedFlow<String?>>()
|
||||
private val mutablePinProtectedUserKeysFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<String?>>()
|
||||
|
||||
private val mutableOnboardingStatusFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<OnboardingStatus?>>()
|
||||
|
@ -158,8 +161,15 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
inMemoryOnly: Boolean,
|
||||
) {
|
||||
storedPinProtectedUserKeys[userId] = pinProtectedUserKey to inMemoryOnly
|
||||
getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey)
|
||||
}
|
||||
|
||||
override fun getPinProtectedUserKeyFlow(userId: String): Flow<String?> =
|
||||
getMutablePinProtectedUserKeyFlow(userId)
|
||||
.onSubscription {
|
||||
emit(getPinProtectedUserKey(userId))
|
||||
}
|
||||
|
||||
override fun getEncryptedPin(userId: String): String? =
|
||||
storedEncryptedPins[userId]
|
||||
|
||||
|
@ -216,8 +226,13 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
|
||||
override fun storeUserBiometricUnlockKey(userId: String, biometricsKey: String?) {
|
||||
storedBiometricKeys[userId] = biometricsKey
|
||||
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
|
||||
}
|
||||
|
||||
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
|
||||
getMutableBiometricUnlockKeyFlow(userId)
|
||||
.onSubscription { emit(getUserBiometricUnlockKey(userId)) }
|
||||
|
||||
override fun getMasterPasswordHash(userId: String): String? =
|
||||
storedMasterPasswordHashes[userId]
|
||||
|
||||
|
@ -469,5 +484,17 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableBiometricUnlockKeyFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<String?> = mutableBiometricKeysFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutablePinProtectedUserKeyFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<String?> = mutablePinProtectedUserKeysFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
//endregion Private helper functions
|
||||
}
|
||||
|
|
|
@ -1150,6 +1150,42 @@ class SettingsRepositoryTest {
|
|||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
assertFalse(settingsRepository.isAuthenticatorSyncEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
settingsRepository.isUnlockWithBiometricsEnabledFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
fakeAuthDiskSource.storeUserBiometricUnlockKey(
|
||||
userId = USER_ID,
|
||||
biometricsKey = "biometricsKey",
|
||||
)
|
||||
assertTrue(awaitItem())
|
||||
fakeAuthDiskSource.storeUserBiometricUnlockKey(
|
||||
userId = USER_ID,
|
||||
biometricsKey = null,
|
||||
)
|
||||
assertFalse(awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isUnlockWithPinEnabledFlow should react to changes in AuthDiskSource`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
settingsRepository.isUnlockWithPinEnabledFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
fakeAuthDiskSource.storePinProtectedUserKey(
|
||||
userId = USER_ID,
|
||||
pinProtectedUserKey = "pinProtectedUserKey",
|
||||
)
|
||||
assertTrue(awaitItem())
|
||||
fakeAuthDiskSource.storePinProtectedUserKey(
|
||||
userId = USER_ID,
|
||||
pinProtectedUserKey = null,
|
||||
)
|
||||
assertFalse(awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val USER_ID: String = "userId"
|
||||
|
|
|
@ -63,6 +63,8 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
|||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
private val vaultRepository: VaultRepository = mockk(relaxed = true)
|
||||
private val mutableBiometricsUnlockEnabledFlow = bufferedMutableSharedFlow<Boolean>()
|
||||
private val mutablePinUnlockEnabledFlow = bufferedMutableSharedFlow<Boolean>()
|
||||
private val settingsRepository: SettingsRepository = mockk {
|
||||
every { isAuthenticatorSyncEnabled } returns false
|
||||
every { isUnlockWithBiometricsEnabled } returns false
|
||||
|
@ -70,6 +72,8 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
|||
every { vaultTimeout } returns VaultTimeout.ThirtyMinutes
|
||||
every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK
|
||||
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
|
||||
every { isUnlockWithBiometricsEnabledFlow } returns mutableBiometricsUnlockEnabledFlow
|
||||
every { isUnlockWithPinEnabledFlow } returns mutablePinUnlockEnabledFlow
|
||||
}
|
||||
|
||||
private val mutableFirstTimeStateFlow = MutableStateFlow(FirstTimeState())
|
||||
|
@ -800,6 +804,28 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when BiometricLockUpdate action is handled, should update the state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
mutableBiometricsUnlockEnabledFlow.emit(true)
|
||||
val expectedState = DEFAULT_STATE.copy(isUnlockWithBiometricsEnabled = true)
|
||||
assertEquals(
|
||||
viewModel.stateFlow.value,
|
||||
expectedState,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when PinProtectedLockUpdate action is handled, should update the state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
mutablePinUnlockEnabledFlow.emit(true)
|
||||
val expectedState = DEFAULT_STATE.copy(isUnlockWithPinEnabled = true)
|
||||
assertEquals(
|
||||
viewModel.stateFlow.value,
|
||||
expectedState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun createViewModel(
|
||||
initialState: AccountSecurityState? = DEFAULT_STATE,
|
||||
|
|
Loading…
Add table
Reference in a new issue