Feature/fga/biometric unlock (#1702)

* Biometric unlock : refactor a bit existing classes

* Biometric unlock : first implementation

* Biometric: add ui for biometric setup

* Biometric unlock : use localazy strings

* Biometric unlock setup : branch skip/allow events

* Biometric : fix tests

* Biometrics: add small test

* Biometric : clean up

* Update screenshots

* Biometric unlock : address some PR review

* Biometric : improve a bit edge cases

* Fix lint issues

---------

Co-authored-by: ganfra <francoisg@element.io>
Co-authored-by: ElementBot <benoitm+elementbot@element.io>
Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
ganfra
2023-10-31 19:22:43 +01:00
committed by GitHub
parent a008f342de
commit 8d903362c8
86 changed files with 1270 additions and 107 deletions

View File

@@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
@@ -42,7 +42,7 @@ import timber.log.Timber
private val loggerTag = LoggerTag("MainActivity")
class MainActivity : NodeComponentActivity() {
class MainActivity : NodeActivity() {
private lateinit var mainNode: MainNode

View File

@@ -22,5 +22,5 @@
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style>
<style name="Theme.ElementX" parent="Theme.Material3.Dark" />
<style name="Theme.ElementX" parent="Theme.Material3.Dark.NoActionBar" />
</resources>

View File

@@ -21,5 +21,5 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style>
<style name="Theme.ElementX" parent="Theme.Material3.Light" />
<style name="Theme.ElementX" parent="Theme.Material3.Light.NoActionBar" />
</resources>

View File

@@ -20,6 +20,8 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Configuration for the lock screen feature.
@@ -48,7 +50,16 @@ data class LockScreenConfig(
/**
* Time period before locking the app once backgrounded.
*/
val gracePeriodInMillis: Long
val gracePeriod: Duration,
/**
* Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
*/
val isStrongBiometricsEnabled: Boolean,
/**
* Authentication with weak methods (most face/iris unlock implementations) is supported.
*/
val isWeakBiometricsEnabled: Boolean,
)
@ContributesTo(AppScope::class)
@@ -61,6 +72,8 @@ object LockScreenConfigModule {
pinBlacklist = setOf("0000", "1234"),
pinSize = 4,
maxPinCodeAttemptsBeforeLogout = 3,
gracePeriodInMillis = 90_000L
gracePeriod = 90.seconds,
isStrongBiometricsEnabled = true,
isWeakBiometricsEnabled = true,
)
}

View File

@@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.biometric)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -20,8 +20,11 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -36,16 +39,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLockScreenService @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val featureFlagService: FeatureFlagService,
private val lockScreenStore: LockScreenStore,
private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,
private val biometricUnlockManager: BiometricUnlockManager,
) : LockScreenService {
private val _lockScreenState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
@@ -63,6 +69,14 @@ class DefaultLockScreenService @Inject constructor(
_lockScreenState.value = LockScreenLockState.Unlocked
}
})
biometricUnlockManager.addCallback(object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
_lockScreenState.value = LockScreenLockState.Unlocked
coroutineScope.launch {
lockScreenStore.resetCounter()
}
}
})
coroutineScope.lockIfNeeded()
observeAppForegroundState()
observeSessionsState()
@@ -93,7 +107,7 @@ class DefaultLockScreenService @Inject constructor(
if (isInForeground) {
lockJob?.cancel()
} else {
lockJob = lockIfNeeded(delayInMillis = lockScreenConfig.gracePeriodInMillis)
lockJob = lockIfNeeded(gracePeriod = lockScreenConfig.gracePeriod)
}
}
}
@@ -105,9 +119,9 @@ class DefaultLockScreenService @Inject constructor(
&& !pinCodeManager.isPinCodeAvailable()
}
private fun CoroutineScope.lockIfNeeded(delayInMillis: Long = 0L) = launch {
private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
delay(delayInMillis)
delay(gracePeriod)
_lockScreenState.value = LockScreenLockState.Locked
}
}

View File

@@ -33,7 +33,7 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
@@ -97,7 +97,7 @@ class LockScreenFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext)
}
NavTarget.Setup -> {
createNode<SetupPinNode>(buildContext)
createNode<LockScreenSetupFlowNode>(buildContext)
}
NavTarget.Settings -> {
createNode<LockScreenSettingsFlowNode>(buildContext)

View File

@@ -0,0 +1,134 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.biometric
import android.security.keystore.KeyPermanentlyInvalidatedException
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.CryptoObject
import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber
import java.security.InvalidKeyException
import javax.crypto.Cipher
interface BiometricUnlock {
interface Callback {
fun onBiometricSetupError()
fun onBiometricUnlockSuccess()
fun onBiometricUnlockFailed(error: Exception?)
}
sealed interface AuthenticationResult {
data object Success : AuthenticationResult
data class Failure(val error: Exception? = null) : AuthenticationResult
}
val isActive: Boolean
fun setup()
suspend fun authenticate(): AuthenticationResult
}
class NoopBiometricUnlock : BiometricUnlock {
override val isActive: Boolean = false
override fun setup() = Unit
override suspend fun authenticate() = BiometricUnlock.AuthenticationResult.Failure()
}
class DefaultBiometricUnlock(
private val activity: FragmentActivity,
private val promptInfo: PromptInfo,
private val secretKeyRepository: SecretKeyRepository,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val keyAlias: String,
private val callbacks: List<BiometricUnlock.Callback>
) : BiometricUnlock {
override val isActive: Boolean = true
private lateinit var cryptoObject: CryptoObject
override fun setup() {
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
cryptoObject = CryptoObject(cipher)
} catch (e: InvalidKeyException) {
callbacks.forEach { it.onBiometricSetupError() }
Timber.e(e, "Invalid biometric key")
}
}
override suspend fun authenticate(): BiometricUnlock.AuthenticationResult {
if (!this::cryptoObject.isInitialized) {
return BiometricUnlock.AuthenticationResult.Failure()
}
val deferredAuthenticationResult = CompletableDeferred<BiometricUnlock.AuthenticationResult>()
val executor = ContextCompat.getMainExecutor(activity.baseContext)
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo, cryptoObject)
return deferredAuthenticationResult.await()
}
@Throws(KeyPermanentlyInvalidatedException::class)
private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
encryptionDecryptionService.createEncryptionCipher(it)
}
}
private class AuthenticationCallback(
private val callbacks: List<BiometricUnlock.Callback>,
private val deferredAuthenticationResult: CompletableDeferred<BiometricUnlock.AuthenticationResult>,
) : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
val biometricUnlockError = BiometricUnlockError(errorCode, errString.toString())
callbacks.forEach { it.onBiometricUnlockFailed(biometricUnlockError) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(biometricUnlockError))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
callbacks.forEach { it.onBiometricUnlockFailed(null) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
if (result.cryptoObject?.cipher.isValid()) {
callbacks.forEach { it.onBiometricUnlockSuccess() }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Success)
} else {
val error = IllegalStateException("Invalid cipher")
callbacks.forEach { it.onBiometricUnlockFailed(error) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure())
}
}
private fun Cipher?.isValid(): Boolean {
if (this == null) return false
return runCatching {
doFinal("biometric_challenge".toByteArray())
}.isSuccess
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.biometric
import androidx.biometric.BiometricPrompt
/**
* Wrapper for [BiometricPrompt.AuthenticationCallback] errors.
*/
class BiometricUnlockError(val code: Int, message: String) : Exception(message) {
/**
* This error disables Biometric authentication, either temporarily or permanently.
*/
val isAuthDisabledError: Boolean get() = code in LOCKOUT_ERROR_CODES
/**
* This error permanently disables Biometric authentication.
*/
val isAuthPermanentlyDisabledError: Boolean get() = code == BiometricPrompt.ERROR_LOCKOUT_PERMANENT
companion object {
private val LOCKOUT_ERROR_CODES = arrayOf(BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.biometric
import androidx.compose.runtime.Composable
interface BiometricUnlockManager {
/**
* If the device is secured for example with a pin, pattern or password.
*/
val isDeviceSecured: Boolean
/**
* If the device has biometric hardware and if the user has enrolled at least one biometric.
*/
val hasAvailableAuthenticator: Boolean
fun addCallback(callback: BiometricUnlock.Callback)
fun removeCallback(callback: BiometricUnlock.Callback)
@Composable
fun rememberBiometricUnlock(): BiometricUnlock
}

View File

@@ -14,14 +14,10 @@
* limitations under the License.
*/
package io.element.android.libraries.cryptography.api
package io.element.android.features.lockscreen.impl.biometric
import javax.crypto.SecretKey
/**
* Simple interface to get or create a secret key for a given alias.
* Implementation should be able to store the generated key securely.
*/
interface SecretKeyProvider {
fun getOrCreateKey(alias: String): SecretKey
open class DefaultBiometricUnlockCallback : BiometricUnlock.Callback {
override fun onBiometricSetupError() = Unit
override fun onBiometricUnlockSuccess() = Unit
override fun onBiometricUnlockFailed(error: Exception?) = Unit
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.biometric
import android.app.KeyguardManager
import android.content.Context
import android.content.ContextWrapper
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_BIOMETRIC"
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultBiometricUnlockManager @Inject constructor(
@ApplicationContext private val context: Context,
private val lockScreenStore: LockScreenStore,
private val lockScreenConfig: LockScreenConfig,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val secretKeyRepository: SecretKeyRepository,
private val coroutineScope: CoroutineScope,
) : BiometricUnlockManager {
private val callbacks = CopyOnWriteArrayList<BiometricUnlock.Callback>()
private val biometricManager = BiometricManager.from(context)
private val keyguardManager: KeyguardManager = context.getSystemService()!!
/**
* Returns true if a weak biometric method (i.e.: some face or iris unlock implementations) can be used.
*/
private val canUseWeakBiometricAuth: Boolean
get() = lockScreenConfig.isWeakBiometricsEnabled
&& biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
/**
* Returns true if a strong biometric method (i.e.: fingerprint, some face or iris unlock implementations) can be used.
*/
private val canUseStrongBiometricAuth: Boolean
get() = lockScreenConfig.isStrongBiometricsEnabled
&& biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
/**
* Returns true if any biometric method (weak or strong) can be used.
*/
override val hasAvailableAuthenticator: Boolean
get() = canUseWeakBiometricAuth || canUseStrongBiometricAuth
override val isDeviceSecured: Boolean
get() = keyguardManager.isDeviceSecure
private val internalCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricSetupError() {
coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(false)
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
}
}
}
@Composable
override fun rememberBiometricUnlock(): BiometricUnlock {
val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) {
derivedStateOf {
isBiometricAllowed && hasAvailableAuthenticator
}
}
val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
val promptNegative = stringResource(id = R.string.screen_app_lock_use_pin_android)
val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) {
if (isAvailable && activity != null) {
val authenticators = when {
canUseStrongBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_STRONG
canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
else -> 0
}
val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
setTitle(promptTitle)
setNegativeButtonText(promptNegative)
setAllowedAuthenticators(authenticators)
}.build()
DefaultBiometricUnlock(
activity = activity,
promptInfo = promptInfo,
secretKeyRepository = secretKeyRepository,
encryptionDecryptionService = encryptionDecryptionService,
keyAlias = SECRET_KEY_ALIAS,
callbacks = callbacks + internalCallback
)
} else {
NoopBiometricUnlock()
}
}
}
override fun addCallback(callback: BiometricUnlock.Callback) {
callbacks.add(callback)
}
override fun removeCallback(callback: BiometricUnlock.Callback) {
callbacks.remove(callback)
}
private fun Context.findFragmentActivity(): FragmentActivity? = when (this) {
is FragmentActivity -> this
is ContextWrapper -> baseContext.findFragmentActivity()
else -> null
}
}

View File

@@ -17,10 +17,10 @@
package io.element.android.features.lockscreen.impl.pin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.impl.pin.storage.PinCodeStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyProvider
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import java.util.concurrent.CopyOnWriteArrayList
@@ -31,9 +31,9 @@ private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultPinCodeManager @Inject constructor(
private val secretKeyProvider: SecretKeyProvider,
private val secretKeyRepository: SecretKeyRepository,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val pinCodeStore: PinCodeStore,
private val lockScreenStore: LockScreenStore,
) : PinCodeManager {
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
@@ -47,37 +47,37 @@ class DefaultPinCodeManager @Inject constructor(
}
override suspend fun isPinCodeAvailable(): Boolean {
return pinCodeStore.hasPinCode()
return lockScreenStore.hasPinCode()
}
override suspend fun getPinCodeSize(): Int {
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return 0
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
return decryptedPinCode.size
}
override suspend fun createPinCode(pinCode: String) {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
pinCodeStore.saveEncryptedPinCode(encryptedPinCode)
lockScreenStore.saveEncryptedPinCode(encryptedPinCode)
callbacks.forEach { it.onPinCodeCreated() }
}
override suspend fun verifyPinCode(pinCode: String): Boolean {
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return false
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return false
return try {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
val pinCodeToCheck = pinCode.toByteArray()
decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect ->
if (isPinCodeCorrect) {
pinCodeStore.resetCounter()
lockScreenStore.resetCounter()
callbacks.forEach { callback ->
callback.onPinCodeVerified()
}
} else {
pinCodeStore.onWrongPin()
lockScreenStore.onWrongPin()
}
}
} catch (failure: Throwable) {
@@ -86,12 +86,12 @@ class DefaultPinCodeManager @Inject constructor(
}
override suspend fun deletePinCode() {
pinCodeStore.deleteEncryptedPinCode()
pinCodeStore.resetCounter()
lockScreenStore.deleteEncryptedPinCode()
lockScreenStore.resetCounter()
callbacks.forEach { it.onPinCodeRemoved() }
}
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return pinCodeStore.getRemainingPinCodeAttemptsNumber()
return lockScreenStore.getRemainingPinCodeAttemptsNumber()
}
}

View File

@@ -20,5 +20,5 @@ sealed interface LockScreenSettingsEvents {
data object OnRemovePin : LockScreenSettingsEvents
data object ConfirmRemovePin : LockScreenSettingsEvents
data object CancelRemovePin : LockScreenSettingsEvents
data object ToggleBiometric : LockScreenSettingsEvents
data object ToggleBiometricAllowed : LockScreenSettingsEvents
}

View File

@@ -32,9 +32,11 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -48,6 +50,7 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Unknown,
@@ -76,15 +79,17 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() {
navigateUp()
}
}
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
backstack.newRoot(NavTarget.Settings)
}
}
init {
lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) {
@@ -96,9 +101,11 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
biometricUnlockManager.addCallback(biometricUnlockCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
biometricUnlockManager.removeCallback(biometricUnlockCallback)
}
)
}
@@ -109,7 +116,12 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext)
}
NavTarget.Setup -> {
createNode<SetupPinNode>(buildContext)
val callback = object : LockScreenSetupFlowNode.Callback {
override fun onSetupDone() {
backstack.newRoot(NavTarget.Settings)
}
}
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback {

View File

@@ -18,13 +18,16 @@ package io.element.android.features.lockscreen.impl.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -33,6 +36,8 @@ import javax.inject.Inject
class LockScreenSettingsPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val pinCodeManager: PinCodeManager,
private val lockScreenStore: LockScreenStore,
private val biometricUnlockManager: BiometricUnlockManager,
private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> {
@@ -44,14 +49,16 @@ class LockScreenSettingsPresenter @Inject constructor(
var showRemovePinOption by remember {
mutableStateOf(false)
}
var isBiometricEnabled by remember {
var showToggleBiometric by remember {
mutableStateOf(false)
}
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
var showRemovePinConfirmation by remember {
mutableStateOf(false)
}
LaunchedEffect(triggerComputation) {
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
showToggleBiometric = biometricUnlockManager.isDeviceSecured
}
fun handleEvents(event: LockScreenSettingsEvents) {
@@ -67,8 +74,10 @@ class LockScreenSettingsPresenter @Inject constructor(
}
}
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
LockScreenSettingsEvents.ToggleBiometric -> {
//TODO branch biometric logic
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(!isBiometricEnabled)
}
}
}
}
@@ -77,6 +86,7 @@ class LockScreenSettingsPresenter @Inject constructor(
showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation,
showToggleBiometric = showToggleBiometric,
eventSink = ::handleEvents
)
}

View File

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

View File

@@ -31,9 +31,11 @@ fun aLockScreenSettingsState(
isLockMandatory: Boolean = false,
isBiometricEnabled: Boolean = false,
showRemovePinConfirmation: Boolean = false,
showToggleBiometric: Boolean = true,
) = LockScreenSettingsState(
showRemovePinOption = isLockMandatory,
isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation,
showToggleBiometric = showToggleBiometric,
eventSink = {}
)

View File

@@ -58,8 +58,16 @@ fun LockScreenSettingsView(
}
)
}
PreferenceDivider()
PreferenceSwitch(title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), isChecked = state.isBiometricEnabled)
if (state.showToggleBiometric) {
PreferenceDivider()
PreferenceSwitch(
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
isChecked = state.isBiometricEnabled,
onCheckedChange = {
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
}
)
}
}
}
if (state.showRemovePinConfirmation) {

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.biometric.SetupBiometricNode
import io.element.android.features.lockscreen.impl.setup.pin.SetupPinNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class LockScreenSetupFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenSetupFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Pin,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
interface Callback : Plugin {
fun onSetupDone()
}
private fun onSetupDone() {
plugins<Callback>().forEach { it.onSetupDone() }
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Pin : NavTarget
@Parcelize
data object Biometric : NavTarget
}
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Biometric)
}
}
init {
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Pin -> {
createNode<SetupPinNode>(buildContext)
}
NavTarget.Biometric -> {
val callback = object : SetupBiometricNode.Callback {
override fun onBiometricSetupDone() {
onSetupDone()
}
}
createNode<SetupBiometricNode>(buildContext, plugins = listOf(callback))
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.biometric
sealed interface SetupBiometricEvents {
data object AllowBiometric : SetupBiometricEvents
data object UsePin : SetupBiometricEvents
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.biometric
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class SetupBiometricNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SetupBiometricPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onBiometricSetupDone()
}
private fun onSetupDone() {
plugins<Callback>().forEach { it.onBiometricSetupDone() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LaunchedEffect(state.isBiometricSetupDone) {
if (state.isBiometricSetupDone) {
onSetupDone()
}
}
SetupBiometricView(
state = state,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.biometric
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
import javax.inject.Inject
class SetupBiometricPresenter @Inject constructor(
private val lockScreenStore: LockScreenStore,
) : Presenter<SetupBiometricState> {
@Composable
override fun present(): SetupBiometricState {
var isBiometricSetupDone by remember {
mutableStateOf(false)
}
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SetupBiometricEvents) {
when (event) {
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(true)
isBiometricSetupDone = true
}
SetupBiometricEvents.UsePin -> coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(false)
isBiometricSetupDone = true
}
}
}
return SetupBiometricState(
isBiometricSetupDone = isBiometricSetupDone,
eventSink = ::handleEvents
)
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.biometric
data class SetupBiometricState(
val isBiometricSetupDone: Boolean,
val eventSink: (SetupBiometricEvents) -> Unit
)

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.biometric
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class SetupBiometricStateProvider : PreviewParameterProvider<SetupBiometricState> {
override val values: Sequence<SetupBiometricState>
get() = sequenceOf(
aSetupBiometricState(),
)
}
fun aSetupBiometricState(
isBiometricSetupDone: Boolean = false,
) = SetupBiometricState(
isBiometricSetupDone = isBiometricSetupDone,
eventSink = {}
)

View File

@@ -0,0 +1,105 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.biometric
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.lockscreen.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.TextButton
@Composable
fun SetupBiometricView(
state: SetupBiometricState,
modifier: Modifier = Modifier,
) {
BackHandler(true) {
state.eventSink(SetupBiometricEvents.UsePin)
}
HeaderFooterPage(
modifier = modifier.padding(top = 80.dp),
header = {
SetupBiometricHeader()
},
footer = {
SetupBiometricFooter(
onAllowClicked = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
onSkipClicked = { state.eventSink(SetupBiometricEvents.UsePin) }
)
},
)
}
@Composable
private fun SetupBiometricHeader(modifier: Modifier = Modifier) {
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
IconTitleSubtitleMolecule(
iconImageVector = Icons.Default.Fingerprint,
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
subTitle = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_subtitle, biometricAuth),
modifier = modifier
)
}
@Composable
private fun SetupBiometricFooter(
onAllowClicked: () -> Unit,
onSkipClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
Button(
text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_allow_title, biometricAuth),
onClick = onAllowClicked
)
TextButton(
text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_skip),
onClick = onSkipClicked
)
}
}
@Composable
@PreviewsDayNight
internal fun SetupBiometricViewPreview(@PreviewParameter(SetupBiometricStateProvider::class) state: SetupBiometricState) {
ElementPreview {
SetupBiometricView(
state = state,
)
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
sealed interface SetupPinEvents {
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -25,8 +25,8 @@ import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig
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.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.delay

View File

@@ -14,10 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
data class SetupPinState(
val choosePinEntry: PinEntry,

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
open class SetupPinStateProvider : PreviewParameterProvider<SetupPinState> {
override val values: Sequence<SetupPinState>

View File

@@ -16,7 +16,7 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -40,7 +40,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.components.PinEntryTextField
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.validation
package io.element.android.features.lockscreen.impl.setup.pin.validation
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.model.PinEntry

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup.validation
package io.element.android.features.lockscreen.impl.setup.pin.validation
sealed interface SetupPinFailure {
data object PinBlacklisted : SetupPinFailure

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin.storage
package io.element.android.features.lockscreen.impl.storage
/**
* Should be implemented by any class that provides access to the encrypted PIN code.

View File

@@ -14,9 +14,11 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin.storage
package io.element.android.features.lockscreen.impl.storage
interface PinCodeStore : EncryptedPinCodeStorage {
import kotlinx.coroutines.flow.Flow
interface LockScreenStore : EncryptedPinCodeStorage {
/**
* Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time.
@@ -32,6 +34,14 @@ interface PinCodeStore : EncryptedPinCodeStorage {
* Resets the counter of attempts for PIN code and biometric access.
*/
suspend fun resetCounter()
/**
* Returns whether the biometric unlock is allowed or not.
*/
fun isBiometricUnlockAllowed(): Flow<Boolean>
/**
* Sets whether the biometric unlock is allowed or not.
*/
suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean)
}

View File

@@ -14,11 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin.storage
package io.element.android.features.lockscreen.impl.storage
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -28,6 +29,7 @@ import io.element.android.appconfig.LockScreenConfig
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -36,13 +38,14 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class PreferencesPinCodeStore @Inject constructor(
class PreferencesLockScreenStore @Inject constructor(
@ApplicationContext private val context: Context,
private val lockScreenConfig: LockScreenConfig,
) : PinCodeStore {
) : LockScreenStore {
private val pinCodeKey = stringPreferencesKey("encoded_pin_code")
private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts")
private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled")
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return context.dataStore.data.map { preferences ->
@@ -88,5 +91,17 @@ class PreferencesPinCodeStore @Inject constructor(
}.first()
}
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
return context.dataStore.data.map { preferences ->
preferences[biometricUnlockKey] ?: false
}
}
override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
context.dataStore.edit { preferences ->
preferences[biometricUnlockKey] = isAllowed
}
}
private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
}

View File

@@ -24,4 +24,5 @@ sealed interface PinUnlockEvents {
data object ClearSignOutPrompt : PinUnlockEvents
data object SignOut : PinUnlockEvents
data object OnUseBiometric : PinUnlockEvents
data object ClearBiometricError : PinUnlockEvents
}

View File

@@ -24,6 +24,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
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.unlock.keypad.PinKeypadModel
@@ -38,6 +40,7 @@ import javax.inject.Inject
class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
private val matrixClient: MatrixClient,
private val coroutineScope: CoroutineScope,
) : Presenter<PinUnlockState> {
@@ -60,6 +63,11 @@ class PinUnlockPresenter @Inject constructor(
val signOutAction = remember {
mutableStateOf<Async<String?>>(Async.Uninitialized)
}
var biometricUnlockResult by remember {
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
}
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
LaunchedEffect(Unit) {
suspend {
@@ -67,6 +75,10 @@ class PinUnlockPresenter @Inject constructor(
PinEntry.createEmpty(pinCodeSize)
}.runCatchingUpdatingState(pinEntryState)
}
LaunchedEffect(biometricUnlock) {
biometricUnlock.setup()
biometricUnlock.authenticate()
}
LaunchedEffect(pinEntry) {
if (pinEntry.isComplete()) {
@@ -97,7 +109,12 @@ class PinUnlockPresenter @Inject constructor(
}
}
PinUnlockEvents.OnUseBiometric -> {
//TODO
coroutineScope.launch {
biometricUnlockResult = biometricUnlock.authenticate()
}
}
PinUnlockEvents.ClearBiometricError -> {
biometricUnlockResult = null
}
}
}
@@ -107,6 +124,8 @@ class PinUnlockPresenter @Inject constructor(
remainingAttempts = remainingAttempts,
showSignOutPrompt = showSignOutPrompt,
signOutAction = signOutAction.value,
showBiometricUnlock = biometricUnlock.isActive,
biometricUnlockResult = biometricUnlockResult,
eventSink = ::handleEvents
)
}

View File

@@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.unlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
@@ -25,10 +27,22 @@ data class PinUnlockState(
val remainingAttempts: Async<Int>,
val showSignOutPrompt: Boolean,
val signOutAction: Async<String?>,
val showBiometricUnlock: Boolean,
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
val eventSink: (PinUnlockEvents) -> Unit
) {
val isSignOutPromptCancellable = when (remainingAttempts) {
is Async.Success -> remainingAttempts.data > 0
else -> true
}
val biometricUnlockErrorMessage = when {
biometricUnlockResult is BiometricUnlock.AuthenticationResult.Failure
&& biometricUnlockResult.error is BiometricUnlockError
&& biometricUnlockResult.error.isAuthDisabledError -> {
biometricUnlockResult.error.message
}
else -> null
}
val showBiometricUnlockError = biometricUnlockErrorMessage != null
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
@@ -27,6 +28,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
aPinUnlockState(showWrongPinTitle = true),
aPinUnlockState(showSignOutPrompt = true),
aPinUnlockState(showBiometricUnlock = false),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
aPinUnlockState(signOutAction = Async.Loading()),
)
@@ -37,12 +39,16 @@ fun aPinUnlockState(
remainingAttempts: Int = 3,
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
showBiometricUnlock: Boolean = true,
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
signOutAction: Async<String?> = Async.Uninitialized,
) = PinUnlockState(
pinEntry = Async.Success(pinEntry),
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = Async.Success(remainingAttempts),
showSignOutPrompt = showSignOutPrompt,
showBiometricUnlock = showBiometricUnlock,
signOutAction = signOutAction,
biometricUnlockResult = biometricUnlockResult,
eventSink = {}
)

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.pin.model.PinDigit
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
@@ -58,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@@ -66,6 +68,12 @@ fun PinUnlockView(
state: PinUnlockState,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
else -> Unit
}
}
Surface(modifier) {
BoxWithConstraints {
val commonModifier = Modifier
@@ -82,6 +90,7 @@ fun PinUnlockView(
val footer = @Composable {
PinUnlockFooter(
modifier = Modifier.padding(top = 24.dp),
showBiometricUnlock = state.showBiometricUnlock,
onUseBiometric = {
state.eventSink(PinUnlockEvents.OnUseBiometric)
},
@@ -126,6 +135,12 @@ fun PinUnlockView(
if (state.signOutAction is Async.Loading) {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
if (state.showBiometricUnlockError) {
ErrorDialog(
content = state.biometricUnlockErrorMessage ?: "",
onDismiss = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
)
}
}
}
@@ -284,12 +299,15 @@ private fun PinUnlockHeader(
@Composable
private fun PinUnlockFooter(
showBiometricUnlock: Boolean,
onUseBiometric: () -> Unit,
onForgotPin: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
TextButton(text = "Use biometric", onClick = onUseBiometric)
if (showBiometricUnlock) {
TextButton(text = stringResource(id = R.string.screen_app_lock_use_biometric_android), onClick = onUseBiometric)
}
TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = onForgotPin)
}
}

View File

@@ -10,6 +10,7 @@
</plurals>
<string name="screen_app_lock_biometric_authentication">"biometric authentication"</string>
<string name="screen_app_lock_biometric_unlock">"biometric unlock"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Unlock with biometric"</string>
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>
@@ -30,5 +31,7 @@ Choose something memorable. If you forget this PIN, you will be logged out of th
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs don\'t match"</string>
<string name="screen_app_lock_signout_alert_message">"Youll need to re-login and create a new PIN to proceed"</string>
<string name="screen_app_lock_signout_alert_title">"You are being signed out"</string>
<string name="screen_app_lock_use_biometric_android">"Use biometric"</string>
<string name="screen_app_lock_use_pin_android">"Use PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
</resources>

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.biometric
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
class FakeBiometricUnlockManager : BiometricUnlockManager {
override var isDeviceSecured: Boolean = true
override var hasAvailableAuthenticator: Boolean = false
override fun addCallback(callback: BiometricUnlock.Callback) {
// no-op
}
override fun removeCallback(callback: BiometricUnlock.Callback) {
// no-op
}
@Composable
override fun rememberBiometricUnlock(): BiometricUnlock {
return remember {
NoopBiometricUnlock()
}
}
}

View File

@@ -17,19 +17,25 @@
package io.element.android.features.lockscreen.impl.fixtures
import io.element.android.appconfig.LockScreenConfig
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
internal fun aLockScreenConfig(
isPinMandatory: Boolean = false,
pinBlacklist: Set<String> = emptySet(),
pinSize: Int = 4,
maxPinCodeAttemptsBeforeLogout: Int = 3,
gracePeriodInMillis: Long = 5 * 60 * 1000L
gracePeriod: Duration = 3.seconds,
isStrongBiometricsEnabled: Boolean = true,
isWeakBiometricsEnabled: Boolean = true,
): LockScreenConfig {
return LockScreenConfig(
isPinMandatory = isPinMandatory,
pinBlacklist = pinBlacklist,
pinSize = pinSize,
maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout,
gracePeriodInMillis = gracePeriodInMillis
gracePeriod = gracePeriod,
isStrongBiometricsEnabled = isStrongBiometricsEnabled,
isWeakBiometricsEnabled = isWeakBiometricsEnabled,
)
}

View File

@@ -18,16 +18,16 @@ package io.element.android.features.lockscreen.impl.fixtures
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore
import io.element.android.features.lockscreen.impl.pin.storage.PinCodeStore
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.EncryptionDecryptionService
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider
import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
internal fun aPinCodeManager(
pinCodeStore: PinCodeStore = InMemoryPinCodeStore(),
secretKeyProvider: SimpleSecretKeyProvider = SimpleSecretKeyProvider(),
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
secretKeyRepository: SimpleSecretKeyRepository = SimpleSecretKeyRepository(),
encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(),
): PinCodeManager {
return DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore)
return DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
}

View File

@@ -17,18 +17,18 @@
package io.element.android.features.lockscreen.impl.pin
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider
import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultPinCodeManagerTest {
private val pinCodeStore = InMemoryPinCodeStore()
private val secretKeyProvider = SimpleSecretKeyProvider()
private val lockScreenStore = InMemoryLockScreenStore()
private val secretKeyRepository = SimpleSecretKeyRepository()
private val encryptionDecryptionService = AESEncryptionDecryptionService()
private val pinCodeManager = DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore)
private val pinCodeManager = DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
@Test
fun `given a pin code when create and delete assert no pin code left`() = runTest {

View File

@@ -16,12 +16,17 @@
package io.element.android.features.lockscreen.impl.pin.storage
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
private const val DEFAULT_REMAINING_ATTEMPTS = 3
class InMemoryPinCodeStore : PinCodeStore {
class InMemoryLockScreenStore : LockScreenStore {
private var pinCode: String? = null
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
private var isBiometricUnlockAllowed = MutableStateFlow(false)
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return remainingAttempts
@@ -50,4 +55,12 @@ class InMemoryPinCodeStore : PinCodeStore {
override suspend fun hasPinCode(): Boolean {
return pinCode != null
}
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
return isBiometricUnlockAllowed
}
override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
isBiometricUnlockAllowed.value = isAllowed
}
}

View File

@@ -21,8 +21,10 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CoroutineScope
@@ -67,13 +69,16 @@ class LockScreenSettingsPresenterTest {
coroutineScope: CoroutineScope,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
): LockScreenSettingsPresenter {
val pinCodeManager = aPinCodeManager().apply {
val lockScreenStore = InMemoryLockScreenStore()
val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply {
createPinCode("1234")
}
return LockScreenSettingsPresenter(
lockScreenStore = lockScreenStore,
pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope,
lockScreenConfig = lockScreenConfig,
biometricUnlockManager = FakeBiometricUnlockManager(),
)
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SetupBiometricPresenterTest {
@Test
fun `present - allow flow`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
val presenter = createSetupBiometricPresenter(lockScreenStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isFalse()
state.eventSink(SetupBiometricEvents.AllowBiometric)
}
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isTrue()
}
}
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isTrue()
}
@Test
fun `present - skip flow`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
val presenter = createSetupBiometricPresenter(lockScreenStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isFalse()
state.eventSink(SetupBiometricEvents.UsePin)
}
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isTrue()
}
}
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
}
private fun createSetupBiometricPresenter(
lockScreenStore: LockScreenStore = InMemoryLockScreenStore()
): SetupBiometricPresenter {
return SetupBiometricPresenter(
lockScreenStore = lockScreenStore,
)
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.setup
package io.element.android.features.lockscreen.impl.setup.pin
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -27,8 +27,8 @@ 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.assertEmpty
import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate

View File

@@ -20,6 +20,8 @@ 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.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -48,7 +50,7 @@ class PinUnlockPresenterTest {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback)
val presenter = createPinUnlockPresenter(this, callback = callback)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -88,7 +90,7 @@ class PinUnlockPresenterTest {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback)
val presenter = createPinUnlockPresenter(this, callback = callback)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -146,6 +148,7 @@ class PinUnlockPresenterTest {
private suspend fun createPinUnlockPresenter(
scope: CoroutineScope,
biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
): PinUnlockPresenter {
val pinCodeManager = aPinCodeManager().apply {
@@ -153,9 +156,10 @@ class PinUnlockPresenterTest {
createPinCode(completePin)
}
return PinUnlockPresenter(
pinCodeManager,
FakeMatrixClient(),
scope,
pinCodeManager = pinCodeManager,
biometricUnlockManager = biometricUnlockManager,
matrixClient = FakeMatrixClient(),
coroutineScope = scope,
)
}
}

View File

@@ -14,7 +14,7 @@ datastore = "1.0.0"
constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1"
recyclerview = "1.3.2"
lifecycle = "2.6.2"
lifecycle = "2.7.0-alpha03"
activity = "1.8.0"
startup = "1.1.1"
media3 = "1.1.1"
@@ -90,6 +90,7 @@ androidx_splash = "androidx.core:core-splashscreen:1.0.1"
androidx_security_crypto = "androidx.security:security-crypto:1.1.0-alpha06"
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.cryptography.api
import javax.crypto.SecretKey
/**
* Simple interface to get, create and delete a secret key for a given alias.
* Implementation should be able to store the generated key securely.
*/
interface SecretKeyRepository {
/**
* 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
/**
* Delete the secret key for a given alias.
* @param alias the alias to use
*/
fun deleteKey(alias: String)
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.cryptography.impl
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import java.security.KeyStore
internal const val ANDROID_KEYSTORE = "AndroidKeyStore"
@ContributesTo(AppScope::class)
@Module
object CryptographyModule {
@Provides
fun providesAndroidKeyStore(): KeyStore {
return KeyStore.getInstance(ANDROID_KEYSTORE).apply {
load(null)
}
}
}

View File

@@ -21,28 +21,27 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.SecretKeyProvider
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import timber.log.Timber
import java.security.KeyStore
import java.security.KeyStoreException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.inject.Inject
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
/**
* Default implementation of [SecretKeyProvider] that uses the Android Keystore to store the keys.
* Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys.
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
*/
@ContributesBinding(AppScope::class)
class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
class KeyStoreSecretKeyRepository @Inject constructor(
private val keyStore: KeyStore,
) : SecretKeyRepository {
// False positive lint issue
@SuppressLint("WrongConstant")
override fun getOrCreateKey(alias: String): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
load(null)
}
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
?.secretKey
return if (secretKeyEntry == null) {
@@ -54,6 +53,7 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
.setBlockModes(AESEncryptionSpecs.BLOCK_MODE)
.setEncryptionPaddings(AESEncryptionSpecs.PADDINGS)
.setKeySize(AESEncryptionSpecs.KEY_SIZE)
.setUserAuthenticationRequired(requiresUserAuthentication)
.build()
generator.init(keyGenSpec)
generator.generateKey()
@@ -61,4 +61,12 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
secretKeyEntry
}
}
override fun deleteKey(alias: String) {
try {
keyStore.deleteEntry(alias)
} catch (e: KeyStoreException) {
Timber.e(e)
}
}
}

View File

@@ -17,20 +17,24 @@
package io.element.android.libraries.cryptography.test
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.SecretKeyProvider
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class SimpleSecretKeyProvider : SecretKeyProvider {
class SimpleSecretKeyRepository : SecretKeyRepository {
private var secretKeyForAlias = HashMap<String, SecretKey>()
override fun getOrCreateKey(alias: String): SecretKey {
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
return secretKeyForAlias.getOrPut(alias) {
generateKey()
}
}
override fun deleteKey(alias: String) {
secretKeyForAlias.remove(alias)
}
private fun generateKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM)
keyGenerator.init(AESEncryptionSpecs.KEY_SIZE)

View File

@@ -218,6 +218,7 @@
<string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</string>
<string name="screen_room_mentions_at_room_subtitle">"Notify the whole room"</string>
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
@@ -262,6 +263,7 @@ If you proceed, some of your settings may change."</string>
<string name="test_untranslated_default_language_identifier">"en"</string>
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Success"</string>
<string name="screen_room_mentions_at_room_title">"Everyone"</string>
<string name="screen_analytics_settings_help_us_improve">"Share anonymous usage data to help us identify issues."</string>
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>