Merge pull request #6744 from element-hq/feature/bma/improvePinUX
Improve pin code ux
This commit is contained in:
commit
cf17502f2e
43 changed files with 288 additions and 195 deletions
|
|
@ -36,13 +36,13 @@ interface BiometricAuthenticator {
|
||||||
}
|
}
|
||||||
|
|
||||||
val isActive: Boolean
|
val isActive: Boolean
|
||||||
fun setup()
|
suspend fun setup()
|
||||||
suspend fun authenticate(): AuthenticationResult
|
suspend fun authenticate(): AuthenticationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoopBiometricAuthentication : BiometricAuthenticator {
|
class NoopBiometricAuthentication : BiometricAuthenticator {
|
||||||
override val isActive: Boolean = false
|
override val isActive: Boolean = false
|
||||||
override fun setup() = Unit
|
override suspend fun setup() = Unit
|
||||||
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
|
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ class DefaultBiometricAuthentication(
|
||||||
|
|
||||||
private var cryptoObject: CryptoObject? = null
|
private var cryptoObject: CryptoObject? = null
|
||||||
|
|
||||||
override fun setup() {
|
override suspend fun setup() {
|
||||||
try {
|
try {
|
||||||
val secretKey = ensureKey()
|
val secretKey = ensureKey()
|
||||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||||
|
|
@ -86,7 +86,7 @@ class DefaultBiometricAuthentication(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(KeyPermanentlyInvalidatedException::class)
|
@Throws(KeyPermanentlyInvalidatedException::class)
|
||||||
private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
private suspend fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
||||||
encryptionDecryptionService.createEncryptionCipher(it)
|
encryptionDecryptionService.createEncryptionCipher(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
|
internal const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
|
|
@ -38,11 +38,11 @@ class DefaultPinCodeManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasPinCode(): Flow<Boolean> {
|
override fun hasPinCode(): Flow<Boolean> {
|
||||||
return lockScreenStore.hasPinCode()
|
return secretKeyRepository.hasKey(SECRET_KEY_ALIAS)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPinCodeSize(): Int {
|
override suspend fun getPinCodeSize(): Int? {
|
||||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
|
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return null
|
||||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||||
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
||||||
return decryptedPinCode.size
|
return decryptedPinCode.size
|
||||||
|
|
@ -79,6 +79,7 @@ class DefaultPinCodeManager(
|
||||||
override suspend fun deletePinCode() {
|
override suspend fun deletePinCode() {
|
||||||
lockScreenStore.deleteEncryptedPinCode()
|
lockScreenStore.deleteEncryptedPinCode()
|
||||||
lockScreenStore.resetCounter()
|
lockScreenStore.resetCounter()
|
||||||
|
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||||
callbacks.forEach { it.onPinCodeRemoved() }
|
callbacks.forEach { it.onPinCodeRemoved() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ interface PinCodeManager {
|
||||||
fun hasPinCode(): Flow<Boolean>
|
fun hasPinCode(): Flow<Boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the size of the saved pin code.
|
* @return the size of the saved pin code. Return null if no pin code is saved.
|
||||||
*/
|
*/
|
||||||
suspend fun getPinCodeSize(): Int
|
suspend fun getPinCodeSize(): Int?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new encrypted pin code.
|
* Creates a new encrypted pin code.
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.settings
|
package io.element.android.features.lockscreen.impl.settings
|
||||||
|
|
||||||
sealed interface LockScreenSettingsEvents {
|
sealed interface LockScreenSettingsEvent {
|
||||||
data object OnRemovePin : LockScreenSettingsEvents
|
data object OnRemovePin : LockScreenSettingsEvent
|
||||||
data object ConfirmRemovePin : LockScreenSettingsEvents
|
data object ConfirmRemovePin : LockScreenSettingsEvent
|
||||||
data object CancelRemovePin : LockScreenSettingsEvents
|
data object CancelRemovePin : LockScreenSettingsEvent
|
||||||
data object ToggleBiometricAllowed : LockScreenSettingsEvents
|
data object ToggleBiometricAllowed : LockScreenSettingsEvent
|
||||||
}
|
}
|
||||||
|
|
@ -51,10 +51,10 @@ class LockScreenSettingsPresenter(
|
||||||
|
|
||||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||||
|
|
||||||
fun handleEvent(event: LockScreenSettingsEvents) {
|
fun handleEvent(event: LockScreenSettingsEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
|
LockScreenSettingsEvent.CancelRemovePin -> showRemovePinConfirmation = false
|
||||||
LockScreenSettingsEvents.ConfirmRemovePin -> {
|
LockScreenSettingsEvent.ConfirmRemovePin -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (showRemovePinConfirmation) {
|
if (showRemovePinConfirmation) {
|
||||||
showRemovePinConfirmation = false
|
showRemovePinConfirmation = false
|
||||||
|
|
@ -62,8 +62,8 @@ class LockScreenSettingsPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
|
LockScreenSettingsEvent.OnRemovePin -> showRemovePinConfirmation = true
|
||||||
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
|
LockScreenSettingsEvent.ToggleBiometricAllowed -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (!isBiometricEnabled) {
|
if (!isBiometricEnabled) {
|
||||||
biometricUnlock.setup()
|
biometricUnlock.setup()
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,5 @@ data class LockScreenSettingsState(
|
||||||
val isBiometricEnabled: Boolean,
|
val isBiometricEnabled: Boolean,
|
||||||
val showRemovePinConfirmation: Boolean,
|
val showRemovePinConfirmation: Boolean,
|
||||||
val showToggleBiometric: Boolean,
|
val showToggleBiometric: Boolean,
|
||||||
val eventSink: (LockScreenSettingsEvents) -> Unit
|
val eventSink: (LockScreenSettingsEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ fun LockScreenSettingsView(
|
||||||
},
|
},
|
||||||
style = ListItemStyle.Destructive,
|
style = ListItemStyle.Destructive,
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
|
state.eventSink(LockScreenSettingsEvent.OnRemovePin)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ fun LockScreenSettingsView(
|
||||||
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
|
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
|
||||||
isChecked = state.isBiometricEnabled,
|
isChecked = state.isBiometricEnabled,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -72,10 +72,10 @@ fun LockScreenSettingsView(
|
||||||
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
|
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
|
||||||
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
|
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
|
||||||
onSubmitClick = {
|
onSubmitClick = {
|
||||||
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
|
state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
|
||||||
},
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
|
state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.setup.biometric
|
package io.element.android.features.lockscreen.impl.setup.biometric
|
||||||
|
|
||||||
sealed interface SetupBiometricEvents {
|
sealed interface SetupBiometricEvent {
|
||||||
data object AllowBiometric : SetupBiometricEvents
|
data object AllowBiometric : SetupBiometricEvent
|
||||||
data object UsePin : SetupBiometricEvents
|
data object UsePin : SetupBiometricEvent
|
||||||
}
|
}
|
||||||
|
|
@ -35,16 +35,16 @@ class SetupBiometricPresenter(
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||||
|
|
||||||
fun handleEvent(event: SetupBiometricEvents) {
|
fun handleEvent(event: SetupBiometricEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
|
SetupBiometricEvent.AllowBiometric -> coroutineScope.launch {
|
||||||
biometricUnlock.setup()
|
biometricUnlock.setup()
|
||||||
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
|
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
|
||||||
lockScreenStore.setIsBiometricUnlockAllowed(true)
|
lockScreenStore.setIsBiometricUnlockAllowed(true)
|
||||||
isBiometricSetupDone = true
|
isBiometricSetupDone = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SetupBiometricEvents.UsePin -> coroutineScope.launch {
|
SetupBiometricEvent.UsePin -> coroutineScope.launch {
|
||||||
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
||||||
isBiometricSetupDone = true
|
isBiometricSetupDone = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@ package io.element.android.features.lockscreen.impl.setup.biometric
|
||||||
|
|
||||||
data class SetupBiometricState(
|
data class SetupBiometricState(
|
||||||
val isBiometricSetupDone: Boolean,
|
val isBiometricSetupDone: Boolean,
|
||||||
val eventSink: (SetupBiometricEvents) -> Unit
|
val eventSink: (SetupBiometricEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ fun SetupBiometricView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BackHandler {
|
BackHandler {
|
||||||
state.eventSink(SetupBiometricEvents.UsePin)
|
state.eventSink(SetupBiometricEvent.UsePin)
|
||||||
}
|
}
|
||||||
HeaderFooterPage(
|
HeaderFooterPage(
|
||||||
modifier = modifier.padding(top = 80.dp),
|
modifier = modifier.padding(top = 80.dp),
|
||||||
|
|
@ -42,8 +42,8 @@ fun SetupBiometricView(
|
||||||
},
|
},
|
||||||
footer = {
|
footer = {
|
||||||
SetupBiometricFooter(
|
SetupBiometricFooter(
|
||||||
onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
|
onAllowClick = { state.eventSink(SetupBiometricEvent.AllowBiometric) },
|
||||||
onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) }
|
onSkipClick = { state.eventSink(SetupBiometricEvent.UsePin) }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.setup.pin
|
package io.element.android.features.lockscreen.impl.setup.pin
|
||||||
|
|
||||||
sealed interface SetupPinEvents {
|
sealed interface SetupPinEvent {
|
||||||
data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents
|
data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvent
|
||||||
data object ClearFailure : SetupPinEvents
|
data object ClearFailure : SetupPinEvent
|
||||||
}
|
}
|
||||||
|
|
@ -74,9 +74,9 @@ class SetupPinPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: SetupPinEvents) {
|
fun handleEvent(event: SetupPinEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is SetupPinEvents.OnPinEntryChanged -> {
|
is SetupPinEvent.OnPinEntryChanged -> {
|
||||||
// Use the fromConfirmationStep flag from ui to avoid race condition.
|
// Use the fromConfirmationStep flag from ui to avoid race condition.
|
||||||
if (event.fromConfirmationStep) {
|
if (event.fromConfirmationStep) {
|
||||||
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
||||||
|
|
@ -84,7 +84,7 @@ class SetupPinPresenter(
|
||||||
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SetupPinEvents.ClearFailure -> {
|
SetupPinEvent.ClearFailure -> {
|
||||||
when (setupPinFailure) {
|
when (setupPinFailure) {
|
||||||
is SetupPinFailure.PinsDoNotMatch -> {
|
is SetupPinFailure.PinsDoNotMatch -> {
|
||||||
choosePinEntry = choosePinEntry.clear()
|
choosePinEntry = choosePinEntry.clear()
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ data class SetupPinState(
|
||||||
val isConfirmationStep: Boolean,
|
val isConfirmationStep: Boolean,
|
||||||
val setupPinFailure: SetupPinFailure?,
|
val setupPinFailure: SetupPinFailure?,
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val eventSink: (SetupPinEvents) -> Unit
|
val eventSink: (SetupPinEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val activePinEntry = if (isConfirmationStep) {
|
val activePinEntry = if (isConfirmationStep) {
|
||||||
confirmPinEntry
|
confirmPinEntry
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ private fun SetupPinContent(
|
||||||
pinEntry = state.activePinEntry,
|
pinEntry = state.activePinEntry,
|
||||||
isSecured = true,
|
isSecured = true,
|
||||||
onValueChange = { entry ->
|
onValueChange = { entry ->
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
|
state.eventSink(SetupPinEvent.OnPinEntryChanged(entry, state.isConfirmationStep))
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
|
|
@ -119,7 +119,7 @@ private fun SetupPinContent(
|
||||||
title = state.setupPinFailure.title(),
|
title = state.setupPinFailure.title(),
|
||||||
content = state.setupPinFailure.content(),
|
content = state.setupPinFailure.content(),
|
||||||
onSubmit = {
|
onSubmit = {
|
||||||
state.eventSink(SetupPinEvents.ClearFailure)
|
state.eventSink(SetupPinEvent.ClearFailure)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.storage
|
package io.element.android.features.lockscreen.impl.storage
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should be implemented by any class that provides access to the encrypted PIN code.
|
* Should be implemented by any class that provides access to the encrypted PIN code.
|
||||||
* All methods are suspending in case there are async IO operations involved.
|
* All methods are suspending in case there are async IO operations involved.
|
||||||
|
|
@ -29,9 +27,4 @@ interface EncryptedPinCodeStorage {
|
||||||
* Deletes the PIN code from some persistable storage.
|
* Deletes the PIN code from some persistable storage.
|
||||||
*/
|
*/
|
||||||
suspend fun deleteEncryptedPinCode()
|
suspend fun deleteEncryptedPinCode()
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether the PIN code is stored or not.
|
|
||||||
*/
|
|
||||||
fun hasPinCode(): Flow<Boolean>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,6 @@ class PreferencesLockScreenStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasPinCode(): Flow<Boolean> {
|
|
||||||
return dataStore.data.map { preferences ->
|
|
||||||
preferences[pinCodeKey] != null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||||
return dataStore.data.map { preferences ->
|
return dataStore.data.map { preferences ->
|
||||||
preferences[biometricUnlockKey] ?: false
|
preferences[biometricUnlockKey] ?: false
|
||||||
|
|
@ -88,5 +82,7 @@ class PreferencesLockScreenStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
|
private fun Preferences.getRemainingPinCodeAttemptsNumber() =
|
||||||
|
this[remainingAttemptsKey]?.coerceIn(0, lockScreenConfig.maxPinCodeAttemptsBeforeLogout)
|
||||||
|
?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ package io.element.android.features.lockscreen.impl.unlock
|
||||||
|
|
||||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||||
|
|
||||||
sealed interface PinUnlockEvents {
|
sealed interface PinUnlockEvent {
|
||||||
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
|
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvent
|
||||||
data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents
|
data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvent
|
||||||
data object OnForgetPin : PinUnlockEvents
|
data object OnForgetPin : PinUnlockEvent
|
||||||
data object ClearSignOutPrompt : PinUnlockEvents
|
data object ClearSignOutPrompt : PinUnlockEvent
|
||||||
data object SignOut : PinUnlockEvents
|
data object SignOut : PinUnlockEvent
|
||||||
data object OnUseBiometric : PinUnlockEvents
|
data object OnUseBiometric : PinUnlockEvent
|
||||||
data object ClearBiometricError : PinUnlockEvents
|
data object ClearBiometricError : PinUnlockEvent
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +69,13 @@ class PinUnlockPresenter(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
suspend {
|
suspend {
|
||||||
val pinCodeSize = pinCodeManager.getPinCodeSize()
|
val pinCodeSize = pinCodeManager.getPinCodeSize()
|
||||||
PinEntry.createEmpty(pinCodeSize)
|
if (pinCodeSize == null) {
|
||||||
|
// No pin code set, deleted store? Force sign out
|
||||||
|
showSignOutPrompt = true
|
||||||
|
error("No pin code size found")
|
||||||
|
} else {
|
||||||
|
PinEntry.createEmpty(pinCodeSize)
|
||||||
|
}
|
||||||
}.runCatchingUpdatingState(pinEntryState)
|
}.runCatchingUpdatingState(pinEntryState)
|
||||||
}
|
}
|
||||||
LaunchedEffect(biometricUnlock) {
|
LaunchedEffect(biometricUnlock) {
|
||||||
|
|
@ -95,28 +101,28 @@ class PinUnlockPresenter(
|
||||||
isUnlocked.value = true
|
isUnlocked.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: PinUnlockEvents) {
|
fun handleEvent(event: PinUnlockEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is PinUnlockEvents.OnPinKeypadPressed -> {
|
is PinUnlockEvent.OnPinKeypadPressed -> {
|
||||||
pinEntryState.value = pinEntry.process(event.pinKeypadModel)
|
pinEntryState.value = pinEntry.process(event.pinKeypadModel)
|
||||||
}
|
}
|
||||||
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
|
PinUnlockEvent.OnForgetPin -> showSignOutPrompt = true
|
||||||
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
|
PinUnlockEvent.ClearSignOutPrompt -> showSignOutPrompt = false
|
||||||
PinUnlockEvents.SignOut -> {
|
PinUnlockEvent.SignOut -> {
|
||||||
if (showSignOutPrompt) {
|
if (showSignOutPrompt) {
|
||||||
showSignOutPrompt = false
|
showSignOutPrompt = false
|
||||||
coroutineScope.signOut(signOutAction)
|
coroutineScope.signOut(signOutAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PinUnlockEvents.OnUseBiometric -> {
|
PinUnlockEvent.OnUseBiometric -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
biometricUnlockResult = biometricUnlock.authenticate()
|
biometricUnlockResult = biometricUnlock.authenticate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PinUnlockEvents.ClearBiometricError -> {
|
PinUnlockEvent.ClearBiometricError -> {
|
||||||
biometricUnlockResult = null
|
biometricUnlockResult = null
|
||||||
}
|
}
|
||||||
is PinUnlockEvents.OnPinEntryChanged -> {
|
is PinUnlockEvent.OnPinEntryChanged -> {
|
||||||
pinEntryState.value = pinEntry.process(event.entryAsText)
|
pinEntryState.value = pinEntry.process(event.entryAsText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,15 @@ data class PinUnlockState(
|
||||||
val showBiometricUnlock: Boolean,
|
val showBiometricUnlock: Boolean,
|
||||||
val isUnlocked: Boolean,
|
val isUnlocked: Boolean,
|
||||||
val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?,
|
val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?,
|
||||||
val eventSink: (PinUnlockEvents) -> Unit
|
val eventSink: (PinUnlockEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val isSignOutPromptCancellable = when (remainingAttempts) {
|
val isSignOutPromptCancellable = if (pinEntry.isFailure()) {
|
||||||
is AsyncData.Success -> remainingAttempts.data > 0
|
false
|
||||||
else -> true
|
} else {
|
||||||
|
when (remainingAttempts) {
|
||||||
|
is AsyncData.Success -> remainingAttempts.data > 0
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val biometricUnlockErrorMessage = when {
|
val biometricUnlockErrorMessage = when {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
||||||
override val values: Sequence<PinUnlockState>
|
override val values: Sequence<PinUnlockState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aPinUnlockState(),
|
aPinUnlockState(),
|
||||||
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
|
aPinUnlockState(pinEntry = AsyncData.Success(PinEntry.createEmpty(4).fillWith("12"))),
|
||||||
aPinUnlockState(showWrongPinTitle = true),
|
aPinUnlockState(showWrongPinTitle = true),
|
||||||
aPinUnlockState(showSignOutPrompt = true),
|
aPinUnlockState(showSignOutPrompt = true),
|
||||||
aPinUnlockState(showBiometricUnlock = false),
|
aPinUnlockState(showBiometricUnlock = false),
|
||||||
|
|
@ -31,11 +31,18 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
||||||
BiometricUnlockError(BiometricPrompt.ERROR_LOCKOUT, "Biometric auth disabled")
|
BiometricUnlockError(BiometricPrompt.ERROR_LOCKOUT, "Biometric auth disabled")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
aPinUnlockState(showSignOutPrompt = true, pinEntry = AsyncData.Failure(Exception("An error occurred"))),
|
||||||
|
// User enter wrong pin once, and then correct PIN. In this case, the error (with counter reset to 3) should not be displayed.
|
||||||
|
aPinUnlockState(
|
||||||
|
remainingAttempts = AsyncData.Success(2),
|
||||||
|
showWrongPinTitle = true,
|
||||||
|
isUnlocked = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aPinUnlockState(
|
fun aPinUnlockState(
|
||||||
pinEntry: PinEntry = PinEntry.createEmpty(4),
|
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
|
||||||
remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
|
remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
|
||||||
showWrongPinTitle: Boolean = false,
|
showWrongPinTitle: Boolean = false,
|
||||||
showSignOutPrompt: Boolean = false,
|
showSignOutPrompt: Boolean = false,
|
||||||
|
|
@ -44,7 +51,7 @@ fun aPinUnlockState(
|
||||||
isUnlocked: Boolean = false,
|
isUnlocked: Boolean = false,
|
||||||
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
) = PinUnlockState(
|
) = PinUnlockState(
|
||||||
pinEntry = AsyncData.Success(pinEntry),
|
pinEntry = pinEntry,
|
||||||
showWrongPinTitle = showWrongPinTitle,
|
showWrongPinTitle = showWrongPinTitle,
|
||||||
remainingAttempts = remainingAttempts,
|
remainingAttempts = remainingAttempts,
|
||||||
showSignOutPrompt = showSignOutPrompt,
|
showSignOutPrompt = showSignOutPrompt,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ fun PinUnlockView(
|
||||||
) {
|
) {
|
||||||
OnLifecycleEvent { _, event ->
|
OnLifecycleEvent { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
|
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvent.OnUseBiometric)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,8 +78,8 @@ fun PinUnlockView(
|
||||||
if (state.showSignOutPrompt) {
|
if (state.showSignOutPrompt) {
|
||||||
SignOutPrompt(
|
SignOutPrompt(
|
||||||
isCancellable = state.isSignOutPromptCancellable,
|
isCancellable = state.isSignOutPromptCancellable,
|
||||||
onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
|
onSignOut = { state.eventSink(PinUnlockEvent.SignOut) },
|
||||||
onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
|
onDismiss = { state.eventSink(PinUnlockEvent.ClearSignOutPrompt) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (state.signOutAction) {
|
when (state.signOutAction) {
|
||||||
|
|
@ -95,7 +95,7 @@ fun PinUnlockView(
|
||||||
if (state.showBiometricUnlockError) {
|
if (state.showBiometricUnlockError) {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
content = state.biometricUnlockErrorMessage ?: "",
|
content = state.biometricUnlockErrorMessage ?: "",
|
||||||
onSubmit = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
|
onSubmit = { state.eventSink(PinUnlockEvent.ClearBiometricError) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,10 +108,10 @@ private fun PinUnlockPage(
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints {
|
BoxWithConstraints {
|
||||||
val commonModifier = Modifier
|
val commonModifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.systemBarsPadding()
|
.systemBarsPadding()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(all = 20.dp)
|
.padding(all = 20.dp)
|
||||||
|
|
||||||
val header = @Composable {
|
val header = @Composable {
|
||||||
PinUnlockHeader(
|
PinUnlockHeader(
|
||||||
|
|
@ -125,10 +125,10 @@ private fun PinUnlockPage(
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
showBiometricUnlock = state.showBiometricUnlock,
|
showBiometricUnlock = state.showBiometricUnlock,
|
||||||
onUseBiometric = {
|
onUseBiometric = {
|
||||||
state.eventSink(PinUnlockEvents.OnUseBiometric)
|
state.eventSink(PinUnlockEvent.OnUseBiometric)
|
||||||
},
|
},
|
||||||
onForgotPin = {
|
onForgotPin = {
|
||||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
state.eventSink(PinUnlockEvent.OnForgetPin)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -144,17 +144,17 @@ private fun PinUnlockPage(
|
||||||
pinEntry = pinEntry,
|
pinEntry = pinEntry,
|
||||||
isSecured = true,
|
isSecured = true,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
state.eventSink(PinUnlockEvents.OnPinEntryChanged(it))
|
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PinKeypad(
|
PinKeypad(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(it))
|
||||||
},
|
},
|
||||||
maxWidth = constraints.maxWidth,
|
maxWidth = constraints.maxWidth,
|
||||||
maxHeight = constraints.maxHeight,
|
maxHeight = constraints.maxHeight,
|
||||||
|
|
@ -217,8 +217,8 @@ private fun PinUnlockCompactView(
|
||||||
}
|
}
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
|
|
@ -239,9 +239,9 @@ private fun PinUnlockExpandedView(
|
||||||
header()
|
header()
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 40.dp),
|
.padding(top = 40.dp),
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
|
@ -274,8 +274,8 @@ private fun PinDot(
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(14.dp)
|
.size(14.dp)
|
||||||
.background(backgroundColor, CircleShape)
|
.background(backgroundColor, CircleShape)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,14 +311,26 @@ private fun PinUnlockHeader(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
val remainingAttempts = state.remainingAttempts.dataOrNull()
|
val remainingAttempts = state.remainingAttempts.dataOrNull()
|
||||||
val subtitle = if (remainingAttempts != null) {
|
val subtitle = when {
|
||||||
if (state.showWrongPinTitle) {
|
state.isUnlocked -> {
|
||||||
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = remainingAttempts, remainingAttempts)
|
// Hide any previous error
|
||||||
} else {
|
""
|
||||||
pluralStringResource(id = R.plurals.screen_app_lock_subtitle, count = remainingAttempts, remainingAttempts)
|
|
||||||
}
|
}
|
||||||
} else {
|
remainingAttempts != null ->
|
||||||
""
|
if (state.showWrongPinTitle) {
|
||||||
|
pluralStringResource(
|
||||||
|
id = R.plurals.screen_app_lock_subtitle_wrong_pin,
|
||||||
|
count = remainingAttempts,
|
||||||
|
remainingAttempts,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(
|
||||||
|
id = R.plurals.screen_app_lock_subtitle,
|
||||||
|
count = remainingAttempts,
|
||||||
|
remainingAttempts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> ""
|
||||||
}
|
}
|
||||||
val subtitleColor = if (state.showWrongPinTitle) {
|
val subtitleColor = if (state.showWrongPinTitle) {
|
||||||
ElementTheme.colors.textCriticalPrimary
|
ElementTheme.colors.textCriticalPrimary
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricAuthentica
|
||||||
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
||||||
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
|
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
|
||||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
|
import io.element.android.features.lockscreen.impl.pin.SECRET_KEY_ALIAS
|
||||||
import io.element.android.features.lockscreen.impl.pin.createDefaultPinCodeManager
|
import io.element.android.features.lockscreen.impl.pin.createDefaultPinCodeManager
|
||||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||||
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
|
import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
|
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
|
||||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
|
|
@ -38,18 +41,18 @@ class DefaultLockScreenServiceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when the pin is mandatory, isSetupRequired emits true`() = runTest {
|
fun `when the pin is mandatory, isSetupRequired emits true`() = runTest {
|
||||||
val lockScreenStore = InMemoryLockScreenStore()
|
val secretKeyRepository = SimpleSecretKeyRepository()
|
||||||
val sut = createDefaultLockScreenService(
|
val sut = createDefaultLockScreenService(
|
||||||
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
|
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
|
||||||
lockScreenStore = lockScreenStore,
|
secretKeyRepository = secretKeyRepository,
|
||||||
)
|
)
|
||||||
sut.isSetupRequired().test {
|
sut.isSetupRequired().test {
|
||||||
assertThat(awaitItem()).isTrue()
|
assertThat(awaitItem()).isTrue()
|
||||||
// When the user configures the pin code, the setup is not required anymore
|
// When the user configures the pin code, the setup is not required anymore
|
||||||
lockScreenStore.saveEncryptedPinCode("encryptedCode")
|
secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, true)
|
||||||
assertThat(awaitItem()).isFalse()
|
assertThat(awaitItem()).isFalse()
|
||||||
// Users deletes the pin code
|
// Users deletes the pin code
|
||||||
lockScreenStore.deleteEncryptedPinCode()
|
secretKeyRepository.deleteKey("elementx.SECRET_KEY_ALIAS_PIN_CODE")
|
||||||
assertThat(awaitItem()).isTrue()
|
assertThat(awaitItem()).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,16 +60,16 @@ class DefaultLockScreenServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `when the last session is deleted, the pin code is removed`() = runTest {
|
fun `when the last session is deleted, the pin code is removed`() = runTest {
|
||||||
val sessionObserver = FakeSessionObserver()
|
val sessionObserver = FakeSessionObserver()
|
||||||
val lockScreenStore = InMemoryLockScreenStore()
|
val secretKeyRepository = SimpleSecretKeyRepository()
|
||||||
val sut = createDefaultLockScreenService(
|
val sut = createDefaultLockScreenService(
|
||||||
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
|
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
|
||||||
lockScreenStore = lockScreenStore,
|
secretKeyRepository = secretKeyRepository,
|
||||||
sessionObserver = sessionObserver,
|
sessionObserver = sessionObserver,
|
||||||
)
|
)
|
||||||
sut.isPinSetup().test {
|
sut.isPinSetup().test {
|
||||||
assertThat(awaitItem()).isFalse()
|
assertThat(awaitItem()).isFalse()
|
||||||
// When the user configure the pin code, the setup is not required anymore
|
// When the user configure the pin code, the setup is not required anymore
|
||||||
lockScreenStore.saveEncryptedPinCode("encryptedCode")
|
secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, true)
|
||||||
assertThat(awaitItem()).isTrue()
|
assertThat(awaitItem()).isTrue()
|
||||||
sessionObserver.onSessionDeleted("userId", wasLastSession = false)
|
sessionObserver.onSessionDeleted("userId", wasLastSession = false)
|
||||||
expectNoEvents()
|
expectNoEvents()
|
||||||
|
|
@ -79,8 +82,10 @@ class DefaultLockScreenServiceTest {
|
||||||
private fun TestScope.createDefaultLockScreenService(
|
private fun TestScope.createDefaultLockScreenService(
|
||||||
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
|
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
|
||||||
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
|
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
|
||||||
|
secretKeyRepository: SecretKeyRepository = SimpleSecretKeyRepository(),
|
||||||
pinCodeManager: PinCodeManager = createDefaultPinCodeManager(
|
pinCodeManager: PinCodeManager = createDefaultPinCodeManager(
|
||||||
lockScreenStore = lockScreenStore,
|
lockScreenStore = lockScreenStore,
|
||||||
|
secretKeyRepository = secretKeyRepository,
|
||||||
),
|
),
|
||||||
sessionObserver: SessionObserver = FakeSessionObserver(),
|
sessionObserver: SessionObserver = FakeSessionObserver(),
|
||||||
appForegroundStateService: AppForegroundStateService = FakeAppForegroundStateService(),
|
appForegroundStateService: AppForegroundStateService = FakeAppForegroundStateService(),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,6 @@ class FakeBiometricAuthenticator(
|
||||||
override val isActive: Boolean = false,
|
override val isActive: Boolean = false,
|
||||||
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
||||||
) : BiometricAuthenticator {
|
) : BiometricAuthenticator {
|
||||||
override fun setup() = Unit
|
override suspend fun setup() = Unit
|
||||||
override suspend fun authenticate() = authenticateLambda()
|
override suspend fun authenticate() = authenticateLambda()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
private const val DEFAULT_REMAINING_ATTEMPTS = 3
|
private const val DEFAULT_REMAINING_ATTEMPTS = 3
|
||||||
|
|
||||||
class InMemoryLockScreenStore : LockScreenStore {
|
class InMemoryLockScreenStore : LockScreenStore {
|
||||||
private val hasPinCode = MutableStateFlow(false)
|
|
||||||
private var pinCode: String? = null
|
private var pinCode: String? = null
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
hasPinCode.value = value != null
|
|
||||||
}
|
|
||||||
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
|
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
|
||||||
private var isBiometricUnlockAllowed = MutableStateFlow(false)
|
private var isBiometricUnlockAllowed = MutableStateFlow(false)
|
||||||
|
|
||||||
|
|
@ -48,10 +43,6 @@ class InMemoryLockScreenStore : LockScreenStore {
|
||||||
pinCode = null
|
pinCode = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasPinCode(): Flow<Boolean> {
|
|
||||||
return hasPinCode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||||
return isBiometricUnlockAllowed
|
return isBiometricUnlockAllowed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,19 +43,19 @@ class LockScreenSettingsPresenterTest {
|
||||||
consumeItemsUntilPredicate { state ->
|
consumeItemsUntilPredicate { state ->
|
||||||
state.showRemovePinOption
|
state.showRemovePinOption
|
||||||
}.last().also { state ->
|
}.last().also { state ->
|
||||||
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
|
state.eventSink(LockScreenSettingsEvent.OnRemovePin)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
assertThat(state.showRemovePinConfirmation).isTrue()
|
assertThat(state.showRemovePinConfirmation).isTrue()
|
||||||
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
|
state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
assertThat(state.showRemovePinConfirmation).isFalse()
|
assertThat(state.showRemovePinConfirmation).isFalse()
|
||||||
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
|
state.eventSink(LockScreenSettingsEvent.OnRemovePin)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
assertThat(state.showRemovePinConfirmation).isTrue()
|
assertThat(state.showRemovePinConfirmation).isTrue()
|
||||||
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
|
state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate {
|
consumeItemsUntilPredicate {
|
||||||
it.showRemovePinOption.not()
|
it.showRemovePinOption.not()
|
||||||
|
|
@ -93,7 +93,7 @@ class LockScreenSettingsPresenterTest {
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricEnabled).isTrue()
|
assertThat(state.isBiometricEnabled).isTrue()
|
||||||
|
|
@ -114,7 +114,7 @@ class LockScreenSettingsPresenterTest {
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +137,7 @@ class LockScreenSettingsPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricEnabled).isTrue()
|
assertThat(state.isBiometricEnabled).isTrue()
|
||||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricEnabled).isFalse()
|
assertThat(state.isBiometricEnabled).isFalse()
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.setup.biometric
|
package io.element.android.features.lockscreen.impl.setup.biometric
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
|
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
|
||||||
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
||||||
|
|
@ -18,6 +15,7 @@ import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthen
|
||||||
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
||||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -30,12 +28,10 @@ class SetupBiometricPresenterTest {
|
||||||
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
|
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
|
||||||
})
|
})
|
||||||
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
|
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricSetupDone).isFalse()
|
assertThat(state.isBiometricSetupDone).isFalse()
|
||||||
state.eventSink(SetupBiometricEvents.AllowBiometric)
|
state.eventSink(SetupBiometricEvent.AllowBiometric)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricSetupDone).isTrue()
|
assertThat(state.isBiometricSetupDone).isTrue()
|
||||||
|
|
@ -51,12 +47,10 @@ class SetupBiometricPresenterTest {
|
||||||
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
|
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
|
||||||
})
|
})
|
||||||
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
|
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricSetupDone).isFalse()
|
assertThat(state.isBiometricSetupDone).isFalse()
|
||||||
state.eventSink(SetupBiometricEvents.AllowBiometric)
|
state.eventSink(SetupBiometricEvent.AllowBiometric)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
|
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
|
||||||
|
|
@ -66,12 +60,10 @@ class SetupBiometricPresenterTest {
|
||||||
fun `present - skip flow`() = runTest {
|
fun `present - skip flow`() = runTest {
|
||||||
val lockScreenStore = InMemoryLockScreenStore()
|
val lockScreenStore = InMemoryLockScreenStore()
|
||||||
val presenter = createSetupBiometricPresenter(lockScreenStore)
|
val presenter = createSetupBiometricPresenter(lockScreenStore)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricSetupDone).isFalse()
|
assertThat(state.isBiometricSetupDone).isFalse()
|
||||||
state.eventSink(SetupBiometricEvents.UsePin)
|
state.eventSink(SetupBiometricEvent.UsePin)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.isBiometricSetupDone).isTrue()
|
assertThat(state.isBiometricSetupDone).isTrue()
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.setup.pin
|
package io.element.android.features.lockscreen.impl.setup.pin
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.lockscreen.impl.LockScreenConfig
|
import io.element.android.features.lockscreen.impl.LockScreenConfig
|
||||||
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
|
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
|
||||||
|
|
@ -24,6 +21,7 @@ import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPin
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -43,9 +41,7 @@ class SetupPinPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createSetupPinPresenter(callback)
|
val presenter = createSetupPinPresenter(callback)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
state.choosePinEntry.assertEmpty()
|
state.choosePinEntry.assertEmpty()
|
||||||
state.confirmPinEntry.assertEmpty()
|
state.confirmPinEntry.assertEmpty()
|
||||||
|
|
@ -63,7 +59,7 @@ class SetupPinPresenterTest {
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.choosePinEntry.assertText(forbiddenPin)
|
state.choosePinEntry.assertText(forbiddenPin)
|
||||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.ForbiddenPin)
|
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.ForbiddenPin)
|
||||||
state.eventSink(SetupPinEvents.ClearFailure)
|
state.eventSink(SetupPinEvent.ClearFailure)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.choosePinEntry.assertEmpty()
|
state.choosePinEntry.assertEmpty()
|
||||||
|
|
@ -82,7 +78,7 @@ class SetupPinPresenterTest {
|
||||||
state.choosePinEntry.assertText(completePin)
|
state.choosePinEntry.assertText(completePin)
|
||||||
state.confirmPinEntry.assertText(mismatchedPin)
|
state.confirmPinEntry.assertText(mismatchedPin)
|
||||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDoNotMatch)
|
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDoNotMatch)
|
||||||
state.eventSink(SetupPinEvents.ClearFailure)
|
state.eventSink(SetupPinEvent.ClearFailure)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.choosePinEntry.assertEmpty()
|
state.choosePinEntry.assertEmpty()
|
||||||
|
|
@ -108,7 +104,7 @@ class SetupPinPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SetupPinState.onPinEntryChanged(pinEntry: String) {
|
private fun SetupPinState.onPinEntryChanged(pinEntry: String) {
|
||||||
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
|
eventSink(SetupPinEvent.OnPinEntryChanged(pinEntry, isConfirmationStep))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSetupPinPresenter(
|
private fun createSetupPinPresenter(
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.unlock
|
package io.element.android.features.lockscreen.impl.unlock
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
||||||
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
||||||
|
|
@ -19,12 +16,14 @@ import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCall
|
||||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||||
import io.element.android.features.lockscreen.impl.pin.model.assertText
|
import io.element.android.features.lockscreen.impl.pin.model.assertText
|
||||||
|
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||||
import io.element.android.features.logout.test.FakeLogoutUseCase
|
import io.element.android.features.logout.test.FakeLogoutUseCase
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -36,9 +35,7 @@ class PinUnlockPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - success verify flow`() = runTest {
|
fun `present - success verify flow`() = runTest {
|
||||||
val presenter = createPinUnlockPresenter()
|
val presenter = createPinUnlockPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Uninitialized::class.java)
|
assertThat(state.pinEntry).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||||
assertThat(state.showWrongPinTitle).isFalse()
|
assertThat(state.showWrongPinTitle).isFalse()
|
||||||
|
|
@ -50,17 +47,17 @@ class PinUnlockPresenterTest {
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||||
}
|
}
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
state.pinEntry.assertText(halfCompletePin)
|
state.pinEntry.assertText(halfCompletePin)
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Back))
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Empty))
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
|
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('5')))
|
||||||
}
|
}
|
||||||
skipItems(4)
|
skipItems(4)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -73,9 +70,7 @@ class PinUnlockPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - failure verify flow`() = runTest {
|
fun `present - failure verify flow`() = runTest {
|
||||||
val presenter = createPinUnlockPresenter()
|
val presenter = createPinUnlockPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem().also { state ->
|
val initialState = awaitItem().also { state ->
|
||||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
||||||
|
|
@ -83,10 +78,10 @@ class PinUnlockPresenterTest {
|
||||||
}
|
}
|
||||||
val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
|
val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
|
||||||
repeat(numberOfAttempts) {
|
repeat(numberOfAttempts) {
|
||||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4')))
|
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('4')))
|
||||||
}
|
}
|
||||||
skipItems(4 * numberOfAttempts + 2)
|
skipItems(4 * numberOfAttempts + 2)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -102,27 +97,25 @@ class PinUnlockPresenterTest {
|
||||||
val signOutLambda = lambdaRecorder<Boolean, Unit> {}
|
val signOutLambda = lambdaRecorder<Boolean, Unit> {}
|
||||||
val signOut = FakeLogoutUseCase(signOutLambda)
|
val signOut = FakeLogoutUseCase(signOutLambda)
|
||||||
val presenter = createPinUnlockPresenter(logoutUseCase = signOut)
|
val presenter = createPinUnlockPresenter(logoutUseCase = signOut)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
|
||||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
state.eventSink(PinUnlockEvent.OnForgetPin)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.showSignOutPrompt).isTrue()
|
assertThat(state.showSignOutPrompt).isTrue()
|
||||||
assertThat(state.isSignOutPromptCancellable).isTrue()
|
assertThat(state.isSignOutPromptCancellable).isTrue()
|
||||||
state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
|
state.eventSink(PinUnlockEvent.ClearSignOutPrompt)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.showSignOutPrompt).isFalse()
|
assertThat(state.showSignOutPrompt).isFalse()
|
||||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
state.eventSink(PinUnlockEvent.OnForgetPin)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.showSignOutPrompt).isTrue()
|
assertThat(state.showSignOutPrompt).isTrue()
|
||||||
state.eventSink(PinUnlockEvents.SignOut)
|
state.eventSink(PinUnlockEvent.SignOut)
|
||||||
}
|
}
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -132,6 +125,28 @@ class PinUnlockPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - pin is configured, but deleted in store, sign out prompt will be shown`() = runTest {
|
||||||
|
val lockScreenStore = InMemoryLockScreenStore()
|
||||||
|
val pinCodeManager = aPinCodeManager(
|
||||||
|
lockScreenStore = lockScreenStore,
|
||||||
|
)
|
||||||
|
val presenter = createPinUnlockPresenter(
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
)
|
||||||
|
// Delete the pin code from the store
|
||||||
|
lockScreenStore.deleteEncryptedPinCode()
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.pinEntry).isInstanceOf(AsyncData.Failure::class.java)
|
||||||
|
assertThat(state.showSignOutPrompt).isTrue()
|
||||||
|
assertThat(state.isSignOutPromptCancellable).isFalse()
|
||||||
|
assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun AsyncData<PinEntry>.assertText(text: String) {
|
private fun AsyncData<PinEntry>.assertText(text: String) {
|
||||||
dataOrNull()?.assertText(text)
|
dataOrNull()?.assertText(text)
|
||||||
}
|
}
|
||||||
|
|
@ -139,9 +154,10 @@ class PinUnlockPresenterTest {
|
||||||
private suspend fun TestScope.createPinUnlockPresenter(
|
private suspend fun TestScope.createPinUnlockPresenter(
|
||||||
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||||
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
|
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
|
||||||
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
|
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
|
||||||
|
pinCodeManager: PinCodeManager = aPinCodeManager()
|
||||||
): PinUnlockPresenter {
|
): PinUnlockPresenter {
|
||||||
val pinCodeManager = aPinCodeManager().apply {
|
pinCodeManager.apply {
|
||||||
addCallback(callback)
|
addCallback(callback)
|
||||||
createPinCode(completePin)
|
createPinCode(completePin)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,7 @@ plugins {
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.libraries.cryptography.api"
|
namespace = "io.element.android.libraries.cryptography.api"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.cryptography.api
|
package io.element.android.libraries.cryptography.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,16 +16,18 @@ import javax.crypto.SecretKey
|
||||||
* Implementation should be able to store the generated key securely.
|
* Implementation should be able to store the generated key securely.
|
||||||
*/
|
*/
|
||||||
interface SecretKeyRepository {
|
interface SecretKeyRepository {
|
||||||
|
fun hasKey(alias: String): Flow<Boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a secret key for a given alias.
|
* Get or create a secret key for a given alias.
|
||||||
* @param alias the alias to use
|
* @param alias the alias to use
|
||||||
* @param requiresUserAuthentication true if the key should be protected by user authentication
|
* @param requiresUserAuthentication true if the key should be protected by user authentication
|
||||||
*/
|
*/
|
||||||
fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey
|
suspend fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the secret key for a given alias.
|
* Delete the secret key for a given alias.
|
||||||
* @param alias the alias to use
|
* @param alias the alias to use
|
||||||
*/
|
*/
|
||||||
fun deleteKey(alias: String)
|
suspend fun deleteKey(alias: String)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ setupDependencyInjection()
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
api(projects.libraries.cryptography.api)
|
api(projects.libraries.cryptography.api)
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,16 @@ import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
|
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
|
||||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.KeyStoreException
|
import java.security.KeyStoreException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
|
|
@ -25,13 +30,23 @@ import javax.crypto.SecretKey
|
||||||
* Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys.
|
* Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys.
|
||||||
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
|
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
|
||||||
*/
|
*/
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class KeyStoreSecretKeyRepository(
|
class KeyStoreSecretKeyRepository(
|
||||||
private val keyStore: KeyStore,
|
private val keyStore: KeyStore,
|
||||||
) : SecretKeyRepository {
|
) : SecretKeyRepository {
|
||||||
|
private val hasKeyMap = ConcurrentHashMap<String, MutableStateFlow<Boolean>>()
|
||||||
|
|
||||||
|
@Suppress("RunCatchingNotAllowed")
|
||||||
|
override fun hasKey(alias: String): Flow<Boolean> {
|
||||||
|
return hasKeyMap.getOrPut(alias) {
|
||||||
|
MutableStateFlow(runCatching { keyStore.containsAlias(alias) }.getOrDefault(false))
|
||||||
|
}.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
// False positive lint issue
|
// False positive lint issue
|
||||||
@SuppressLint("WrongConstant")
|
@SuppressLint("WrongConstant")
|
||||||
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
|
override suspend fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
|
||||||
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
|
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
|
||||||
?.secretKey
|
?.secretKey
|
||||||
return if (secretKeyEntry == null) {
|
return if (secretKeyEntry == null) {
|
||||||
|
|
@ -46,15 +61,22 @@ class KeyStoreSecretKeyRepository(
|
||||||
.setUserAuthenticationRequired(requiresUserAuthentication)
|
.setUserAuthenticationRequired(requiresUserAuthentication)
|
||||||
.build()
|
.build()
|
||||||
generator.init(keyGenSpec)
|
generator.init(keyGenSpec)
|
||||||
generator.generateKey()
|
generator.generateKey().also {
|
||||||
|
hasKeyMap.getOrPut(alias) {
|
||||||
|
MutableStateFlow(true)
|
||||||
|
}.emit(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
secretKeyEntry
|
secretKeyEntry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteKey(alias: String) {
|
override suspend fun deleteKey(alias: String) {
|
||||||
try {
|
try {
|
||||||
keyStore.deleteEntry(alias)
|
keyStore.deleteEntry(alias)
|
||||||
|
hasKeyMap.getOrPut(alias) {
|
||||||
|
MutableStateFlow(false)
|
||||||
|
}.emit(false)
|
||||||
} catch (e: KeyStoreException) {
|
} catch (e: KeyStoreException) {
|
||||||
Timber.e(e)
|
Timber.e(e)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,5 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.libraries.cryptography.api)
|
api(projects.libraries.cryptography.api)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,39 @@ package io.element.android.libraries.cryptography.test
|
||||||
|
|
||||||
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
|
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
|
||||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
class SimpleSecretKeyRepository : SecretKeyRepository {
|
class SimpleSecretKeyRepository : SecretKeyRepository {
|
||||||
private var secretKeyForAlias = HashMap<String, SecretKey>()
|
private var secretKeyForAlias = HashMap<String, SecretKey>()
|
||||||
|
|
||||||
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
|
private val hasKeyMap = ConcurrentHashMap<String, MutableStateFlow<Boolean>>()
|
||||||
|
|
||||||
|
override fun hasKey(alias: String): Flow<Boolean> {
|
||||||
|
return hasKeyMap.getOrPut(alias) {
|
||||||
|
MutableStateFlow(false)
|
||||||
|
}.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
|
||||||
return secretKeyForAlias.getOrPut(alias) {
|
return secretKeyForAlias.getOrPut(alias) {
|
||||||
generateKey()
|
generateKey().also {
|
||||||
|
hasKeyMap.getOrPut(alias) {
|
||||||
|
MutableStateFlow(true)
|
||||||
|
}.emit(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteKey(alias: String) {
|
override suspend fun deleteKey(alias: String) {
|
||||||
secretKeyForAlias.remove(alias)
|
secretKeyForAlias.remove(alias)
|
||||||
|
hasKeyMap.getOrPut(alias) {
|
||||||
|
MutableStateFlow(false)
|
||||||
|
}.emit(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateKey(): SecretKey {
|
private fun generateKey(): SecretKey {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8665179304ccd2e0acf517ddcf369c4daae51c41732ee1b9618e3bedf38aeffc
|
||||||
|
size 31643
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:14300f3af0bc8c003ce94868665547ad439826b47cf416f1a062ffa4a1d7e793
|
||||||
|
size 15782
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:88354d222679723cadfb76d2c4928fad78e17fec13514c0e4712e6f877b1a0c7
|
||||||
|
size 29627
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d505c4b2323dfc67c1b7e119960ede5b97b52d6c453610cdbb01efd485730aa5
|
||||||
|
size 15369
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:80246e3957a0e3f98bce55ddd6d8fe09b5f140eee3837d80ca20253f6bcfb876
|
||||||
|
size 37738
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:60dd90eb571f473b78ecc629573bcf2faca02819022bdf57f05cc8b0876f4ab8
|
||||||
|
size 31440
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d60a8caa2441ff0618636d307d32d0944bdb77fb3b579c0959dc951b0318ad72
|
||||||
|
size 35429
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bfe8b59b8d5d7a1c33285072dec437060faae55bfa2a42687bd8895dd90409a6
|
||||||
|
size 30310
|
||||||
Loading…
Add table
Add a link
Reference in a new issue