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

View file

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

View file

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

View file

@ -20,6 +20,8 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** /**
* Configuration for the lock screen feature. * Configuration for the lock screen feature.
@ -48,7 +50,16 @@ data class LockScreenConfig(
/** /**
* Time period before locking the app once backgrounded. * 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) @ContributesTo(AppScope::class)
@ -61,6 +72,8 @@ object LockScreenConfigModule {
pinBlacklist = setOf("0000", "1234"), pinBlacklist = setOf("0000", "1234"),
pinSize = 4, pinSize = 4,
maxPinCodeAttemptsBeforeLogout = 3, 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.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.biometric)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) 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.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService 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.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultLockScreenService @Inject constructor( class DefaultLockScreenService @Inject constructor(
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val lockScreenStore: LockScreenStore,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val biometricUnlockManager: BiometricUnlockManager,
) : LockScreenService { ) : LockScreenService {
private val _lockScreenState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked) private val _lockScreenState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
@ -63,6 +69,14 @@ class DefaultLockScreenService @Inject constructor(
_lockScreenState.value = LockScreenLockState.Unlocked _lockScreenState.value = LockScreenLockState.Unlocked
} }
}) })
biometricUnlockManager.addCallback(object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
_lockScreenState.value = LockScreenLockState.Unlocked
coroutineScope.launch {
lockScreenStore.resetCounter()
}
}
})
coroutineScope.lockIfNeeded() coroutineScope.lockIfNeeded()
observeAppForegroundState() observeAppForegroundState()
observeSessionsState() observeSessionsState()
@ -93,7 +107,7 @@ class DefaultLockScreenService @Inject constructor(
if (isInForeground) { if (isInForeground) {
lockJob?.cancel() lockJob?.cancel()
} else { } else {
lockJob = lockIfNeeded(delayInMillis = lockScreenConfig.gracePeriodInMillis) lockJob = lockIfNeeded(gracePeriod = lockScreenConfig.gracePeriod)
} }
} }
} }
@ -105,9 +119,9 @@ class DefaultLockScreenService @Inject constructor(
&& !pinCodeManager.isPinCodeAvailable() && !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()) { if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
delay(delayInMillis) delay(gracePeriod)
_lockScreenState.value = LockScreenLockState.Locked _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.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode 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.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -97,7 +97,7 @@ class LockScreenFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext) createNode<PinUnlockNode>(buildContext)
} }
NavTarget.Setup -> { NavTarget.Setup -> {
createNode<SetupPinNode>(buildContext) createNode<LockScreenSetupFlowNode>(buildContext)
} }
NavTarget.Settings -> { NavTarget.Settings -> {
createNode<LockScreenSettingsFlowNode>(buildContext) 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. * limitations under the License.
*/ */
package io.element.android.libraries.cryptography.api package io.element.android.features.lockscreen.impl.biometric
import javax.crypto.SecretKey open class DefaultBiometricUnlockCallback : BiometricUnlock.Callback {
override fun onBiometricSetupError() = Unit
/** override fun onBiometricUnlockSuccess() = Unit
* Simple interface to get or create a secret key for a given alias. override fun onBiometricUnlockFailed(error: Exception?) = Unit
* Implementation should be able to store the generated key securely.
*/
interface SecretKeyProvider {
fun getOrCreateKey(alias: String): SecretKey
} }

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 package io.element.android.features.lockscreen.impl.pin
import com.squareup.anvil.annotations.ContributesBinding 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.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult 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.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
@ -31,9 +31,9 @@ private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class DefaultPinCodeManager @Inject constructor( class DefaultPinCodeManager @Inject constructor(
private val secretKeyProvider: SecretKeyProvider, private val secretKeyRepository: SecretKeyRepository,
private val encryptionDecryptionService: EncryptionDecryptionService, private val encryptionDecryptionService: EncryptionDecryptionService,
private val pinCodeStore: PinCodeStore, private val lockScreenStore: LockScreenStore,
) : PinCodeManager { ) : PinCodeManager {
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>() private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
@ -47,37 +47,37 @@ class DefaultPinCodeManager @Inject constructor(
} }
override suspend fun isPinCodeAvailable(): Boolean { override suspend fun isPinCodeAvailable(): Boolean {
return pinCodeStore.hasPinCode() return lockScreenStore.hasPinCode()
} }
override suspend fun getPinCodeSize(): Int { override suspend fun getPinCodeSize(): Int {
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return 0 val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS) val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode)) val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
return decryptedPinCode.size return decryptedPinCode.size
} }
override suspend fun createPinCode(pinCode: String) { 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() val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
pinCodeStore.saveEncryptedPinCode(encryptedPinCode) lockScreenStore.saveEncryptedPinCode(encryptedPinCode)
callbacks.forEach { it.onPinCodeCreated() } callbacks.forEach { it.onPinCodeCreated() }
} }
override suspend fun verifyPinCode(pinCode: String): Boolean { override suspend fun verifyPinCode(pinCode: String): Boolean {
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return false val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return false
return try { 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 decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
val pinCodeToCheck = pinCode.toByteArray() val pinCodeToCheck = pinCode.toByteArray()
decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect -> decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect ->
if (isPinCodeCorrect) { if (isPinCodeCorrect) {
pinCodeStore.resetCounter() lockScreenStore.resetCounter()
callbacks.forEach { callback -> callbacks.forEach { callback ->
callback.onPinCodeVerified() callback.onPinCodeVerified()
} }
} else { } else {
pinCodeStore.onWrongPin() lockScreenStore.onWrongPin()
} }
} }
} catch (failure: Throwable) { } catch (failure: Throwable) {
@ -86,12 +86,12 @@ class DefaultPinCodeManager @Inject constructor(
} }
override suspend fun deletePinCode() { override suspend fun deletePinCode() {
pinCodeStore.deleteEncryptedPinCode() lockScreenStore.deleteEncryptedPinCode()
pinCodeStore.resetCounter() lockScreenStore.resetCounter()
callbacks.forEach { it.onPinCodeRemoved() } callbacks.forEach { it.onPinCodeRemoved() }
} }
override suspend fun getRemainingPinCodeAttemptsNumber(): Int { 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 OnRemovePin : LockScreenSettingsEvents
data object ConfirmRemovePin : LockScreenSettingsEvents data object ConfirmRemovePin : LockScreenSettingsEvents
data object CancelRemovePin : 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.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.SetupPinNode import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -48,6 +50,7 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>( ) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Unknown, initialElement = NavTarget.Unknown,
@ -76,15 +79,17 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
backstack.newRoot(NavTarget.Settings) backstack.newRoot(NavTarget.Settings)
} }
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() { override fun onPinCodeRemoved() {
navigateUp() navigateUp()
} }
} }
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
backstack.newRoot(NavTarget.Settings)
}
}
init { init {
lifecycleScope.launch { lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) { if (pinCodeManager.isPinCodeAvailable()) {
@ -96,9 +101,11 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback) pinCodeManager.addCallback(pinCodeManagerCallback)
biometricUnlockManager.addCallback(biometricUnlockCallback)
}, },
onDestroy = { onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback) pinCodeManager.removeCallback(pinCodeManagerCallback)
biometricUnlockManager.removeCallback(biometricUnlockCallback)
} }
) )
} }
@ -109,7 +116,12 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext) createNode<PinUnlockNode>(buildContext)
} }
NavTarget.Setup -> { 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 -> { NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback { 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig 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.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -33,6 +36,8 @@ import javax.inject.Inject
class LockScreenSettingsPresenter @Inject constructor( class LockScreenSettingsPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val lockScreenStore: LockScreenStore,
private val biometricUnlockManager: BiometricUnlockManager,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> { ) : Presenter<LockScreenSettingsState> {
@ -44,14 +49,16 @@ class LockScreenSettingsPresenter @Inject constructor(
var showRemovePinOption by remember { var showRemovePinOption by remember {
mutableStateOf(false) mutableStateOf(false)
} }
var isBiometricEnabled by remember { var showToggleBiometric by remember {
mutableStateOf(false) mutableStateOf(false)
} }
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
var showRemovePinConfirmation by remember { var showRemovePinConfirmation by remember {
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(triggerComputation) { LaunchedEffect(triggerComputation) {
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable() showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
showToggleBiometric = biometricUnlockManager.isDeviceSecured
} }
fun handleEvents(event: LockScreenSettingsEvents) { fun handleEvents(event: LockScreenSettingsEvents) {
@ -67,8 +74,10 @@ class LockScreenSettingsPresenter @Inject constructor(
} }
} }
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
LockScreenSettingsEvents.ToggleBiometric -> { LockScreenSettingsEvents.ToggleBiometricAllowed -> {
//TODO branch biometric logic coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(!isBiometricEnabled)
}
} }
} }
} }
@ -77,6 +86,7 @@ class LockScreenSettingsPresenter @Inject constructor(
showRemovePinOption = showRemovePinOption, showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled, isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation, showRemovePinConfirmation = showRemovePinConfirmation,
showToggleBiometric = showToggleBiometric,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

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

View file

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

View file

@ -58,8 +58,16 @@ fun LockScreenSettingsView(
} }
) )
} }
PreferenceDivider() if (state.showToggleBiometric) {
PreferenceSwitch(title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), isChecked = state.isBiometricEnabled) 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) { 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. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.setup package io.element.android.features.lockscreen.impl.setup.pin
sealed interface SetupPinEvents { sealed interface SetupPinEvents {
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier

View file

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

View file

@ -14,10 +14,10 @@
* limitations under the License. * 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.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( data class SetupPinState(
val choosePinEntry: PinEntry, val choosePinEntry: PinEntry,

View file

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

View file

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

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 { sealed interface SetupPinFailure {
data object PinBlacklisted : SetupPinFailure data object PinBlacklisted : SetupPinFailure

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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. * Should be implemented by any class that provides access to the encrypted PIN code.

View file

@ -14,9 +14,11 @@
* limitations under the License. * 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. * 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. * Resets the counter of attempts for PIN code and biometric access.
*/ */
suspend fun resetCounter() 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. * 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 android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey 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.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@ -36,13 +38,14 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class PreferencesPinCodeStore @Inject constructor( class PreferencesLockScreenStore @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
) : PinCodeStore { ) : LockScreenStore {
private val pinCodeKey = stringPreferencesKey("encoded_pin_code") private val pinCodeKey = stringPreferencesKey("encoded_pin_code")
private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts") private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts")
private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled")
override suspend fun getRemainingPinCodeAttemptsNumber(): Int { override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return context.dataStore.data.map { preferences -> return context.dataStore.data.map { preferences ->
@ -88,5 +91,17 @@ class PreferencesPinCodeStore @Inject constructor(
}.first() }.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 private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
} }

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@
</plurals> </plurals>
<string name="screen_app_lock_biometric_authentication">"biometric authentication"</string> <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">"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_forgot_pin">"Forgot PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</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> <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_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_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_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> <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
</resources> </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 package io.element.android.features.lockscreen.impl.fixtures
import io.element.android.appconfig.LockScreenConfig import io.element.android.appconfig.LockScreenConfig
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
internal fun aLockScreenConfig( internal fun aLockScreenConfig(
isPinMandatory: Boolean = false, isPinMandatory: Boolean = false,
pinBlacklist: Set<String> = emptySet(), pinBlacklist: Set<String> = emptySet(),
pinSize: Int = 4, pinSize: Int = 4,
maxPinCodeAttemptsBeforeLogout: Int = 3, maxPinCodeAttemptsBeforeLogout: Int = 3,
gracePeriodInMillis: Long = 5 * 60 * 1000L gracePeriod: Duration = 3.seconds,
isStrongBiometricsEnabled: Boolean = true,
isWeakBiometricsEnabled: Boolean = true,
): LockScreenConfig { ): LockScreenConfig {
return LockScreenConfig( return LockScreenConfig(
isPinMandatory = isPinMandatory, isPinMandatory = isPinMandatory,
pinBlacklist = pinBlacklist, pinBlacklist = pinBlacklist,
pinSize = pinSize, pinSize = pinSize,
maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout, 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.DefaultPinCodeManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
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.EncryptionDecryptionService
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService 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( internal fun aPinCodeManager(
pinCodeStore: PinCodeStore = InMemoryPinCodeStore(), lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
secretKeyProvider: SimpleSecretKeyProvider = SimpleSecretKeyProvider(), secretKeyRepository: SimpleSecretKeyRepository = SimpleSecretKeyRepository(),
encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(), encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(),
): PinCodeManager { ): 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 package io.element.android.features.lockscreen.impl.pin
import com.google.common.truth.Truth.assertThat 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.impl.AESEncryptionDecryptionService
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class DefaultPinCodeManagerTest { class DefaultPinCodeManagerTest {
private val pinCodeStore = InMemoryPinCodeStore() private val lockScreenStore = InMemoryLockScreenStore()
private val secretKeyProvider = SimpleSecretKeyProvider() private val secretKeyRepository = SimpleSecretKeyRepository()
private val encryptionDecryptionService = AESEncryptionDecryptionService() private val encryptionDecryptionService = AESEncryptionDecryptionService()
private val pinCodeManager = DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore) private val pinCodeManager = DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
@Test @Test
fun `given a pin code when create and delete assert no pin code left`() = runTest { 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 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 private const val DEFAULT_REMAINING_ATTEMPTS = 3
class InMemoryPinCodeStore : PinCodeStore { class InMemoryLockScreenStore : LockScreenStore {
private var pinCode: String? = null private var pinCode: String? = null
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
private var isBiometricUnlockAllowed = MutableStateFlow(false)
override suspend fun getRemainingPinCodeAttemptsNumber(): Int { override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return remainingAttempts return remainingAttempts
@ -50,4 +55,12 @@ class InMemoryPinCodeStore : PinCodeStore {
override suspend fun hasPinCode(): Boolean { override suspend fun hasPinCode(): Boolean {
return pinCode != null 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.LockScreenConfig 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.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager 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.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -67,13 +69,16 @@ class LockScreenSettingsPresenterTest {
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(), lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
): LockScreenSettingsPresenter { ): LockScreenSettingsPresenter {
val pinCodeManager = aPinCodeManager().apply { val lockScreenStore = InMemoryLockScreenStore()
val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply {
createPinCode("1234") createPinCode("1234")
} }
return LockScreenSettingsPresenter( return LockScreenSettingsPresenter(
lockScreenStore = lockScreenStore,
pinCodeManager = pinCodeManager, pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
lockScreenConfig = lockScreenConfig, 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. * 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.RecompositionMode
import app.cash.molecule.moleculeFlow 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.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty 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.pin.model.assertText
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
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.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate

View file

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

View file

@ -14,7 +14,7 @@ datastore = "1.0.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1" constraintlayout_compose = "1.0.1"
recyclerview = "1.3.2" recyclerview = "1.3.2"
lifecycle = "2.6.2" lifecycle = "2.7.0-alpha03"
activity = "1.8.0" activity = "1.8.0"
startup = "1.1.1" startup = "1.1.1"
media3 = "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_security_crypto = "androidx.security:security-crypto:1.1.0-alpha06"
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx_media3_ui = { module = "androidx.media3:media3-ui", 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_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", 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 android.security.keystore.KeyProperties
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs 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 io.element.android.libraries.di.AppScope
import timber.log.Timber
import java.security.KeyStore import java.security.KeyStore
import java.security.KeyStoreException
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.inject.Inject 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. * The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
*/ */
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider { class KeyStoreSecretKeyRepository @Inject constructor(
private val keyStore: KeyStore,
) : SecretKeyRepository {
// False positive lint issue // False positive lint issue
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
override fun getOrCreateKey(alias: String): SecretKey { override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
load(null)
}
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry) val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
?.secretKey ?.secretKey
return if (secretKeyEntry == null) { return if (secretKeyEntry == null) {
@ -54,6 +53,7 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
.setBlockModes(AESEncryptionSpecs.BLOCK_MODE) .setBlockModes(AESEncryptionSpecs.BLOCK_MODE)
.setEncryptionPaddings(AESEncryptionSpecs.PADDINGS) .setEncryptionPaddings(AESEncryptionSpecs.PADDINGS)
.setKeySize(AESEncryptionSpecs.KEY_SIZE) .setKeySize(AESEncryptionSpecs.KEY_SIZE)
.setUserAuthenticationRequired(requiresUserAuthentication)
.build() .build()
generator.init(keyGenSpec) generator.init(keyGenSpec)
generator.generateKey() generator.generateKey()
@ -61,4 +61,12 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
secretKeyEntry 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 package io.element.android.libraries.cryptography.test
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.SecretKeyProvider import io.element.android.libraries.cryptography.api.SecretKeyRepository
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
class SimpleSecretKeyProvider : SecretKeyProvider { class SimpleSecretKeyRepository : SecretKeyRepository {
private var secretKeyForAlias = HashMap<String, SecretKey>() private var secretKeyForAlias = HashMap<String, SecretKey>()
override fun getOrCreateKey(alias: String): SecretKey { override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
return secretKeyForAlias.getOrPut(alias) { return secretKeyForAlias.getOrPut(alias) {
generateKey() generateKey()
} }
} }
override fun deleteKey(alias: String) {
secretKeyForAlias.remove(alias)
}
private fun generateKey(): SecretKey { private fun generateKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM) val keyGenerator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM)
keyGenerator.init(AESEncryptionSpecs.KEY_SIZE) 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">"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_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</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_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_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> <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="test_untranslated_default_language_identifier">"en"</string>
<string name="dialog_title_error">"Error"</string> <string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Success"</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_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">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string> <string name="screen_analytics_settings_read_terms_content_link">"here"</string>

View file

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

View file

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

View file

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

View file

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