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:
@@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.bumble.appyx.core.integration.NodeHost
|
||||
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||
import com.bumble.appyx.core.integrationpoint.NodeActivity
|
||||
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
@@ -42,7 +42,7 @@ import timber.log.Timber
|
||||
|
||||
private val loggerTag = LoggerTag("MainActivity")
|
||||
|
||||
class MainActivity : NodeComponentActivity() {
|
||||
class MainActivity : NodeActivity() {
|
||||
|
||||
private lateinit var mainNode: MainNode
|
||||
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.ElementX" parent="Theme.Material3.Dark" />
|
||||
<style name="Theme.ElementX" parent="Theme.Material3.Dark.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
|
||||
</style>
|
||||
<style name="Theme.ElementX" parent="Theme.Material3.Light" />
|
||||
<style name="Theme.ElementX" parent="Theme.Material3.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -20,6 +20,8 @@ import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Configuration for the lock screen feature.
|
||||
@@ -48,7 +50,16 @@ data class LockScreenConfig(
|
||||
/**
|
||||
* Time period before locking the app once backgrounded.
|
||||
*/
|
||||
val gracePeriodInMillis: Long
|
||||
val gracePeriod: Duration,
|
||||
|
||||
/**
|
||||
* Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
|
||||
*/
|
||||
val isStrongBiometricsEnabled: Boolean,
|
||||
/**
|
||||
* Authentication with weak methods (most face/iris unlock implementations) is supported.
|
||||
*/
|
||||
val isWeakBiometricsEnabled: Boolean,
|
||||
)
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
@@ -61,6 +72,8 @@ object LockScreenConfigModule {
|
||||
pinBlacklist = setOf("0000", "1234"),
|
||||
pinSize = 4,
|
||||
maxPinCodeAttemptsBeforeLogout = 3,
|
||||
gracePeriodInMillis = 90_000L
|
||||
gracePeriod = 90.seconds,
|
||||
isStrongBiometricsEnabled = true,
|
||||
isWeakBiometricsEnabled = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ dependencies {
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.biometric)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
||||
@@ -20,8 +20,11 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.api.LockScreenLockState
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
|
||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
@@ -36,16 +39,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLockScreenService @Inject constructor(
|
||||
private val lockScreenConfig: LockScreenConfig,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val lockScreenStore: LockScreenStore,
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val sessionObserver: SessionObserver,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val biometricUnlockManager: BiometricUnlockManager,
|
||||
) : LockScreenService {
|
||||
|
||||
private val _lockScreenState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
|
||||
@@ -63,6 +69,14 @@ class DefaultLockScreenService @Inject constructor(
|
||||
_lockScreenState.value = LockScreenLockState.Unlocked
|
||||
}
|
||||
})
|
||||
biometricUnlockManager.addCallback(object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricUnlockSuccess() {
|
||||
_lockScreenState.value = LockScreenLockState.Unlocked
|
||||
coroutineScope.launch {
|
||||
lockScreenStore.resetCounter()
|
||||
}
|
||||
}
|
||||
})
|
||||
coroutineScope.lockIfNeeded()
|
||||
observeAppForegroundState()
|
||||
observeSessionsState()
|
||||
@@ -93,7 +107,7 @@ class DefaultLockScreenService @Inject constructor(
|
||||
if (isInForeground) {
|
||||
lockJob?.cancel()
|
||||
} else {
|
||||
lockJob = lockIfNeeded(delayInMillis = lockScreenConfig.gracePeriodInMillis)
|
||||
lockJob = lockIfNeeded(gracePeriod = lockScreenConfig.gracePeriod)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,9 +119,9 @@ class DefaultLockScreenService @Inject constructor(
|
||||
&& !pinCodeManager.isPinCodeAvailable()
|
||||
}
|
||||
|
||||
private fun CoroutineScope.lockIfNeeded(delayInMillis: Long = 0L) = launch {
|
||||
private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
|
||||
delay(delayInMillis)
|
||||
delay(gracePeriod)
|
||||
_lockScreenState.value = LockScreenLockState.Locked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
|
||||
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
|
||||
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
@@ -97,7 +97,7 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||
createNode<PinUnlockNode>(buildContext)
|
||||
}
|
||||
NavTarget.Setup -> {
|
||||
createNode<SetupPinNode>(buildContext)
|
||||
createNode<LockScreenSetupFlowNode>(buildContext)
|
||||
}
|
||||
NavTarget.Settings -> {
|
||||
createNode<LockScreenSettingsFlowNode>(buildContext)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,14 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.cryptography.api
|
||||
package io.element.android.features.lockscreen.impl.biometric
|
||||
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
/**
|
||||
* Simple interface to get or create a secret key for a given alias.
|
||||
* Implementation should be able to store the generated key securely.
|
||||
*/
|
||||
interface SecretKeyProvider {
|
||||
fun getOrCreateKey(alias: String): SecretKey
|
||||
open class DefaultBiometricUnlockCallback : BiometricUnlock.Callback {
|
||||
override fun onBiometricSetupError() = Unit
|
||||
override fun onBiometricUnlockSuccess() = Unit
|
||||
override fun onBiometricUnlockFailed(error: Exception?) = Unit
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@
|
||||
package io.element.android.features.lockscreen.impl.pin
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.PinCodeStore
|
||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
||||
import io.element.android.libraries.cryptography.api.EncryptionResult
|
||||
import io.element.android.libraries.cryptography.api.SecretKeyProvider
|
||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
@@ -31,9 +31,9 @@ private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultPinCodeManager @Inject constructor(
|
||||
private val secretKeyProvider: SecretKeyProvider,
|
||||
private val secretKeyRepository: SecretKeyRepository,
|
||||
private val encryptionDecryptionService: EncryptionDecryptionService,
|
||||
private val pinCodeStore: PinCodeStore,
|
||||
private val lockScreenStore: LockScreenStore,
|
||||
) : PinCodeManager {
|
||||
|
||||
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
|
||||
@@ -47,37 +47,37 @@ class DefaultPinCodeManager @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun isPinCodeAvailable(): Boolean {
|
||||
return pinCodeStore.hasPinCode()
|
||||
return lockScreenStore.hasPinCode()
|
||||
}
|
||||
|
||||
override suspend fun getPinCodeSize(): Int {
|
||||
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return 0
|
||||
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
|
||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
|
||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
||||
return decryptedPinCode.size
|
||||
}
|
||||
|
||||
override suspend fun createPinCode(pinCode: String) {
|
||||
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
|
||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||
val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
|
||||
pinCodeStore.saveEncryptedPinCode(encryptedPinCode)
|
||||
lockScreenStore.saveEncryptedPinCode(encryptedPinCode)
|
||||
callbacks.forEach { it.onPinCodeCreated() }
|
||||
}
|
||||
|
||||
override suspend fun verifyPinCode(pinCode: String): Boolean {
|
||||
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return false
|
||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return false
|
||||
return try {
|
||||
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
|
||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
||||
val pinCodeToCheck = pinCode.toByteArray()
|
||||
decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect ->
|
||||
if (isPinCodeCorrect) {
|
||||
pinCodeStore.resetCounter()
|
||||
lockScreenStore.resetCounter()
|
||||
callbacks.forEach { callback ->
|
||||
callback.onPinCodeVerified()
|
||||
}
|
||||
} else {
|
||||
pinCodeStore.onWrongPin()
|
||||
lockScreenStore.onWrongPin()
|
||||
}
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
@@ -86,12 +86,12 @@ class DefaultPinCodeManager @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun deletePinCode() {
|
||||
pinCodeStore.deleteEncryptedPinCode()
|
||||
pinCodeStore.resetCounter()
|
||||
lockScreenStore.deleteEncryptedPinCode()
|
||||
lockScreenStore.resetCounter()
|
||||
callbacks.forEach { it.onPinCodeRemoved() }
|
||||
}
|
||||
|
||||
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
|
||||
return pinCodeStore.getRemainingPinCodeAttemptsNumber()
|
||||
return lockScreenStore.getRemainingPinCodeAttemptsNumber()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@ sealed interface LockScreenSettingsEvents {
|
||||
data object OnRemovePin : LockScreenSettingsEvents
|
||||
data object ConfirmRemovePin : LockScreenSettingsEvents
|
||||
data object CancelRemovePin : LockScreenSettingsEvents
|
||||
data object ToggleBiometric : LockScreenSettingsEvents
|
||||
data object ToggleBiometricAllowed : LockScreenSettingsEvents
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
|
||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
|
||||
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
@@ -48,6 +50,7 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
private val biometricUnlockManager: BiometricUnlockManager,
|
||||
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Unknown,
|
||||
@@ -76,15 +79,17 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||
backstack.newRoot(NavTarget.Settings)
|
||||
}
|
||||
|
||||
override fun onPinCodeCreated() {
|
||||
backstack.newRoot(NavTarget.Settings)
|
||||
}
|
||||
|
||||
override fun onPinCodeRemoved() {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricUnlockSuccess() {
|
||||
backstack.newRoot(NavTarget.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycleScope.launch {
|
||||
if (pinCodeManager.isPinCodeAvailable()) {
|
||||
@@ -96,9 +101,11 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
pinCodeManager.addCallback(pinCodeManagerCallback)
|
||||
biometricUnlockManager.addCallback(biometricUnlockCallback)
|
||||
},
|
||||
onDestroy = {
|
||||
pinCodeManager.removeCallback(pinCodeManagerCallback)
|
||||
biometricUnlockManager.removeCallback(biometricUnlockCallback)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -109,7 +116,12 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||
createNode<PinUnlockNode>(buildContext)
|
||||
}
|
||||
NavTarget.Setup -> {
|
||||
createNode<SetupPinNode>(buildContext)
|
||||
val callback = object : LockScreenSetupFlowNode.Callback {
|
||||
override fun onSetupDone() {
|
||||
backstack.newRoot(NavTarget.Settings)
|
||||
}
|
||||
}
|
||||
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.Settings -> {
|
||||
val callback = object : LockScreenSettingsNode.Callback {
|
||||
|
||||
@@ -18,13 +18,16 @@ package io.element.android.features.lockscreen.impl.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -33,6 +36,8 @@ import javax.inject.Inject
|
||||
class LockScreenSettingsPresenter @Inject constructor(
|
||||
private val lockScreenConfig: LockScreenConfig,
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
private val lockScreenStore: LockScreenStore,
|
||||
private val biometricUnlockManager: BiometricUnlockManager,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : Presenter<LockScreenSettingsState> {
|
||||
|
||||
@@ -44,14 +49,16 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||
var showRemovePinOption by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var isBiometricEnabled by remember {
|
||||
var showToggleBiometric by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
||||
var showRemovePinConfirmation by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
LaunchedEffect(triggerComputation) {
|
||||
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
|
||||
showToggleBiometric = biometricUnlockManager.isDeviceSecured
|
||||
}
|
||||
|
||||
fun handleEvents(event: LockScreenSettingsEvents) {
|
||||
@@ -67,8 +74,10 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
|
||||
LockScreenSettingsEvents.ToggleBiometric -> {
|
||||
//TODO branch biometric logic
|
||||
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
|
||||
coroutineScope.launch {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(!isBiometricEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +86,7 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||
showRemovePinOption = showRemovePinOption,
|
||||
isBiometricEnabled = isBiometricEnabled,
|
||||
showRemovePinConfirmation = showRemovePinConfirmation,
|
||||
showToggleBiometric = showToggleBiometric,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ data class LockScreenSettingsState(
|
||||
val showRemovePinOption: Boolean,
|
||||
val isBiometricEnabled: Boolean,
|
||||
val showRemovePinConfirmation: Boolean,
|
||||
val showToggleBiometric: Boolean,
|
||||
val eventSink: (LockScreenSettingsEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -31,9 +31,11 @@ fun aLockScreenSettingsState(
|
||||
isLockMandatory: Boolean = false,
|
||||
isBiometricEnabled: Boolean = false,
|
||||
showRemovePinConfirmation: Boolean = false,
|
||||
showToggleBiometric: Boolean = true,
|
||||
) = LockScreenSettingsState(
|
||||
showRemovePinOption = isLockMandatory,
|
||||
isBiometricEnabled = isBiometricEnabled,
|
||||
showRemovePinConfirmation = showRemovePinConfirmation,
|
||||
showToggleBiometric = showToggleBiometric,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -58,8 +58,16 @@ fun LockScreenSettingsView(
|
||||
}
|
||||
)
|
||||
}
|
||||
PreferenceDivider()
|
||||
PreferenceSwitch(title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), isChecked = state.isBiometricEnabled)
|
||||
if (state.showToggleBiometric) {
|
||||
PreferenceDivider()
|
||||
PreferenceSwitch(
|
||||
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
|
||||
isChecked = state.isBiometricEnabled,
|
||||
onCheckedChange = {
|
||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.showRemovePinConfirmation) {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
sealed interface SetupPinEvents {
|
||||
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -25,8 +25,8 @@ import androidx.compose.runtime.setValue
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
|
||||
|
||||
data class SetupPinState(
|
||||
val choosePinEntry: PinEntry,
|
||||
@@ -14,11 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
|
||||
|
||||
open class SetupPinStateProvider : PreviewParameterProvider<SetupPinState> {
|
||||
override val values: Sequence<SetupPinState>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
@@ -40,7 +40,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.lockscreen.impl.R
|
||||
import io.element.android.features.lockscreen.impl.components.PinEntryTextField
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup.validation
|
||||
package io.element.android.features.lockscreen.impl.setup.pin.validation
|
||||
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup.validation
|
||||
package io.element.android.features.lockscreen.impl.setup.pin.validation
|
||||
|
||||
sealed interface SetupPinFailure {
|
||||
data object PinBlacklisted : SetupPinFailure
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.storage
|
||||
package io.element.android.features.lockscreen.impl.storage
|
||||
|
||||
/**
|
||||
* Should be implemented by any class that provides access to the encrypted PIN code.
|
||||
@@ -14,9 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.storage
|
||||
package io.element.android.features.lockscreen.impl.storage
|
||||
|
||||
interface PinCodeStore : EncryptedPinCodeStorage {
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface LockScreenStore : EncryptedPinCodeStorage {
|
||||
|
||||
/**
|
||||
* Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time.
|
||||
@@ -32,6 +34,14 @@ interface PinCodeStore : EncryptedPinCodeStorage {
|
||||
* Resets the counter of attempts for PIN code and biometric access.
|
||||
*/
|
||||
suspend fun resetCounter()
|
||||
|
||||
/**
|
||||
* Returns whether the biometric unlock is allowed or not.
|
||||
*/
|
||||
fun isBiometricUnlockAllowed(): Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Sets whether the biometric unlock is allowed or not.
|
||||
*/
|
||||
suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.storage
|
||||
package io.element.android.features.lockscreen.impl.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -28,6 +29,7 @@ import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
@@ -36,13 +38,14 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class PreferencesPinCodeStore @Inject constructor(
|
||||
class PreferencesLockScreenStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val lockScreenConfig: LockScreenConfig,
|
||||
) : PinCodeStore {
|
||||
) : LockScreenStore {
|
||||
|
||||
private val pinCodeKey = stringPreferencesKey("encoded_pin_code")
|
||||
private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts")
|
||||
private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled")
|
||||
|
||||
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
@@ -88,5 +91,17 @@ class PreferencesPinCodeStore @Inject constructor(
|
||||
}.first()
|
||||
}
|
||||
|
||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences[biometricUnlockKey] ?: false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[biometricUnlockKey] = isAllowed
|
||||
}
|
||||
}
|
||||
|
||||
private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
|
||||
}
|
||||
@@ -24,4 +24,5 @@ sealed interface PinUnlockEvents {
|
||||
data object ClearSignOutPrompt : PinUnlockEvents
|
||||
data object SignOut : PinUnlockEvents
|
||||
data object OnUseBiometric : PinUnlockEvents
|
||||
data object ClearBiometricError : PinUnlockEvents
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||
@@ -38,6 +40,7 @@ import javax.inject.Inject
|
||||
|
||||
class PinUnlockPresenter @Inject constructor(
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
private val biometricUnlockManager: BiometricUnlockManager,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : Presenter<PinUnlockState> {
|
||||
@@ -60,6 +63,11 @@ class PinUnlockPresenter @Inject constructor(
|
||||
val signOutAction = remember {
|
||||
mutableStateOf<Async<String?>>(Async.Uninitialized)
|
||||
}
|
||||
var biometricUnlockResult by remember {
|
||||
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
|
||||
}
|
||||
|
||||
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
suspend {
|
||||
@@ -67,6 +75,10 @@ class PinUnlockPresenter @Inject constructor(
|
||||
PinEntry.createEmpty(pinCodeSize)
|
||||
}.runCatchingUpdatingState(pinEntryState)
|
||||
}
|
||||
LaunchedEffect(biometricUnlock) {
|
||||
biometricUnlock.setup()
|
||||
biometricUnlock.authenticate()
|
||||
}
|
||||
|
||||
LaunchedEffect(pinEntry) {
|
||||
if (pinEntry.isComplete()) {
|
||||
@@ -97,7 +109,12 @@ class PinUnlockPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
PinUnlockEvents.OnUseBiometric -> {
|
||||
//TODO
|
||||
coroutineScope.launch {
|
||||
biometricUnlockResult = biometricUnlock.authenticate()
|
||||
}
|
||||
}
|
||||
PinUnlockEvents.ClearBiometricError -> {
|
||||
biometricUnlockResult = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +124,8 @@ class PinUnlockPresenter @Inject constructor(
|
||||
remainingAttempts = remainingAttempts,
|
||||
showSignOutPrompt = showSignOutPrompt,
|
||||
signOutAction = signOutAction.value,
|
||||
showBiometricUnlock = biometricUnlock.isActive,
|
||||
biometricUnlockResult = biometricUnlockResult,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
@@ -25,10 +27,22 @@ data class PinUnlockState(
|
||||
val remainingAttempts: Async<Int>,
|
||||
val showSignOutPrompt: Boolean,
|
||||
val signOutAction: Async<String?>,
|
||||
val showBiometricUnlock: Boolean,
|
||||
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
|
||||
val eventSink: (PinUnlockEvents) -> Unit
|
||||
) {
|
||||
val isSignOutPromptCancellable = when (remainingAttempts) {
|
||||
is Async.Success -> remainingAttempts.data > 0
|
||||
else -> true
|
||||
}
|
||||
|
||||
val biometricUnlockErrorMessage = when {
|
||||
biometricUnlockResult is BiometricUnlock.AuthenticationResult.Failure
|
||||
&& biometricUnlockResult.error is BiometricUnlockError
|
||||
&& biometricUnlockResult.error.isAuthDisabledError -> {
|
||||
biometricUnlockResult.error.message
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
val showBiometricUnlockError = biometricUnlockErrorMessage != null
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
@@ -27,6 +28,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
||||
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
|
||||
aPinUnlockState(showWrongPinTitle = true),
|
||||
aPinUnlockState(showSignOutPrompt = true),
|
||||
aPinUnlockState(showBiometricUnlock = false),
|
||||
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
|
||||
aPinUnlockState(signOutAction = Async.Loading()),
|
||||
)
|
||||
@@ -37,12 +39,16 @@ fun aPinUnlockState(
|
||||
remainingAttempts: Int = 3,
|
||||
showWrongPinTitle: Boolean = false,
|
||||
showSignOutPrompt: Boolean = false,
|
||||
showBiometricUnlock: Boolean = true,
|
||||
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
|
||||
signOutAction: Async<String?> = Async.Uninitialized,
|
||||
) = PinUnlockState(
|
||||
pinEntry = Async.Success(pinEntry),
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = Async.Success(remainingAttempts),
|
||||
showSignOutPrompt = showSignOutPrompt,
|
||||
showBiometricUnlock = showBiometricUnlock,
|
||||
signOutAction = signOutAction,
|
||||
biometricUnlockResult = biometricUnlockResult,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.features.lockscreen.impl.R
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinDigit
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
@@ -58,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -66,6 +68,12 @@ fun PinUnlockView(
|
||||
state: PinUnlockState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
Surface(modifier) {
|
||||
BoxWithConstraints {
|
||||
val commonModifier = Modifier
|
||||
@@ -82,6 +90,7 @@ fun PinUnlockView(
|
||||
val footer = @Composable {
|
||||
PinUnlockFooter(
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
showBiometricUnlock = state.showBiometricUnlock,
|
||||
onUseBiometric = {
|
||||
state.eventSink(PinUnlockEvents.OnUseBiometric)
|
||||
},
|
||||
@@ -126,6 +135,12 @@ fun PinUnlockView(
|
||||
if (state.signOutAction is Async.Loading) {
|
||||
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
||||
}
|
||||
if (state.showBiometricUnlockError) {
|
||||
ErrorDialog(
|
||||
content = state.biometricUnlockErrorMessage ?: "",
|
||||
onDismiss = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +299,15 @@ private fun PinUnlockHeader(
|
||||
|
||||
@Composable
|
||||
private fun PinUnlockFooter(
|
||||
showBiometricUnlock: Boolean,
|
||||
onUseBiometric: () -> Unit,
|
||||
onForgotPin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
|
||||
TextButton(text = "Use biometric", onClick = onUseBiometric)
|
||||
if (showBiometricUnlock) {
|
||||
TextButton(text = stringResource(id = R.string.screen_app_lock_use_biometric_android), onClick = onUseBiometric)
|
||||
}
|
||||
TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = onForgotPin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
</plurals>
|
||||
<string name="screen_app_lock_biometric_authentication">"biometric authentication"</string>
|
||||
<string name="screen_app_lock_biometric_unlock">"biometric unlock"</string>
|
||||
<string name="screen_app_lock_biometric_unlock_title_android">"Unlock with biometric"</string>
|
||||
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
|
||||
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
|
||||
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>
|
||||
@@ -30,5 +31,7 @@ Choose something memorable. If you forget this PIN, you will be logged out of th
|
||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs don\'t match"</string>
|
||||
<string name="screen_app_lock_signout_alert_message">"You’ll need to re-login and create a new PIN to proceed"</string>
|
||||
<string name="screen_app_lock_signout_alert_title">"You are being signed out"</string>
|
||||
<string name="screen_app_lock_use_biometric_android">"Use biometric"</string>
|
||||
<string name="screen_app_lock_use_pin_android">"Use PIN"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
|
||||
</resources>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,25 @@
|
||||
package io.element.android.features.lockscreen.impl.fixtures
|
||||
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal fun aLockScreenConfig(
|
||||
isPinMandatory: Boolean = false,
|
||||
pinBlacklist: Set<String> = emptySet(),
|
||||
pinSize: Int = 4,
|
||||
maxPinCodeAttemptsBeforeLogout: Int = 3,
|
||||
gracePeriodInMillis: Long = 5 * 60 * 1000L
|
||||
gracePeriod: Duration = 3.seconds,
|
||||
isStrongBiometricsEnabled: Boolean = true,
|
||||
isWeakBiometricsEnabled: Boolean = true,
|
||||
): LockScreenConfig {
|
||||
return LockScreenConfig(
|
||||
isPinMandatory = isPinMandatory,
|
||||
pinBlacklist = pinBlacklist,
|
||||
pinSize = pinSize,
|
||||
maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout,
|
||||
gracePeriodInMillis = gracePeriodInMillis
|
||||
gracePeriod = gracePeriod,
|
||||
isStrongBiometricsEnabled = isStrongBiometricsEnabled,
|
||||
isWeakBiometricsEnabled = isWeakBiometricsEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,16 +18,16 @@ package io.element.android.features.lockscreen.impl.fixtures
|
||||
|
||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.PinCodeStore
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
||||
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
|
||||
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider
|
||||
import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
|
||||
|
||||
internal fun aPinCodeManager(
|
||||
pinCodeStore: PinCodeStore = InMemoryPinCodeStore(),
|
||||
secretKeyProvider: SimpleSecretKeyProvider = SimpleSecretKeyProvider(),
|
||||
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
|
||||
secretKeyRepository: SimpleSecretKeyRepository = SimpleSecretKeyRepository(),
|
||||
encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(),
|
||||
): PinCodeManager {
|
||||
return DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore)
|
||||
return DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
|
||||
}
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
package io.element.android.features.lockscreen.impl.pin
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
|
||||
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider
|
||||
import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultPinCodeManagerTest {
|
||||
|
||||
private val pinCodeStore = InMemoryPinCodeStore()
|
||||
private val secretKeyProvider = SimpleSecretKeyProvider()
|
||||
private val lockScreenStore = InMemoryLockScreenStore()
|
||||
private val secretKeyRepository = SimpleSecretKeyRepository()
|
||||
private val encryptionDecryptionService = AESEncryptionDecryptionService()
|
||||
private val pinCodeManager = DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore)
|
||||
private val pinCodeManager = DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
|
||||
|
||||
@Test
|
||||
fun `given a pin code when create and delete assert no pin code left`() = runTest {
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.storage
|
||||
|
||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
private const val DEFAULT_REMAINING_ATTEMPTS = 3
|
||||
|
||||
class InMemoryPinCodeStore : PinCodeStore {
|
||||
class InMemoryLockScreenStore : LockScreenStore {
|
||||
|
||||
private var pinCode: String? = null
|
||||
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
|
||||
private var isBiometricUnlockAllowed = MutableStateFlow(false)
|
||||
|
||||
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
|
||||
return remainingAttempts
|
||||
@@ -50,4 +55,12 @@ class InMemoryPinCodeStore : PinCodeStore {
|
||||
override suspend fun hasPinCode(): Boolean {
|
||||
return pinCode != null
|
||||
}
|
||||
|
||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||
return isBiometricUnlockAllowed
|
||||
}
|
||||
|
||||
override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
|
||||
isBiometricUnlockAllowed.value = isAllowed
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -67,13 +69,16 @@ class LockScreenSettingsPresenterTest {
|
||||
coroutineScope: CoroutineScope,
|
||||
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
|
||||
): LockScreenSettingsPresenter {
|
||||
val pinCodeManager = aPinCodeManager().apply {
|
||||
val lockScreenStore = InMemoryLockScreenStore()
|
||||
val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply {
|
||||
createPinCode("1234")
|
||||
}
|
||||
return LockScreenSettingsPresenter(
|
||||
lockScreenStore = lockScreenStore,
|
||||
pinCodeManager = pinCodeManager,
|
||||
coroutineScope = coroutineScope,
|
||||
lockScreenConfig = lockScreenConfig,
|
||||
biometricUnlockManager = FakeBiometricUnlockManager(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
package io.element.android.features.lockscreen.impl.setup.pin
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
@@ -27,8 +27,8 @@ import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCall
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty
|
||||
import io.element.android.features.lockscreen.impl.pin.model.assertText
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
@@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
|
||||
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
@@ -48,7 +50,7 @@ class PinUnlockPresenterTest {
|
||||
pinCodeVerified.complete(Unit)
|
||||
}
|
||||
}
|
||||
val presenter = createPinUnlockPresenter(this, callback)
|
||||
val presenter = createPinUnlockPresenter(this, callback = callback)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -88,7 +90,7 @@ class PinUnlockPresenterTest {
|
||||
pinCodeVerified.complete(Unit)
|
||||
}
|
||||
}
|
||||
val presenter = createPinUnlockPresenter(this, callback)
|
||||
val presenter = createPinUnlockPresenter(this, callback = callback)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -146,6 +148,7 @@ class PinUnlockPresenterTest {
|
||||
|
||||
private suspend fun createPinUnlockPresenter(
|
||||
scope: CoroutineScope,
|
||||
biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
|
||||
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
|
||||
): PinUnlockPresenter {
|
||||
val pinCodeManager = aPinCodeManager().apply {
|
||||
@@ -153,9 +156,10 @@ class PinUnlockPresenterTest {
|
||||
createPinCode(completePin)
|
||||
}
|
||||
return PinUnlockPresenter(
|
||||
pinCodeManager,
|
||||
FakeMatrixClient(),
|
||||
scope,
|
||||
pinCodeManager = pinCodeManager,
|
||||
biometricUnlockManager = biometricUnlockManager,
|
||||
matrixClient = FakeMatrixClient(),
|
||||
coroutineScope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ datastore = "1.0.0"
|
||||
constraintlayout = "2.1.4"
|
||||
constraintlayout_compose = "1.0.1"
|
||||
recyclerview = "1.3.2"
|
||||
lifecycle = "2.6.2"
|
||||
lifecycle = "2.7.0-alpha03"
|
||||
activity = "1.8.0"
|
||||
startup = "1.1.1"
|
||||
media3 = "1.1.1"
|
||||
@@ -90,6 +90,7 @@ androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||
androidx_security_crypto = "androidx.security:security-crypto:1.1.0-alpha06"
|
||||
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
||||
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,28 +21,27 @@ import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
|
||||
import io.element.android.libraries.cryptography.api.SecretKeyProvider
|
||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import java.security.KeyStoreException
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
/**
|
||||
* Default implementation of [SecretKeyProvider] that uses the Android Keystore to store the keys.
|
||||
* Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys.
|
||||
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
|
||||
class KeyStoreSecretKeyRepository @Inject constructor(
|
||||
private val keyStore: KeyStore,
|
||||
) : SecretKeyRepository {
|
||||
|
||||
// False positive lint issue
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun getOrCreateKey(alias: String): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
|
||||
load(null)
|
||||
}
|
||||
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
|
||||
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
|
||||
?.secretKey
|
||||
return if (secretKeyEntry == null) {
|
||||
@@ -54,6 +53,7 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
|
||||
.setBlockModes(AESEncryptionSpecs.BLOCK_MODE)
|
||||
.setEncryptionPaddings(AESEncryptionSpecs.PADDINGS)
|
||||
.setKeySize(AESEncryptionSpecs.KEY_SIZE)
|
||||
.setUserAuthenticationRequired(requiresUserAuthentication)
|
||||
.build()
|
||||
generator.init(keyGenSpec)
|
||||
generator.generateKey()
|
||||
@@ -61,4 +61,12 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
|
||||
secretKeyEntry
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteKey(alias: String) {
|
||||
try {
|
||||
keyStore.deleteEntry(alias)
|
||||
} catch (e: KeyStoreException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,24 @@
|
||||
package io.element.android.libraries.cryptography.test
|
||||
|
||||
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
|
||||
import io.element.android.libraries.cryptography.api.SecretKeyProvider
|
||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
class SimpleSecretKeyProvider : SecretKeyProvider {
|
||||
class SimpleSecretKeyRepository : SecretKeyRepository {
|
||||
|
||||
private var secretKeyForAlias = HashMap<String, SecretKey>()
|
||||
|
||||
override fun getOrCreateKey(alias: String): SecretKey {
|
||||
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
|
||||
return secretKeyForAlias.getOrPut(alias) {
|
||||
generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteKey(alias: String) {
|
||||
secretKeyForAlias.remove(alias)
|
||||
}
|
||||
|
||||
private fun generateKey(): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM)
|
||||
keyGenerator.init(AESEncryptionSpecs.KEY_SIZE)
|
||||
@@ -218,6 +218,7 @@
|
||||
<string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
|
||||
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
|
||||
<string name="room_timeline_read_marker_title">"New"</string>
|
||||
<string name="screen_room_mentions_at_room_subtitle">"Notify the whole room"</string>
|
||||
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
||||
@@ -262,6 +263,7 @@ If you proceed, some of your settings may change."</string>
|
||||
<string name="test_untranslated_default_language_identifier">"en"</string>
|
||||
<string name="dialog_title_error">"Error"</string>
|
||||
<string name="dialog_title_success">"Success"</string>
|
||||
<string name="screen_room_mentions_at_room_title">"Everyone"</string>
|
||||
<string name="screen_analytics_settings_help_us_improve">"Share anonymous usage data to help us identify issues."</string>
|
||||
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
|
||||
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user