Merge pull request #6744 from element-hq/feature/bma/improvePinUX
Improve pin code ux
This commit is contained in:
commit
4497d7da36
43 changed files with 288 additions and 195 deletions
|
|
@ -36,13 +36,13 @@ interface BiometricAuthenticator {
|
|||
}
|
||||
|
||||
val isActive: Boolean
|
||||
fun setup()
|
||||
suspend fun setup()
|
||||
suspend fun authenticate(): AuthenticationResult
|
||||
}
|
||||
|
||||
class NoopBiometricAuthentication : BiometricAuthenticator {
|
||||
override val isActive: Boolean = false
|
||||
override fun setup() = Unit
|
||||
override suspend fun setup() = Unit
|
||||
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class DefaultBiometricAuthentication(
|
|||
|
||||
private var cryptoObject: CryptoObject? = null
|
||||
|
||||
override fun setup() {
|
||||
override suspend fun setup() {
|
||||
try {
|
||||
val secretKey = ensureKey()
|
||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||
|
|
@ -86,7 +86,7 @@ class DefaultBiometricAuthentication(
|
|||
}
|
||||
|
||||
@Throws(KeyPermanentlyInvalidatedException::class)
|
||||
private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
||||
private suspend fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
||||
encryptionDecryptionService.createEncryptionCipher(it)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
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)
|
||||
@SingleIn(AppScope::class)
|
||||
|
|
@ -38,11 +38,11 @@ class DefaultPinCodeManager(
|
|||
}
|
||||
|
||||
override fun hasPinCode(): Flow<Boolean> {
|
||||
return lockScreenStore.hasPinCode()
|
||||
return secretKeyRepository.hasKey(SECRET_KEY_ALIAS)
|
||||
}
|
||||
|
||||
override suspend fun getPinCodeSize(): Int {
|
||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
|
||||
override suspend fun getPinCodeSize(): Int? {
|
||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return null
|
||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
||||
return decryptedPinCode.size
|
||||
|
|
@ -79,6 +79,7 @@ class DefaultPinCodeManager(
|
|||
override suspend fun deletePinCode() {
|
||||
lockScreenStore.deleteEncryptedPinCode()
|
||||
lockScreenStore.resetCounter()
|
||||
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||
callbacks.forEach { it.onPinCodeRemoved() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,9 +51,9 @@ interface PinCodeManager {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
package io.element.android.features.lockscreen.impl.settings
|
||||
|
||||
sealed interface LockScreenSettingsEvents {
|
||||
data object OnRemovePin : LockScreenSettingsEvents
|
||||
data object ConfirmRemovePin : LockScreenSettingsEvents
|
||||
data object CancelRemovePin : LockScreenSettingsEvents
|
||||
data object ToggleBiometricAllowed : LockScreenSettingsEvents
|
||||
sealed interface LockScreenSettingsEvent {
|
||||
data object OnRemovePin : LockScreenSettingsEvent
|
||||
data object ConfirmRemovePin : LockScreenSettingsEvent
|
||||
data object CancelRemovePin : LockScreenSettingsEvent
|
||||
data object ToggleBiometricAllowed : LockScreenSettingsEvent
|
||||
}
|
||||
|
|
@ -51,10 +51,10 @@ class LockScreenSettingsPresenter(
|
|||
|
||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||
|
||||
fun handleEvent(event: LockScreenSettingsEvents) {
|
||||
fun handleEvent(event: LockScreenSettingsEvent) {
|
||||
when (event) {
|
||||
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
|
||||
LockScreenSettingsEvents.ConfirmRemovePin -> {
|
||||
LockScreenSettingsEvent.CancelRemovePin -> showRemovePinConfirmation = false
|
||||
LockScreenSettingsEvent.ConfirmRemovePin -> {
|
||||
coroutineScope.launch {
|
||||
if (showRemovePinConfirmation) {
|
||||
showRemovePinConfirmation = false
|
||||
|
|
@ -62,8 +62,8 @@ class LockScreenSettingsPresenter(
|
|||
}
|
||||
}
|
||||
}
|
||||
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
|
||||
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
|
||||
LockScreenSettingsEvent.OnRemovePin -> showRemovePinConfirmation = true
|
||||
LockScreenSettingsEvent.ToggleBiometricAllowed -> {
|
||||
coroutineScope.launch {
|
||||
if (!isBiometricEnabled) {
|
||||
biometricUnlock.setup()
|
||||
|
|
|
|||
|
|
@ -13,5 +13,5 @@ data class LockScreenSettingsState(
|
|||
val isBiometricEnabled: Boolean,
|
||||
val showRemovePinConfirmation: Boolean,
|
||||
val showToggleBiometric: Boolean,
|
||||
val eventSink: (LockScreenSettingsEvents) -> Unit
|
||||
val eventSink: (LockScreenSettingsEvent) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ fun LockScreenSettingsView(
|
|||
},
|
||||
style = ListItemStyle.Destructive,
|
||||
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),
|
||||
isChecked = state.isBiometricEnabled,
|
||||
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),
|
||||
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
|
||||
onSubmitClick = {
|
||||
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
package io.element.android.features.lockscreen.impl.setup.biometric
|
||||
|
||||
sealed interface SetupBiometricEvents {
|
||||
data object AllowBiometric : SetupBiometricEvents
|
||||
data object UsePin : SetupBiometricEvents
|
||||
sealed interface SetupBiometricEvent {
|
||||
data object AllowBiometric : SetupBiometricEvent
|
||||
data object UsePin : SetupBiometricEvent
|
||||
}
|
||||
|
|
@ -35,16 +35,16 @@ class SetupBiometricPresenter(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||
|
||||
fun handleEvent(event: SetupBiometricEvents) {
|
||||
fun handleEvent(event: SetupBiometricEvent) {
|
||||
when (event) {
|
||||
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
|
||||
SetupBiometricEvent.AllowBiometric -> coroutineScope.launch {
|
||||
biometricUnlock.setup()
|
||||
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(true)
|
||||
isBiometricSetupDone = true
|
||||
}
|
||||
}
|
||||
SetupBiometricEvents.UsePin -> coroutineScope.launch {
|
||||
SetupBiometricEvent.UsePin -> coroutineScope.launch {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
||||
isBiometricSetupDone = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ package io.element.android.features.lockscreen.impl.setup.biometric
|
|||
|
||||
data class SetupBiometricState(
|
||||
val isBiometricSetupDone: Boolean,
|
||||
val eventSink: (SetupBiometricEvents) -> Unit
|
||||
val eventSink: (SetupBiometricEvent) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ fun SetupBiometricView(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler {
|
||||
state.eventSink(SetupBiometricEvents.UsePin)
|
||||
state.eventSink(SetupBiometricEvent.UsePin)
|
||||
}
|
||||
HeaderFooterPage(
|
||||
modifier = modifier.padding(top = 80.dp),
|
||||
|
|
@ -42,8 +42,8 @@ fun SetupBiometricView(
|
|||
},
|
||||
footer = {
|
||||
SetupBiometricFooter(
|
||||
onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
|
||||
onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) }
|
||||
onAllowClick = { state.eventSink(SetupBiometricEvent.AllowBiometric) },
|
||||
onSkipClick = { state.eventSink(SetupBiometricEvent.UsePin) }
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
sealed interface SetupPinEvents {
|
||||
data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents
|
||||
data object ClearFailure : SetupPinEvents
|
||||
sealed interface SetupPinEvent {
|
||||
data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvent
|
||||
data object ClearFailure : SetupPinEvent
|
||||
}
|
||||
|
|
@ -74,9 +74,9 @@ class SetupPinPresenter(
|
|||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: SetupPinEvents) {
|
||||
fun handleEvent(event: SetupPinEvent) {
|
||||
when (event) {
|
||||
is SetupPinEvents.OnPinEntryChanged -> {
|
||||
is SetupPinEvent.OnPinEntryChanged -> {
|
||||
// Use the fromConfirmationStep flag from ui to avoid race condition.
|
||||
if (event.fromConfirmationStep) {
|
||||
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
||||
|
|
@ -84,7 +84,7 @@ class SetupPinPresenter(
|
|||
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
||||
}
|
||||
}
|
||||
SetupPinEvents.ClearFailure -> {
|
||||
SetupPinEvent.ClearFailure -> {
|
||||
when (setupPinFailure) {
|
||||
is SetupPinFailure.PinsDoNotMatch -> {
|
||||
choosePinEntry = choosePinEntry.clear()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ data class SetupPinState(
|
|||
val isConfirmationStep: Boolean,
|
||||
val setupPinFailure: SetupPinFailure?,
|
||||
val appName: String,
|
||||
val eventSink: (SetupPinEvents) -> Unit
|
||||
val eventSink: (SetupPinEvent) -> Unit
|
||||
) {
|
||||
val activePinEntry = if (isConfirmationStep) {
|
||||
confirmPinEntry
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ private fun SetupPinContent(
|
|||
pinEntry = state.activePinEntry,
|
||||
isSecured = true,
|
||||
onValueChange = { entry ->
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
|
||||
state.eventSink(SetupPinEvent.OnPinEntryChanged(entry, state.isConfirmationStep))
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
|
|
@ -119,7 +119,7 @@ private fun SetupPinContent(
|
|||
title = state.setupPinFailure.title(),
|
||||
content = state.setupPinFailure.content(),
|
||||
onSubmit = {
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
state.eventSink(SetupPinEvent.ClearFailure)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
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> {
|
||||
return dataStore.data.map { preferences ->
|
||||
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
|
||||
|
||||
sealed interface PinUnlockEvents {
|
||||
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
|
||||
data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents
|
||||
data object OnForgetPin : PinUnlockEvents
|
||||
data object ClearSignOutPrompt : PinUnlockEvents
|
||||
data object SignOut : PinUnlockEvents
|
||||
data object OnUseBiometric : PinUnlockEvents
|
||||
data object ClearBiometricError : PinUnlockEvents
|
||||
sealed interface PinUnlockEvent {
|
||||
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvent
|
||||
data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvent
|
||||
data object OnForgetPin : PinUnlockEvent
|
||||
data object ClearSignOutPrompt : PinUnlockEvent
|
||||
data object SignOut : PinUnlockEvent
|
||||
data object OnUseBiometric : PinUnlockEvent
|
||||
data object ClearBiometricError : PinUnlockEvent
|
||||
}
|
||||
|
|
@ -69,7 +69,13 @@ class PinUnlockPresenter(
|
|||
LaunchedEffect(Unit) {
|
||||
suspend {
|
||||
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)
|
||||
}
|
||||
LaunchedEffect(biometricUnlock) {
|
||||
|
|
@ -95,28 +101,28 @@ class PinUnlockPresenter(
|
|||
isUnlocked.value = true
|
||||
}
|
||||
|
||||
fun handleEvent(event: PinUnlockEvents) {
|
||||
fun handleEvent(event: PinUnlockEvent) {
|
||||
when (event) {
|
||||
is PinUnlockEvents.OnPinKeypadPressed -> {
|
||||
is PinUnlockEvent.OnPinKeypadPressed -> {
|
||||
pinEntryState.value = pinEntry.process(event.pinKeypadModel)
|
||||
}
|
||||
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
|
||||
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
|
||||
PinUnlockEvents.SignOut -> {
|
||||
PinUnlockEvent.OnForgetPin -> showSignOutPrompt = true
|
||||
PinUnlockEvent.ClearSignOutPrompt -> showSignOutPrompt = false
|
||||
PinUnlockEvent.SignOut -> {
|
||||
if (showSignOutPrompt) {
|
||||
showSignOutPrompt = false
|
||||
coroutineScope.signOut(signOutAction)
|
||||
}
|
||||
}
|
||||
PinUnlockEvents.OnUseBiometric -> {
|
||||
PinUnlockEvent.OnUseBiometric -> {
|
||||
coroutineScope.launch {
|
||||
biometricUnlockResult = biometricUnlock.authenticate()
|
||||
}
|
||||
}
|
||||
PinUnlockEvents.ClearBiometricError -> {
|
||||
PinUnlockEvent.ClearBiometricError -> {
|
||||
biometricUnlockResult = null
|
||||
}
|
||||
is PinUnlockEvents.OnPinEntryChanged -> {
|
||||
is PinUnlockEvent.OnPinEntryChanged -> {
|
||||
pinEntryState.value = pinEntry.process(event.entryAsText)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,15 @@ data class PinUnlockState(
|
|||
val showBiometricUnlock: Boolean,
|
||||
val isUnlocked: Boolean,
|
||||
val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?,
|
||||
val eventSink: (PinUnlockEvents) -> Unit
|
||||
val eventSink: (PinUnlockEvent) -> Unit
|
||||
) {
|
||||
val isSignOutPromptCancellable = when (remainingAttempts) {
|
||||
is AsyncData.Success -> remainingAttempts.data > 0
|
||||
else -> true
|
||||
val isSignOutPromptCancellable = if (pinEntry.isFailure()) {
|
||||
false
|
||||
} else {
|
||||
when (remainingAttempts) {
|
||||
is AsyncData.Success -> remainingAttempts.data > 0
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
val biometricUnlockErrorMessage = when {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
|||
override val values: Sequence<PinUnlockState>
|
||||
get() = sequenceOf(
|
||||
aPinUnlockState(),
|
||||
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
|
||||
aPinUnlockState(pinEntry = AsyncData.Success(PinEntry.createEmpty(4).fillWith("12"))),
|
||||
aPinUnlockState(showWrongPinTitle = true),
|
||||
aPinUnlockState(showSignOutPrompt = true),
|
||||
aPinUnlockState(showBiometricUnlock = false),
|
||||
|
|
@ -31,11 +31,18 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
|||
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(
|
||||
pinEntry: PinEntry = PinEntry.createEmpty(4),
|
||||
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
|
||||
remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
|
||||
showWrongPinTitle: Boolean = false,
|
||||
showSignOutPrompt: Boolean = false,
|
||||
|
|
@ -44,7 +51,7 @@ fun aPinUnlockState(
|
|||
isUnlocked: Boolean = false,
|
||||
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
) = PinUnlockState(
|
||||
pinEntry = AsyncData.Success(pinEntry),
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
showSignOutPrompt = showSignOutPrompt,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ fun PinUnlockView(
|
|||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
|
||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvent.OnUseBiometric)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -78,8 +78,8 @@ fun PinUnlockView(
|
|||
if (state.showSignOutPrompt) {
|
||||
SignOutPrompt(
|
||||
isCancellable = state.isSignOutPromptCancellable,
|
||||
onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
|
||||
onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
|
||||
onSignOut = { state.eventSink(PinUnlockEvent.SignOut) },
|
||||
onDismiss = { state.eventSink(PinUnlockEvent.ClearSignOutPrompt) },
|
||||
)
|
||||
}
|
||||
when (state.signOutAction) {
|
||||
|
|
@ -95,7 +95,7 @@ fun PinUnlockView(
|
|||
if (state.showBiometricUnlockError) {
|
||||
ErrorDialog(
|
||||
content = state.biometricUnlockErrorMessage ?: "",
|
||||
onSubmit = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
|
||||
onSubmit = { state.eventSink(PinUnlockEvent.ClearBiometricError) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -108,10 +108,10 @@ private fun PinUnlockPage(
|
|||
) {
|
||||
BoxWithConstraints {
|
||||
val commonModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(all = 20.dp)
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(all = 20.dp)
|
||||
|
||||
val header = @Composable {
|
||||
PinUnlockHeader(
|
||||
|
|
@ -125,10 +125,10 @@ private fun PinUnlockPage(
|
|||
modifier = Modifier.padding(top = 24.dp),
|
||||
showBiometricUnlock = state.showBiometricUnlock,
|
||||
onUseBiometric = {
|
||||
state.eventSink(PinUnlockEvents.OnUseBiometric)
|
||||
state.eventSink(PinUnlockEvent.OnUseBiometric)
|
||||
},
|
||||
onForgotPin = {
|
||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
||||
state.eventSink(PinUnlockEvent.OnForgetPin)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -144,17 +144,17 @@ private fun PinUnlockPage(
|
|||
pinEntry = pinEntry,
|
||||
isSecured = true,
|
||||
onValueChange = {
|
||||
state.eventSink(PinUnlockEvents.OnPinEntryChanged(it))
|
||||
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PinKeypad(
|
||||
onClick = {
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(it))
|
||||
},
|
||||
maxWidth = constraints.maxWidth,
|
||||
maxHeight = constraints.maxHeight,
|
||||
|
|
@ -217,8 +217,8 @@ private fun PinUnlockCompactView(
|
|||
}
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
|
|
@ -239,9 +239,9 @@ private fun PinUnlockExpandedView(
|
|||
header()
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
|
@ -274,8 +274,8 @@ private fun PinDot(
|
|||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -311,14 +311,26 @@ private fun PinUnlockHeader(
|
|||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val remainingAttempts = state.remainingAttempts.dataOrNull()
|
||||
val subtitle = if (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)
|
||||
val subtitle = when {
|
||||
state.isUnlocked -> {
|
||||
// Hide any previous error
|
||||
""
|
||||
}
|
||||
} 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) {
|
||||
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.fixtures.aLockScreenConfig
|
||||
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.storage.InMemoryLockScreenStore
|
||||
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.test.observer.FakeSessionObserver
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
|
|
@ -38,18 +41,18 @@ class DefaultLockScreenServiceTest {
|
|||
|
||||
@Test
|
||||
fun `when the pin is mandatory, isSetupRequired emits true`() = runTest {
|
||||
val lockScreenStore = InMemoryLockScreenStore()
|
||||
val secretKeyRepository = SimpleSecretKeyRepository()
|
||||
val sut = createDefaultLockScreenService(
|
||||
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
|
||||
lockScreenStore = lockScreenStore,
|
||||
secretKeyRepository = secretKeyRepository,
|
||||
)
|
||||
sut.isSetupRequired().test {
|
||||
assertThat(awaitItem()).isTrue()
|
||||
// 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()
|
||||
// Users deletes the pin code
|
||||
lockScreenStore.deleteEncryptedPinCode()
|
||||
secretKeyRepository.deleteKey("elementx.SECRET_KEY_ALIAS_PIN_CODE")
|
||||
assertThat(awaitItem()).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
@ -57,16 +60,16 @@ class DefaultLockScreenServiceTest {
|
|||
@Test
|
||||
fun `when the last session is deleted, the pin code is removed`() = runTest {
|
||||
val sessionObserver = FakeSessionObserver()
|
||||
val lockScreenStore = InMemoryLockScreenStore()
|
||||
val secretKeyRepository = SimpleSecretKeyRepository()
|
||||
val sut = createDefaultLockScreenService(
|
||||
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
|
||||
lockScreenStore = lockScreenStore,
|
||||
secretKeyRepository = secretKeyRepository,
|
||||
sessionObserver = sessionObserver,
|
||||
)
|
||||
sut.isPinSetup().test {
|
||||
assertThat(awaitItem()).isFalse()
|
||||
// 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()
|
||||
sessionObserver.onSessionDeleted("userId", wasLastSession = false)
|
||||
expectNoEvents()
|
||||
|
|
@ -79,8 +82,10 @@ class DefaultLockScreenServiceTest {
|
|||
private fun TestScope.createDefaultLockScreenService(
|
||||
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
|
||||
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
|
||||
secretKeyRepository: SecretKeyRepository = SimpleSecretKeyRepository(),
|
||||
pinCodeManager: PinCodeManager = createDefaultPinCodeManager(
|
||||
lockScreenStore = lockScreenStore,
|
||||
secretKeyRepository = secretKeyRepository,
|
||||
),
|
||||
sessionObserver: SessionObserver = FakeSessionObserver(),
|
||||
appForegroundStateService: AppForegroundStateService = FakeAppForegroundStateService(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@ class FakeBiometricAuthenticator(
|
|||
override val isActive: Boolean = false,
|
||||
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
||||
) : BiometricAuthenticator {
|
||||
override fun setup() = Unit
|
||||
override suspend fun setup() = Unit
|
||||
override suspend fun authenticate() = authenticateLambda()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
private const val DEFAULT_REMAINING_ATTEMPTS = 3
|
||||
|
||||
class InMemoryLockScreenStore : LockScreenStore {
|
||||
private val hasPinCode = MutableStateFlow(false)
|
||||
private var pinCode: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
hasPinCode.value = value != null
|
||||
}
|
||||
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
|
||||
private var isBiometricUnlockAllowed = MutableStateFlow(false)
|
||||
|
||||
|
|
@ -48,10 +43,6 @@ class InMemoryLockScreenStore : LockScreenStore {
|
|||
pinCode = null
|
||||
}
|
||||
|
||||
override fun hasPinCode(): Flow<Boolean> {
|
||||
return hasPinCode
|
||||
}
|
||||
|
||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||
return isBiometricUnlockAllowed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,19 +43,19 @@ class LockScreenSettingsPresenterTest {
|
|||
consumeItemsUntilPredicate { state ->
|
||||
state.showRemovePinOption
|
||||
}.last().also { state ->
|
||||
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.OnRemovePin)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
assertThat(state.showRemovePinConfirmation).isTrue()
|
||||
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
assertThat(state.showRemovePinConfirmation).isFalse()
|
||||
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.OnRemovePin)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
assertThat(state.showRemovePinConfirmation).isTrue()
|
||||
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
|
||||
}
|
||||
consumeItemsUntilPredicate {
|
||||
it.showRemovePinOption.not()
|
||||
|
|
@ -93,7 +93,7 @@ class LockScreenSettingsPresenterTest {
|
|||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
||||
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricEnabled).isTrue()
|
||||
|
|
@ -114,7 +114,7 @@ class LockScreenSettingsPresenterTest {
|
|||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
||||
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ class LockScreenSettingsPresenterTest {
|
|||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricEnabled).isTrue()
|
||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
||||
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricEnabled).isFalse()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@
|
|||
|
||||
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 io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
|
||||
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.pin.storage.InMemoryLockScreenStore
|
||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -30,12 +28,10 @@ class SetupBiometricPresenterTest {
|
|||
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
|
||||
})
|
||||
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricSetupDone).isFalse()
|
||||
state.eventSink(SetupBiometricEvents.AllowBiometric)
|
||||
state.eventSink(SetupBiometricEvent.AllowBiometric)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricSetupDone).isTrue()
|
||||
|
|
@ -51,12 +47,10 @@ class SetupBiometricPresenterTest {
|
|||
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
|
||||
})
|
||||
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricSetupDone).isFalse()
|
||||
state.eventSink(SetupBiometricEvents.AllowBiometric)
|
||||
state.eventSink(SetupBiometricEvent.AllowBiometric)
|
||||
}
|
||||
}
|
||||
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
|
||||
|
|
@ -66,12 +60,10 @@ class SetupBiometricPresenterTest {
|
|||
fun `present - skip flow`() = runTest {
|
||||
val lockScreenStore = InMemoryLockScreenStore()
|
||||
val presenter = createSetupBiometricPresenter(lockScreenStore)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricSetupDone).isFalse()
|
||||
state.eventSink(SetupBiometricEvents.UsePin)
|
||||
state.eventSink(SetupBiometricEvent.UsePin)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isBiometricSetupDone).isTrue()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@
|
|||
|
||||
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 io.element.android.features.lockscreen.impl.LockScreenConfig
|
||||
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.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -43,9 +41,7 @@ class SetupPinPresenterTest {
|
|||
}
|
||||
}
|
||||
val presenter = createSetupPinPresenter(callback)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
|
|
@ -63,7 +59,7 @@ class SetupPinPresenterTest {
|
|||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(forbiddenPin)
|
||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.ForbiddenPin)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
state.eventSink(SetupPinEvent.ClearFailure)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
|
|
@ -82,7 +78,7 @@ class SetupPinPresenterTest {
|
|||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertText(mismatchedPin)
|
||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDoNotMatch)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
state.eventSink(SetupPinEvent.ClearFailure)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
|
|
@ -108,7 +104,7 @@ class SetupPinPresenterTest {
|
|||
}
|
||||
|
||||
private fun SetupPinState.onPinEntryChanged(pinEntry: String) {
|
||||
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
|
||||
eventSink(SetupPinEvent.OnPinEntryChanged(pinEntry, isConfirmationStep))
|
||||
}
|
||||
|
||||
private fun createSetupPinPresenter(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@
|
|||
|
||||
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 io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
||||
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.model.PinEntry
|
||||
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.logout.test.FakeLogoutUseCase
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -36,9 +35,7 @@ class PinUnlockPresenterTest {
|
|||
@Test
|
||||
fun `present - success verify flow`() = runTest {
|
||||
val presenter = createPinUnlockPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
assertThat(state.showWrongPinTitle).isFalse()
|
||||
|
|
@ -50,17 +47,17 @@ class PinUnlockPresenterTest {
|
|||
awaitItem().also { state ->
|
||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
state.pinEntry.assertText(halfCompletePin)
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Back))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Empty))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('5')))
|
||||
}
|
||||
skipItems(4)
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -73,9 +70,7 @@ class PinUnlockPresenterTest {
|
|||
@Test
|
||||
fun `present - failure verify flow`() = runTest {
|
||||
val presenter = createPinUnlockPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem().also { state ->
|
||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
||||
|
|
@ -83,10 +78,10 @@ class PinUnlockPresenterTest {
|
|||
}
|
||||
val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
|
||||
repeat(numberOfAttempts) {
|
||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4')))
|
||||
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('4')))
|
||||
}
|
||||
skipItems(4 * numberOfAttempts + 2)
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -102,27 +97,25 @@ class PinUnlockPresenterTest {
|
|||
val signOutLambda = lambdaRecorder<Boolean, Unit> {}
|
||||
val signOut = FakeLogoutUseCase(signOutLambda)
|
||||
val presenter = createPinUnlockPresenter(logoutUseCase = signOut)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
|
||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
||||
state.eventSink(PinUnlockEvent.OnForgetPin)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showSignOutPrompt).isTrue()
|
||||
assertThat(state.isSignOutPromptCancellable).isTrue()
|
||||
state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
|
||||
state.eventSink(PinUnlockEvent.ClearSignOutPrompt)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showSignOutPrompt).isFalse()
|
||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
||||
state.eventSink(PinUnlockEvent.OnForgetPin)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showSignOutPrompt).isTrue()
|
||||
state.eventSink(PinUnlockEvents.SignOut)
|
||||
state.eventSink(PinUnlockEvent.SignOut)
|
||||
}
|
||||
skipItems(2)
|
||||
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) {
|
||||
dataOrNull()?.assertText(text)
|
||||
}
|
||||
|
|
@ -139,9 +154,10 @@ class PinUnlockPresenterTest {
|
|||
private suspend fun TestScope.createPinUnlockPresenter(
|
||||
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
|
||||
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
|
||||
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
|
||||
pinCodeManager: PinCodeManager = aPinCodeManager()
|
||||
): PinUnlockPresenter {
|
||||
val pinCodeManager = aPinCodeManager().apply {
|
||||
pinCodeManager.apply {
|
||||
addCallback(callback)
|
||||
createPinCode(completePin)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,3 +13,7 @@ plugins {
|
|||
android {
|
||||
namespace = "io.element.android.libraries.cryptography.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
package io.element.android.libraries.cryptography.api
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
/**
|
||||
|
|
@ -15,16 +16,18 @@ import javax.crypto.SecretKey
|
|||
* Implementation should be able to store the generated key securely.
|
||||
*/
|
||||
interface SecretKeyRepository {
|
||||
fun hasKey(alias: String): Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Get or create a secret key for a given alias.
|
||||
* @param alias the alias to use
|
||||
* @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.
|
||||
* @param alias the alias to use
|
||||
*/
|
||||
fun deleteKey(alias: String)
|
||||
suspend fun deleteKey(alias: String)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ setupDependencyInjection()
|
|||
|
||||
dependencies {
|
||||
implementation(projects.libraries.di)
|
||||
implementation(libs.coroutines.core)
|
||||
api(projects.libraries.cryptography.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ import android.security.keystore.KeyGenParameterSpec
|
|||
import android.security.keystore.KeyProperties
|
||||
import dev.zacsweers.metro.AppScope
|
||||
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.SecretKeyRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import java.security.KeyStoreException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.crypto.KeyGenerator
|
||||
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.
|
||||
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class KeyStoreSecretKeyRepository(
|
||||
private val keyStore: KeyStore,
|
||||
) : 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
|
||||
@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)
|
||||
?.secretKey
|
||||
return if (secretKeyEntry == null) {
|
||||
|
|
@ -46,15 +61,22 @@ class KeyStoreSecretKeyRepository(
|
|||
.setUserAuthenticationRequired(requiresUserAuthentication)
|
||||
.build()
|
||||
generator.init(keyGenSpec)
|
||||
generator.generateKey()
|
||||
generator.generateKey().also {
|
||||
hasKeyMap.getOrPut(alias) {
|
||||
MutableStateFlow(true)
|
||||
}.emit(true)
|
||||
}
|
||||
} else {
|
||||
secretKeyEntry
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteKey(alias: String) {
|
||||
override suspend fun deleteKey(alias: String) {
|
||||
try {
|
||||
keyStore.deleteEntry(alias)
|
||||
hasKeyMap.getOrPut(alias) {
|
||||
MutableStateFlow(false)
|
||||
}.emit(false)
|
||||
} catch (e: KeyStoreException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ android {
|
|||
|
||||
dependencies {
|
||||
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.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.SecretKey
|
||||
|
||||
class SimpleSecretKeyRepository : SecretKeyRepository {
|
||||
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) {
|
||||
generateKey()
|
||||
generateKey().also {
|
||||
hasKeyMap.getOrPut(alias) {
|
||||
MutableStateFlow(true)
|
||||
}.emit(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteKey(alias: String) {
|
||||
override suspend fun deleteKey(alias: String) {
|
||||
secretKeyForAlias.remove(alias)
|
||||
hasKeyMap.getOrPut(alias) {
|
||||
MutableStateFlow(false)
|
||||
}.emit(false)
|
||||
}
|
||||
|
||||
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