PIN : start branching logic

This commit is contained in:
ganfra 2023-10-24 14:19:52 +02:00
parent b0f27c111e
commit 645c699a6b
20 changed files with 316 additions and 78 deletions

View file

@ -32,4 +32,9 @@ object LockScreenConfig {
* The size of the PIN. * The size of the PIN.
*/ */
const val PIN_SIZE = 4 const val PIN_SIZE = 4
/**
* Number of attempts before the user is logged out.
*/
const val MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT = 3
} }

View file

@ -23,5 +23,4 @@ interface LockScreenStateService {
suspend fun entersForeground() suspend fun entersForeground()
suspend fun entersBackground() suspend fun entersBackground()
suspend fun unlock()
} }

View file

@ -24,9 +24,11 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsNode import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsNode
import io.element.android.features.lockscreen.impl.setup.SetupPinNode import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
@ -74,7 +76,7 @@ class LockScreenFlowNode @AssistedInject constructor(
createNode<SetupPinNode>(buildContext) createNode<SetupPinNode>(buildContext)
} }
NavTarget.Settings -> { NavTarget.Settings -> {
createNode<LockScreenSettingsNode>(buildContext) createNode<LockScreenSettingsFlowNode>(buildContext)
} }
} }
} }

View file

@ -22,17 +22,30 @@ import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyProvider import io.element.android.libraries.cryptography.api.SecretKeyProvider
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject import javax.inject.Inject
private const val SECRET_KEY_ALIAS = "SECRET_KEY_ALIAS_PIN_CODE" private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultPinCodeManager @Inject constructor( class DefaultPinCodeManager @Inject constructor(
private val secretKeyProvider: SecretKeyProvider, private val secretKeyProvider: SecretKeyProvider,
private val encryptionDecryptionService: EncryptionDecryptionService, private val encryptionDecryptionService: EncryptionDecryptionService,
private val pinCodeStore: PinCodeStore, private val pinCodeStore: PinCodeStore,
) : PinCodeManager { ) : PinCodeManager {
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
override fun addCallback(callback: PinCodeManager.Callback) {
callbacks.add(callback)
}
override fun removeCallback(callback: PinCodeManager.Callback) {
callbacks.remove(callback)
}
override suspend fun isPinCodeAvailable(): Boolean { override suspend fun isPinCodeAvailable(): Boolean {
return pinCodeStore.hasPinCode() return pinCodeStore.hasPinCode()
} }
@ -41,6 +54,7 @@ class DefaultPinCodeManager @Inject constructor(
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS) val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64() val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
pinCodeStore.saveEncryptedPinCode(encryptedPinCode) pinCodeStore.saveEncryptedPinCode(encryptedPinCode)
callbacks.forEach { it.onPinCodeCreated() }
} }
override suspend fun verifyPinCode(pinCode: String): Boolean { override suspend fun verifyPinCode(pinCode: String): Boolean {
@ -48,7 +62,17 @@ class DefaultPinCodeManager @Inject constructor(
return try { return try {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS) val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode)) val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
decryptedPinCode.contentEquals(pinCode.toByteArray()) val pinCodeToCheck = pinCode.toByteArray()
decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect ->
if (isPinCodeCorrect) {
pinCodeStore.resetCounter()
callbacks.forEach { callback ->
callback.onPinCodeVerified()
}
} else {
pinCodeStore.onWrongPin()
}
}
} catch (failure: Throwable) { } catch (failure: Throwable) {
false false
} }
@ -56,17 +80,11 @@ class DefaultPinCodeManager @Inject constructor(
override suspend fun deletePinCode() { override suspend fun deletePinCode() {
pinCodeStore.deleteEncryptedPinCode() pinCodeStore.deleteEncryptedPinCode()
pinCodeStore.resetCounter()
callbacks.forEach { it.onPinCodeRemoved() }
} }
override suspend fun getRemainingPinCodeAttemptsNumber(): Int { override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return pinCodeStore.getRemainingPinCodeAttemptsNumber() return pinCodeStore.getRemainingPinCodeAttemptsNumber()
} }
override suspend fun onWrongPin(): Int {
return pinCodeStore.onWrongPin()
}
override suspend fun resetCounter() {
pinCodeStore.resetCounter()
}
} }

View file

@ -21,6 +21,37 @@ package io.element.android.features.lockscreen.impl.pin
* Implementation should take care of encrypting the pin code and storing it. * Implementation should take care of encrypting the pin code and storing it.
*/ */
interface PinCodeManager { interface PinCodeManager {
/**
* Callbacks for pin code management events.
*/
interface Callback {
/**
* Called when the pin code is verified.
*/
fun onPinCodeVerified() = Unit
/**
* Called when the pin code is created.
*/
fun onPinCodeCreated() = Unit
/**
* Called when the pin code is removed.
*/
fun onPinCodeRemoved() = Unit
}
/**
* Register a callback to be notified of pin code management events.
*/
fun addCallback(callback: Callback)
/**
* Unregister callback to be notified of pin code management events.
*/
fun removeCallback(callback: Callback)
/** /**
* @return true if a pin code is available. * @return true if a pin code is available.
*/ */
@ -46,16 +77,4 @@ interface PinCodeManager {
* @return the number of remaining attempts before the pin code is blocked. * @return the number of remaining attempts before the pin code is blocked.
*/ */
suspend fun getRemainingPinCodeAttemptsNumber(): Int suspend fun getRemainingPinCodeAttemptsNumber(): Int
/**
* Should be called when the pin code is incorrect.
* Will decrement the remaining attempts number.
* @return the number of remaining attempts before the pin code is blocked.
*/
suspend fun onWrongPin(): Int
/**
* Resets the counter of attempts for PIN code.
*/
suspend fun resetCounter()
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.lockscreen.impl.pin.storage
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.LockScreenConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.DefaultPreferences
@ -31,7 +32,6 @@ import javax.inject.Inject
private const val ENCODED_PIN_CODE_KEY = "ENCODED_PIN_CODE_KEY" private const val ENCODED_PIN_CODE_KEY = "ENCODED_PIN_CODE_KEY"
private const val REMAINING_PIN_CODE_ATTEMPTS_KEY = "REMAINING_PIN_CODE_ATTEMPTS_KEY" private const val REMAINING_PIN_CODE_ATTEMPTS_KEY = "REMAINING_PIN_CODE_ATTEMPTS_KEY"
private const val MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT = 3
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -57,8 +57,6 @@ class SharedPreferencesPinCodeStore @Inject constructor(
} }
override suspend fun deleteEncryptedPinCode() = withContext(dispatchers.io) { override suspend fun deleteEncryptedPinCode() = withContext(dispatchers.io) {
// Also reset the counters
resetCounter()
sharedPreferences.edit { sharedPreferences.edit {
remove(ENCODED_PIN_CODE_KEY) remove(ENCODED_PIN_CODE_KEY)
} }
@ -72,14 +70,12 @@ class SharedPreferencesPinCodeStore @Inject constructor(
} }
override suspend fun getRemainingPinCodeAttemptsNumber(): Int = withContext(dispatchers.io) { override suspend fun getRemainingPinCodeAttemptsNumber(): Int = withContext(dispatchers.io) {
mutex.withLock { sharedPreferences.getInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, LockScreenConfig.MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT)
sharedPreferences.getInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT)
}
} }
override suspend fun onWrongPin(): Int = withContext(dispatchers.io) { override suspend fun onWrongPin(): Int = withContext(dispatchers.io) {
mutex.withLock { mutex.withLock {
val remaining = getRemainingPinCodeAttemptsNumber() - 1 val remaining = (getRemainingPinCodeAttemptsNumber() - 1).coerceAtLeast(0)
sharedPreferences.edit { sharedPreferences.edit {
putInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, remaining) putInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, remaining)
} }

View file

@ -20,6 +20,5 @@ sealed interface LockScreenSettingsEvents {
data object RemovePin : LockScreenSettingsEvents data object RemovePin : LockScreenSettingsEvents
data object ConfirmRemovePin : LockScreenSettingsEvents data object ConfirmRemovePin : LockScreenSettingsEvents
data object CancelRemovePin : LockScreenSettingsEvents data object CancelRemovePin : LockScreenSettingsEvents
data object ChangePin : LockScreenSettingsEvents
data object ToggleBiometric : LockScreenSettingsEvents data object ToggleBiometric : LockScreenSettingsEvents
} }

View file

@ -0,0 +1,133 @@
/*
* 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.settings
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
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.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
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.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
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.AppScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class LockScreenSettingsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Unknown,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Unknown : NavTarget
@Parcelize
data object Unlock : NavTarget
@Parcelize
data object Setup : NavTarget
@Parcelize
data object Settings : NavTarget
}
private val pinCodeManagerCallback = object : PinCodeManager.Callback {
override fun onPinCodeVerified() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() {
navigateUp()
}
}
init {
lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) {
backstack.newRoot(NavTarget.Unlock)
} else {
backstack.newRoot(NavTarget.Setup)
}
}
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Unlock -> {
createNode<PinUnlockNode>(buildContext)
}
NavTarget.Setup -> {
createNode<SetupPinNode>(buildContext)
}
NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback {
override fun onChangePinClicked() {
backstack.push(NavTarget.Setup)
}
}
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Unknown -> node(buildContext) { }
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -33,12 +34,22 @@ class LockScreenSettingsNode @AssistedInject constructor(
private val presenter: LockScreenSettingsPresenter, private val presenter: LockScreenSettingsPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onChangePinClicked()
}
private fun onChangePinClicked() {
plugins<Callback>().forEach { it.onChangePinClicked() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
LockScreenSettingsView( LockScreenSettingsView(
state = state, state = state,
modifier = modifier onBackPressed = this::navigateUp,
onChangePinClicked = this::onChangePinClicked,
modifier = modifier,
) )
} }
} }

View file

@ -17,41 +17,65 @@
package io.element.android.features.lockscreen.impl.settings package io.element.android.features.lockscreen.impl.settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class LockScreenSettingsPresenter @Inject constructor() : Presenter<LockScreenSettingsState> { class LockScreenSettingsPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> {
@Composable @Composable
override fun present(): LockScreenSettingsState { override fun present(): LockScreenSettingsState {
var triggerComputation by remember {
mutableIntStateOf(0)
}
var showRemovePinOption by remember {
mutableStateOf(false)
}
var isBiometricEnabled by remember { var isBiometricEnabled by remember {
mutableStateOf(false) mutableStateOf(false)
} }
var showRemovePinConfirmation by remember { var showRemovePinConfirmation by remember {
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(triggerComputation) {
showRemovePinOption = !LockScreenConfig.IS_PIN_MANDATORY && pinCodeManager.isPinCodeAvailable()
}
fun handleEvents(event: LockScreenSettingsEvents) { fun handleEvents(event: LockScreenSettingsEvents) {
when (event) { when (event) {
LockScreenSettingsEvents.CancelRemovePin -> TODO() LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
LockScreenSettingsEvents.ChangePin -> TODO() LockScreenSettingsEvents.ConfirmRemovePin -> {
LockScreenSettingsEvents.ConfirmRemovePin -> TODO() coroutineScope.launch {
LockScreenSettingsEvents.RemovePin -> TODO() showRemovePinConfirmation = false
LockScreenSettingsEvents.ToggleBiometric -> TODO() pinCodeManager.deletePinCode()
triggerComputation++
}
}
LockScreenSettingsEvents.RemovePin -> showRemovePinConfirmation = true
LockScreenSettingsEvents.ToggleBiometric -> {
//TODO branch biometric logic
}
} }
} }
return LockScreenSettingsState( return LockScreenSettingsState(
isPinMandatory = LockScreenConfig.IS_PIN_MANDATORY, showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled, isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation, showRemovePinConfirmation = showRemovePinConfirmation,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
} }

View file

@ -17,7 +17,7 @@
package io.element.android.features.lockscreen.impl.settings package io.element.android.features.lockscreen.impl.settings
data class LockScreenSettingsState( data class LockScreenSettingsState(
val isPinMandatory: Boolean, val showRemovePinOption: Boolean,
val isBiometricEnabled: Boolean, val isBiometricEnabled: Boolean,
val showRemovePinConfirmation: Boolean, val showRemovePinConfirmation: Boolean,
val eventSink: (LockScreenSettingsEvents) -> Unit val eventSink: (LockScreenSettingsEvents) -> Unit

View file

@ -32,7 +32,7 @@ fun aLockScreenSettingsState(
isBiometricEnabled: Boolean = false, isBiometricEnabled: Boolean = false,
showRemovePinConfirmation: Boolean = false, showRemovePinConfirmation: Boolean = false,
) = LockScreenSettingsState( ) = LockScreenSettingsState(
isPinMandatory = isLockMandatory, showRemovePinOption = isLockMandatory,
isBiometricEnabled = isBiometricEnabled, isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation, showRemovePinConfirmation = showRemovePinConfirmation,
eventSink = {} eventSink = {}

View file

@ -34,21 +34,22 @@ import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
fun LockScreenSettingsView( fun LockScreenSettingsView(
state: LockScreenSettingsState, state: LockScreenSettingsState,
onChangePinClicked: () -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
PreferencePage( PreferencePage(
title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock), title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock),
onBackPressed = onBackPressed,
modifier = modifier modifier = modifier
) { ) {
PreferenceCategory(showDivider = false) { PreferenceCategory(showDivider = false) {
PreferenceText( PreferenceText(
title = stringResource(id = R.string.screen_app_lock_settings_change_pin), title = stringResource(id = R.string.screen_app_lock_settings_change_pin),
onClick = { onClick = onChangePinClicked
state.eventSink(LockScreenSettingsEvents.ChangePin)
}
) )
PreferenceDivider() PreferenceDivider()
if (!state.isPinMandatory) { if (state.showRemovePinOption) {
PreferenceText( PreferenceText(
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin), title = stringResource(id = R.string.screen_app_lock_settings_remove_pin),
tintColor = ElementTheme.colors.textCriticalPrimary, tintColor = ElementTheme.colors.textCriticalPrimary,
@ -80,6 +81,10 @@ internal fun LockScreenSettingsViewPreview(
@PreviewParameter(LockScreenSettingsStateProvider::class) state: LockScreenSettingsState, @PreviewParameter(LockScreenSettingsStateProvider::class) state: LockScreenSettingsState,
) { ) {
ElementPreview { ElementPreview {
LockScreenSettingsView(state) LockScreenSettingsView(
state = state,
onChangePinClicked = {},
onBackPressed = {},
)
} }
} }

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
@ -34,6 +35,7 @@ import javax.inject.Inject
class SetupPinPresenter @Inject constructor( class SetupPinPresenter @Inject constructor(
private val pinValidator: PinValidator, private val pinValidator: PinValidator,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val pinCodeManager: PinCodeManager,
) : Presenter<SetupPinState> { ) : Presenter<SetupPinState> {
@Composable @Composable
@ -68,7 +70,7 @@ class SetupPinPresenter @Inject constructor(
LaunchedEffect(confirmPinEntry) { LaunchedEffect(confirmPinEntry) {
if (confirmPinEntry.isComplete()) { if (confirmPinEntry.isComplete()) {
if (confirmPinEntry == choosePinEntry) { if (confirmPinEntry == choosePinEntry) {
//TODO save in db and navigate to next screen pinCodeManager.createPinCode(confirmPinEntry.toText())
} else { } else {
setupPinFailure = SetupPinFailure.PinsDontMatch setupPinFailure = SetupPinFailure.PinsDontMatch
} }

View file

@ -19,12 +19,15 @@ package io.element.android.features.lockscreen.impl.state
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenState import io.element.android.features.lockscreen.api.LockScreenState
import io.element.android.features.lockscreen.api.LockScreenStateService import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -36,6 +39,8 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultLockScreenStateService @Inject constructor( class DefaultLockScreenStateService @Inject constructor(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope,
) : LockScreenStateService { ) : LockScreenStateService {
private val _lockScreenState = MutableStateFlow<LockScreenState>(LockScreenState.Unlocked) private val _lockScreenState = MutableStateFlow<LockScreenState>(LockScreenState.Unlocked)
@ -43,10 +48,13 @@ class DefaultLockScreenStateService @Inject constructor(
private var lockJob: Job? = null private var lockJob: Job? = null
override suspend fun unlock() { init {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) { pinCodeManager.addCallback(object : PinCodeManager.Callback {
_lockScreenState.value = LockScreenState.Unlocked override fun onPinCodeVerified() {
} _lockScreenState.value = LockScreenState.Unlocked
}
})
coroutineScope.lockIfNeeded()
} }
override suspend fun entersForeground() { override suspend fun entersForeground() {
@ -54,11 +62,13 @@ class DefaultLockScreenStateService @Inject constructor(
} }
override suspend fun entersBackground() = coroutineScope { override suspend fun entersBackground() = coroutineScope {
lockJob = launch { lockJob = lockIfNeeded()
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) { }
//delay(GRACE_PERIOD_IN_MILLIS)
_lockScreenState.value = LockScreenState.Locked private fun CoroutineScope.lockIfNeeded(delayInMillis: Long = 0L) = launch {
} if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
delay(delayInMillis)
_lockScreenState.value = LockScreenState.Locked
} }
} }
} }

View file

@ -17,24 +17,22 @@
package io.element.android.features.lockscreen.impl.unlock package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.api.LockScreenStateService import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class PinUnlockPresenter @Inject constructor( class PinUnlockPresenter @Inject constructor(
private val pinStateService: LockScreenStateService, private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@Composable @Composable
@ -43,9 +41,8 @@ class PinUnlockPresenter @Inject constructor(
//TODO fetch size from db //TODO fetch size from db
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE)) mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
} }
var remainingAttempts by rememberSaveable { var remainingAttempts by remember {
//TODO fetch from db mutableStateOf<Async<Int>>(Async.Uninitialized)
mutableIntStateOf(3)
} }
var showWrongPinTitle by rememberSaveable { var showWrongPinTitle by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@ -54,14 +51,25 @@ class PinUnlockPresenter @Inject constructor(
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(pinEntry) {
if (pinEntry.isComplete()) {
val isVerified = pinCodeManager.verifyPinCode(pinEntry.toText())
if (!isVerified) {
pinEntry = pinEntry.clear()
showWrongPinTitle = true
}
}
val remainingAttemptsNumber = pinCodeManager.getRemainingPinCodeAttemptsNumber()
remainingAttempts = Async.Success(remainingAttemptsNumber)
if (remainingAttemptsNumber == 0) {
showSignOutPrompt = true
}
}
fun handleEvents(event: PinUnlockEvents) { fun handleEvents(event: PinUnlockEvents) {
when (event) { when (event) {
is PinUnlockEvents.OnPinKeypadPressed -> { is PinUnlockEvents.OnPinKeypadPressed -> {
pinEntry = pinEntry.process(event.pinKeypadModel) pinEntry = pinEntry.process(event.pinKeypadModel)
if (pinEntry.isComplete()) {
//TODO check pin with PinCodeManager
coroutineScope.launch { pinStateService.unlock() }
}
} }
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false

View file

@ -17,13 +17,14 @@
package io.element.android.features.lockscreen.impl.unlock package io.element.android.features.lockscreen.impl.unlock
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
data class PinUnlockState( data class PinUnlockState(
val pinEntry: PinEntry, val pinEntry: PinEntry,
val showWrongPinTitle: Boolean, val showWrongPinTitle: Boolean,
val remainingAttempts: Int, val remainingAttempts: Async<Int>,
val showSignOutPrompt: Boolean, val showSignOutPrompt: Boolean,
val eventSink: (PinUnlockEvents) -> Unit val eventSink: (PinUnlockEvents) -> Unit
) { ) {
val isSignOutPromptCancellable = remainingAttempts > 0 val isSignOutPromptCancellable = (remainingAttempts.dataOrNull() ?: 0) > 0
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> { open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState> override val values: Sequence<PinUnlockState>
@ -38,7 +39,7 @@ fun aPinUnlockState(
) = PinUnlockState( ) = PinUnlockState(
pinEntry = pinEntry, pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = Async.Success(remainingAttempts),
showSignOutPrompt = showSignOutPrompt, showSignOutPrompt = showSignOutPrompt,
eventSink = {} eventSink = {}
) )

View file

@ -226,10 +226,15 @@ private fun PinUnlockHeader(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
val subtitle = if (state.showWrongPinTitle) { val remainingAttempts = state.remainingAttempts.dataOrNull()
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = state.remainingAttempts, state.remainingAttempts) val subtitle = if (remainingAttempts != null) {
if (state.showWrongPinTitle) {
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = remainingAttempts, remainingAttempts)
} else {
stringResource(id = R.string.screen_app_lock_subtitle)
}
} else { } else {
stringResource(id = R.string.screen_app_lock_subtitle) ""
} }
val subtitleColor = if (state.showWrongPinTitle) { val subtitleColor = if (state.showWrongPinTitle) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error

View file

@ -40,7 +40,7 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
// False positive lint issue // False positive lint issue
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
override fun getOrCreateKey(alias: String): SecretKey { override fun getOrCreateKey(alias: String): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).also { it.load(null) }
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry) val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
?.secretKey ?.secretKey
return if (secretKeyEntry == null) { return if (secretKeyEntry == null) {