Merge pull request #6744 from element-hq/feature/bma/improvePinUX

Improve pin code ux
This commit is contained in:
Benoit Marty 2026-05-07 17:04:43 +02:00 committed by GitHub
commit 4497d7da36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 288 additions and 195 deletions

View file

@ -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)
}
}

View file

@ -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() }
}

View file

@ -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.

View file

@ -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
}

View file

@ -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()

View file

@ -13,5 +13,5 @@ data class LockScreenSettingsState(
val isBiometricEnabled: Boolean,
val showRemovePinConfirmation: Boolean,
val showToggleBiometric: Boolean,
val eventSink: (LockScreenSettingsEvents) -> Unit
val eventSink: (LockScreenSettingsEvent) -> Unit
)

View file

@ -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)
}
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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) }
)
},
)

View file

@ -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
}

View file

@ -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()

View file

@ -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

View file

@ -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)
}
)
}

View file

@ -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>
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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

View file

@ -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(),

View file

@ -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()
}

View file

@ -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
}

View file

@ -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()

View file

@ -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()

View file

@ -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(

View file

@ -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)
}

View file

@ -13,3 +13,7 @@ plugins {
android {
namespace = "io.element.android.libraries.cryptography.api"
}
dependencies {
implementation(libs.coroutines.core)
}

View file

@ -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)
}

View file

@ -21,6 +21,7 @@ setupDependencyInjection()
dependencies {
implementation(projects.libraries.di)
implementation(libs.coroutines.core)
api(projects.libraries.cryptography.api)
testCommonDependencies(libs)

View file

@ -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)
}

View file

@ -16,4 +16,5 @@ android {
dependencies {
api(projects.libraries.cryptography.api)
implementation(libs.coroutines.core)
}

View file

@ -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 {

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8665179304ccd2e0acf517ddcf369c4daae51c41732ee1b9618e3bedf38aeffc
size 31643

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:14300f3af0bc8c003ce94868665547ad439826b47cf416f1a062ffa4a1d7e793
size 15782

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88354d222679723cadfb76d2c4928fad78e17fec13514c0e4712e6f877b1a0c7
size 29627

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d505c4b2323dfc67c1b7e119960ede5b97b52d6c453610cdbb01efd485730aa5
size 15369

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80246e3957a0e3f98bce55ddd6d8fe09b5f140eee3837d80ca20253f6bcfb876
size 37738

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60dd90eb571f473b78ecc629573bcf2faca02819022bdf57f05cc8b0876f4ab8
size 31440

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d60a8caa2441ff0618636d307d32d0944bdb77fb3b579c0959dc951b0318ad72
size 35429

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bfe8b59b8d5d7a1c33285072dec437060faae55bfa2a42687bd8895dd90409a6
size 30310