Merge pull request #1757 from vector-im/feature/fga/lock_polish

LockScreen polish
This commit is contained in:
ganfra 2023-11-08 14:02:53 +01:00 committed by GitHub
commit 4d48005f3d
29 changed files with 264 additions and 124 deletions

View file

@ -32,6 +32,7 @@ import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.features.lockscreen.api.handleSecureFlag
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
@ -53,6 +54,7 @@ class MainActivity : NodeActivity() {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appBindings = bindings() appBindings = bindings()
appBindings.lockScreenService().handleSecureFlag(this)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
MainContent(appBindings) MainContent(appBindings)

View file

@ -17,6 +17,7 @@
package io.element.android.x.di package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -27,4 +28,5 @@ interface AppBindings {
fun snackbarDispatcher(): SnackbarDispatcher fun snackbarDispatcher(): SnackbarDispatcher
fun tracingService(): TracingService fun tracingService(): TracingService
fun bugReporter(): BugReporter fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService
} }

View file

@ -146,7 +146,7 @@ class FtueFlowNode @AssistedInject constructor(
} }
NavTarget.LockScreenSetup -> { NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback { val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupCompleted() { override fun onSetupDone() {
lifecycleScope.launch { moveToNextStep() } lifecycleScope.launch { moveToNextStep() }
} }
} }

View file

@ -120,7 +120,7 @@ class DefaultFtueState @Inject constructor(
private fun shouldDisplayLockscreenSetup(): Boolean { private fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking { return runBlocking {
lockScreenService.isSetupRequired() lockScreenService.isSetupRequired().first()
} }
} }

View file

@ -57,6 +57,7 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState( val state = createState(
@ -64,13 +65,15 @@ class DefaultFtueStateTests {
welcomeState = welcomeState, welcomeState = welcomeState,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
) )
welcomeState.setWelcomeScreenShown() welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID) migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
state.updateState() state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse() assertThat(state.shouldDisplayFlow.value).isFalse()
@ -85,6 +88,7 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState( val state = createState(
@ -92,7 +96,8 @@ class DefaultFtueStateTests {
welcomeState = welcomeState, welcomeState = welcomeState,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
) )
val steps = mutableListOf<FtueStep?>() val steps = mutableListOf<FtueStep?>()
@ -108,7 +113,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
// Fourth step, analytics opt in // Fourth step, entering PIN code
steps.add(state.getNextStep(steps.lastOrNull()))
lockScreenService.setIsPinSetup(true)
// Fifth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
@ -119,6 +128,7 @@ class DefaultFtueStateTests {
FtueStep.MigrationScreen, FtueStep.MigrationScreen,
FtueStep.WelcomeScreen, FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn, FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn, FtueStep.AnalyticsOptIn,
null, // Final state null, // Final state
) )
@ -133,18 +143,20 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
) )
// Skip first 3 steps // Skip first 4 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID) migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.setWelcomeScreenShown() state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@ -160,18 +172,21 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
sdkIntVersion = Build.VERSION_CODES.M, sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
lockScreenService = lockScreenService,
) )
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID) migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen) assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown() state.setWelcomeScreenShown()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()

View file

@ -31,8 +31,8 @@ interface LockScreenEntryPoint : FeatureEntryPoint {
fun build(): Node fun build(): Node
} }
interface Callback: Plugin { interface Callback : Plugin {
fun onSetupCompleted() fun onSetupDone()
} }
enum class Target { enum class Target {

View file

@ -16,7 +16,14 @@
package io.element.android.features.lockscreen.api package io.element.android.features.lockscreen.api
import android.os.Build
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
interface LockScreenService { interface LockScreenService {
/** /**
@ -28,5 +35,34 @@ interface LockScreenService {
* Check if setting up the lock screen is required. * Check if setting up the lock screen is required.
* @return true if the lock screen is mandatory and not setup yet, false otherwise. * @return true if the lock screen is mandatory and not setup yet, false otherwise.
*/ */
suspend fun isSetupRequired(): Boolean fun isSetupRequired(): Flow<Boolean>
/**
* Check if pin is setup.
* @return true if the pin is setup, false otherwise.
*/
fun isPinSetup(): Flow<Boolean>
}
/**
* Makes sure the secure flag is set on the activity if the pin is setup.
* @param activity the activity to set the flag on.
*/
fun LockScreenService.handleSecureFlag(activity: ComponentActivity) {
isPinSetup()
.onEach { isPinSetup ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.setRecentsScreenshotEnabled(!isPinSetup)
} else {
if (isPinSetup) {
activity.window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
.launchIn(activity.lifecycleScope)
} }

View file

@ -35,8 +35,12 @@ import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@ -113,14 +117,23 @@ class DefaultLockScreenService @Inject constructor(
} }
} }
override suspend fun isSetupRequired(): Boolean { override fun isPinSetup(): Flow<Boolean> {
return lockScreenConfig.isPinMandatory return combine(
&& featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinUnlock),
&& !pinCodeManager.isPinCodeAvailable() pinCodeManager.hasPinCode()
) { isEnabled, hasPinCode ->
isEnabled && hasPinCode
}
}
override fun isSetupRequired(): Flow<Boolean> {
return isPinSetup().map { isPinSetup ->
!isPinSetup && lockScreenConfig.isPinMandatory
}
} }
private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch { private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) { if (isPinSetup().first()) {
delay(gracePeriod) delay(gracePeriod)
_lockScreenState.value = LockScreenLockState.Locked _lockScreenState.value = LockScreenLockState.Locked
} }

View file

@ -20,7 +20,6 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children 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.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
@ -30,8 +29,6 @@ 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.api.LockScreenEntryPoint 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.settings.LockScreenSettingsFlowNode
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
@ -46,7 +43,6 @@ import kotlinx.parcelize.Parcelize
class LockScreenFlowNode @AssistedInject constructor( class LockScreenFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenFlowNode.NavTarget>( ) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget, initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
@ -71,26 +67,14 @@ class LockScreenFlowNode @AssistedInject constructor(
data object Settings : NavTarget data object Settings : NavTarget
} }
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() { private class OnSetupDoneCallback(private val plugins: List<LockScreenEntryPoint.Callback>) : LockScreenSetupFlowNode.Callback {
override fun onPinCodeCreated() { override fun onSetupDone() {
plugins<LockScreenEntryPoint.Callback>().forEach { plugins.forEach {
it.onSetupCompleted() it.onSetupDone()
} }
} }
} }
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Unlock -> { NavTarget.Unlock -> {
@ -98,7 +82,8 @@ class LockScreenFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs)) createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
} }
NavTarget.Setup -> { NavTarget.Setup -> {
createNode<LockScreenSetupFlowNode>(buildContext) val callback = OnSetupDoneCallback(plugins())
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.Settings -> { NavTarget.Settings -> {
createNode<LockScreenSettingsFlowNode>(buildContext) createNode<LockScreenSettingsFlowNode>(buildContext)

View file

@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber import timber.log.Timber
import java.security.InvalidKeyException import java.security.InvalidKeyException
@ -86,7 +87,12 @@ class DefaultBiometricUnlock(
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult) val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback) val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo, cryptoObject) prompt.authenticate(promptInfo, cryptoObject)
return deferredAuthenticationResult.await() return try {
deferredAuthenticationResult.await()
} catch (cancellation: CancellationException) {
prompt.cancelAuthentication()
BiometricUnlock.AuthenticationResult.Failure(cancellation)
}
} }
@Throws(KeyPermanentlyInvalidatedException::class) @Throws(KeyPermanentlyInvalidatedException::class)
@ -110,7 +116,6 @@ private class AuthenticationCallback(
override fun onAuthenticationFailed() { override fun onAuthenticationFailed() {
super.onAuthenticationFailed() super.onAuthenticationFailed()
callbacks.forEach { it.onBiometricUnlockFailed(null) } callbacks.forEach { it.onBiometricUnlockFailed(null) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
} }
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject import javax.inject.Inject
@ -46,7 +47,7 @@ class DefaultPinCodeManager @Inject constructor(
callbacks.remove(callback) callbacks.remove(callback)
} }
override suspend fun isPinCodeAvailable(): Boolean { override fun hasPinCode(): Flow<Boolean> {
return lockScreenStore.hasPinCode() return lockScreenStore.hasPinCode()
} }

View file

@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.pin package io.element.android.features.lockscreen.impl.pin
import kotlinx.coroutines.flow.Flow
/** /**
* This interface is the main interface to manage the pin code. * This interface is the main interface to manage the pin code.
* Implementation should take care of encrypting the pin code and storing it. * Implementation should take care of encrypting the pin code and storing it.
@ -55,7 +57,7 @@ interface PinCodeManager {
/** /**
* @return true if a pin code is available. * @return true if a pin code is available.
*/ */
suspend fun isPinCodeAvailable(): Boolean fun hasPinCode(): Flow<Boolean>
/** /**
* @return the size of the saved pin code. * @return the size of the saved pin code.

View file

@ -32,16 +32,15 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode import io.element.android.features.lockscreen.impl.setup.pin.SetupPinNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -50,7 +49,6 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>( ) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Unknown, initialElement = NavTarget.Unknown,
@ -68,44 +66,39 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
data object Unlock : NavTarget data object Unlock : NavTarget
@Parcelize @Parcelize
data object Setup : NavTarget data object SetupPin : NavTarget
@Parcelize @Parcelize
data object Settings : NavTarget data object Settings : NavTarget
} }
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() { private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() { override fun onPinCodeRemoved() {
navigateUp() navigateUp()
} }
}
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() { override fun onPinCodeCreated() {
override fun onBiometricUnlockSuccess() {
backstack.newRoot(NavTarget.Settings) backstack.newRoot(NavTarget.Settings)
} }
} }
init { override fun onBuilt() {
super.onBuilt()
lifecycleScope.launch { lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) { val hasPinCode = pinCodeManager.hasPinCode().first()
if (hasPinCode) {
backstack.newRoot(NavTarget.Unlock) backstack.newRoot(NavTarget.Unlock)
} else { } else {
backstack.newRoot(NavTarget.Setup) backstack.newRoot(NavTarget.SetupPin)
} }
} }
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback) pinCodeManager.addCallback(pinCodeManagerCallback)
biometricUnlockManager.addCallback(biometricUnlockCallback)
}, },
onDestroy = { onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback) pinCodeManager.removeCallback(pinCodeManagerCallback)
biometricUnlockManager.removeCallback(biometricUnlockCallback)
} }
) )
} }
@ -114,25 +107,26 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
NavTarget.Unlock -> { NavTarget.Unlock -> {
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true) val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs)) val callback = object : PinUnlockNode.Callback {
} override fun onUnlock() {
NavTarget.Setup -> {
val callback = object : LockScreenSetupFlowNode.Callback {
override fun onSetupDone() {
backstack.newRoot(NavTarget.Settings) backstack.newRoot(NavTarget.Settings)
} }
} }
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback)) createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.SetupPin -> {
createNode<SetupPinNode>(buildContext)
} }
NavTarget.Settings -> { NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback { val callback = object : LockScreenSettingsNode.Callback {
override fun onChangePinClicked() { override fun onChangePinClicked() {
backstack.push(NavTarget.Setup) backstack.push(NavTarget.SetupPin)
} }
} }
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback)) createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.Unknown -> node(buildContext) { } NavTarget.Unknown -> node(buildContext) { }
} }
} }

View file

@ -17,11 +17,10 @@
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.collectAsState import androidx.compose.runtime.collectAsState
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.produceState
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
@ -43,23 +42,15 @@ class LockScreenSettingsPresenter @Inject constructor(
@Composable @Composable
override fun present(): LockScreenSettingsState { override fun present(): LockScreenSettingsState {
var triggerComputation by remember { val showRemovePinOption by produceState(initialValue = false) {
mutableIntStateOf(0) pinCodeManager.hasPinCode().collect { hasPinCode ->
} value = !lockScreenConfig.isPinMandatory && hasPinCode
var showRemovePinOption by remember { }
mutableStateOf(false)
}
var showToggleBiometric by remember {
mutableStateOf(false)
} }
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false) val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
var showRemovePinConfirmation by remember { var showRemovePinConfirmation by remember {
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(triggerComputation) {
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
showToggleBiometric = biometricUnlockManager.isDeviceSecured
}
fun handleEvents(event: LockScreenSettingsEvents) { fun handleEvents(event: LockScreenSettingsEvents) {
when (event) { when (event) {
@ -69,7 +60,6 @@ class LockScreenSettingsPresenter @Inject constructor(
if (showRemovePinConfirmation) { if (showRemovePinConfirmation) {
showRemovePinConfirmation = false showRemovePinConfirmation = false
pinCodeManager.deletePinCode() pinCodeManager.deletePinCode()
triggerComputation++
} }
} }
} }
@ -86,7 +76,7 @@ class LockScreenSettingsPresenter @Inject constructor(
showRemovePinOption = showRemovePinOption, showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled, isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation, showRemovePinConfirmation = showRemovePinConfirmation,
showToggleBiometric = showToggleBiometric, showToggleBiometric = biometricUnlockManager.isDeviceSecured,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -17,6 +17,6 @@
package io.element.android.features.lockscreen.impl.setup.pin package io.element.android.features.lockscreen.impl.setup.pin
sealed interface SetupPinEvents { sealed interface SetupPinEvents {
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents
data object ClearFailure : SetupPinEvents data object ClearFailure : SetupPinEvents
} }

View file

@ -32,6 +32,11 @@ import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
/**
* Some time for the ui to refresh before showing confirmation step.
*/
private const val DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS = 100L
class SetupPinPresenter @Inject constructor( class SetupPinPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
private val pinValidator: PinValidator, private val pinValidator: PinValidator,
@ -60,8 +65,7 @@ class SetupPinPresenter @Inject constructor(
setupPinFailure = pinValidationResult.failure setupPinFailure = pinValidationResult.failure
} }
PinValidator.Result.Valid -> { PinValidator.Result.Valid -> {
// Leave some time for the ui to refresh before showing confirmation delay(DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS)
delay(150)
isConfirmationStep = true isConfirmationStep = true
} }
} }
@ -81,7 +85,8 @@ class SetupPinPresenter @Inject constructor(
fun handleEvents(event: SetupPinEvents) { fun handleEvents(event: SetupPinEvents) {
when (event) { when (event) {
is SetupPinEvents.OnPinEntryChanged -> { is SetupPinEvents.OnPinEntryChanged -> {
if (isConfirmationStep) { // Use the fromConfirmationStep flag from ui to avoid race condition.
if (event.fromConfirmationStep) {
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText) confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
} else { } else {
choosePinEntry = choosePinEntry.fillWith(event.entryAsText) choosePinEntry = choosePinEntry.fillWith(event.entryAsText)

View file

@ -116,8 +116,8 @@ private fun SetupPinContent(
PinEntryTextField( PinEntryTextField(
pinEntry = state.activePinEntry, pinEntry = state.activePinEntry,
isSecured = true, isSecured = true,
onValueChange = { onValueChange = { entry ->
state.eventSink(SetupPinEvents.OnPinEntryChanged(it)) state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
}, },
modifier = modifier modifier = modifier
.focusRequester(focusRequester) .focusRequester(focusRequester)

View file

@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.storage package io.element.android.features.lockscreen.impl.storage
import kotlinx.coroutines.flow.Flow
/** /**
* Should be implemented by any class that provides access to the encrypted PIN code. * Should be implemented by any class that provides access to the encrypted PIN code.
* All methods are suspending in case there are async IO operations involved. * All methods are suspending in case there are async IO operations involved.
@ -39,5 +41,6 @@ interface EncryptedPinCodeStorage {
/** /**
* Returns whether the PIN code is stored or not. * Returns whether the PIN code is stored or not.
*/ */
suspend fun hasPinCode(): Boolean fun hasPinCode(): Flow<Boolean>
} }

View file

@ -85,10 +85,10 @@ class PreferencesLockScreenStore @Inject constructor(
} }
} }
override suspend fun hasPinCode(): Boolean { override fun hasPinCode(): Flow<Boolean> {
return context.dataStore.data.map { preferences -> return context.dataStore.data.map { preferences ->
preferences[pinCodeKey] != null preferences[pinCodeKey] != null
}.first() }
} }
override fun isBiometricUnlockAllowed(): Flow<Boolean> { override fun isBiometricUnlockAllowed(): Flow<Boolean> {

View file

@ -0,0 +1,53 @@
/*
* 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.unlock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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 javax.inject.Inject
class PinUnlockHelper @Inject constructor(
private val biometricUnlockManager: BiometricUnlockManager,
private val pinCodeManager: PinCodeManager
) {
@Composable
fun OnUnlockEffect(onUnlock: () -> Unit) {
DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
onUnlock()
}
}
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
onUnlock()
}
}
biometricUnlockManager.addCallback(biometricUnlockCallback)
pinCodeManager.addCallback(pinCodeVerifiedCallback)
onDispose {
biometricUnlockManager.removeCallback(biometricUnlockCallback)
pinCodeManager.removeCallback(pinCodeVerifiedCallback)
}
}
}
}

View file

@ -17,10 +17,12 @@
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.ui.Modifier 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
@ -35,15 +37,30 @@ class PinUnlockNode @AssistedInject constructor(
private val presenter: PinUnlockPresenter, private val presenter: PinUnlockPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onUnlock()
}
data class Inputs( data class Inputs(
val isInAppUnlock: Boolean val isInAppUnlock: Boolean
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private fun onUnlock() {
plugins<Callback>().forEach {
it.onUnlock()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
LaunchedEffect(state.isUnlocked) {
if (state.isUnlocked) {
onUnlock()
}
}
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = inputs.isInAppUnlock, isInAppUnlock = inputs.isInAppUnlock,

View file

@ -43,6 +43,7 @@ class PinUnlockPresenter @Inject constructor(
private val biometricUnlockManager: BiometricUnlockManager, private val biometricUnlockManager: BiometricUnlockManager,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@Composable @Composable
@ -66,9 +67,10 @@ class PinUnlockPresenter @Inject constructor(
var biometricUnlockResult by remember { var biometricUnlockResult by remember {
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null) mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
} }
val isUnlocked = remember {
mutableStateOf(false)
}
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock() val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
suspend { suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize() val pinCodeSize = pinCodeManager.getPinCodeSize()
@ -94,6 +96,9 @@ class PinUnlockPresenter @Inject constructor(
showSignOutPrompt = true showSignOutPrompt = true
} }
} }
pinUnlockHelper.OnUnlockEffect {
isUnlocked.value = true
}
fun handleEvents(event: PinUnlockEvents) { fun handleEvents(event: PinUnlockEvents) {
when (event) { when (event) {
@ -129,6 +134,7 @@ class PinUnlockPresenter @Inject constructor(
signOutAction = signOutAction.value, signOutAction = signOutAction.value,
showBiometricUnlock = biometricUnlock.isActive, showBiometricUnlock = biometricUnlock.isActive,
biometricUnlockResult = biometricUnlockResult, biometricUnlockResult = biometricUnlockResult,
isUnlocked = isUnlocked.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -28,6 +28,7 @@ data class PinUnlockState(
val showSignOutPrompt: Boolean, val showSignOutPrompt: Boolean,
val signOutAction: Async<String?>, val signOutAction: Async<String?>,
val showBiometricUnlock: Boolean, val showBiometricUnlock: Boolean,
val isUnlocked: Boolean,
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?, val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
val eventSink: (PinUnlockEvents) -> Unit val eventSink: (PinUnlockEvents) -> Unit
) { ) {

View file

@ -41,6 +41,7 @@ fun aPinUnlockState(
showSignOutPrompt: Boolean = false, showSignOutPrompt: Boolean = false,
showBiometricUnlock: Boolean = true, showBiometricUnlock: Boolean = true,
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null, biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
isUnlocked: Boolean = false,
signOutAction: Async<String?> = Async.Uninitialized, signOutAction: Async<String?> = Async.Uninitialized,
) = PinUnlockState( ) = PinUnlockState(
pinEntry = Async.Success(pinEntry), pinEntry = Async.Success(pinEntry),
@ -50,5 +51,6 @@ fun aPinUnlockState(
showBiometricUnlock = showBiometricUnlock, showBiometricUnlock = showBiometricUnlock,
signOutAction = signOutAction, signOutAction = signOutAction,
biometricUnlockResult = biometricUnlockResult, biometricUnlockResult = biometricUnlockResult,
isUnlocked = isUnlocked,
eventSink = {} eventSink = {}
) )

View file

@ -16,6 +16,7 @@
package io.element.android.features.lockscreen.impl.pin package io.element.android.features.lockscreen.impl.pin
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
@ -32,10 +33,13 @@ class DefaultPinCodeManagerTest {
@Test @Test
fun `given a pin code when create and delete assert no pin code left`() = runTest { fun `given a pin code when create and delete assert no pin code left`() = runTest {
pinCodeManager.createPinCode("1234") pinCodeManager.hasPinCode().test {
assertThat(pinCodeManager.isPinCodeAvailable()).isTrue() assertThat(awaitItem()).isFalse()
pinCodeManager.deletePinCode() pinCodeManager.createPinCode("1234")
assertThat(pinCodeManager.isPinCodeAvailable()).isFalse() assertThat(awaitItem()).isTrue()
pinCodeManager.deletePinCode()
assertThat(awaitItem()).isFalse()
}
} }
@Test @Test

View file

@ -24,7 +24,12 @@ private const val DEFAULT_REMAINING_ATTEMPTS = 3
class InMemoryLockScreenStore : LockScreenStore { class InMemoryLockScreenStore : LockScreenStore {
private val hasPinCode = MutableStateFlow(false)
private var pinCode: String? = null private var pinCode: String? = null
set(value) {
field = value
hasPinCode.value = value != null
}
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
private var isBiometricUnlockAllowed = MutableStateFlow(false) private var isBiometricUnlockAllowed = MutableStateFlow(false)
@ -52,8 +57,8 @@ class InMemoryLockScreenStore : LockScreenStore {
pinCode = null pinCode = null
} }
override suspend fun hasPinCode(): Boolean { override fun hasPinCode(): Flow<Boolean> {
return pinCode != null return hasPinCode
} }
override fun isBiometricUnlockAllowed(): Flow<Boolean> { override fun isBiometricUnlockAllowed(): Flow<Boolean> {

View file

@ -60,14 +60,14 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin)) state.onPinEntryChanged(halfCompletePin)
} }
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertText(halfCompletePin) state.choosePinEntry.assertText(halfCompletePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin)) state.onPinEntryChanged(blacklistedPin)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(blacklistedPin) state.choosePinEntry.assertText(blacklistedPin)
@ -77,7 +77,7 @@ class SetupPinPresenterTest {
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty() state.choosePinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.onPinEntryChanged(completePin)
} }
consumeItemsUntilPredicate { consumeItemsUntilPredicate {
it.isConfirmationStep it.isConfirmationStep
@ -85,7 +85,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin)) state.onPinEntryChanged(mismatchedPin)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
@ -98,7 +98,7 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.onPinEntryChanged(completePin)
} }
consumeItemsUntilPredicate { consumeItemsUntilPredicate {
it.isConfirmationStep it.isConfirmationStep
@ -106,7 +106,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.onPinEntryChanged(completePin)
} }
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
@ -116,6 +116,10 @@ class SetupPinPresenterTest {
} }
} }
private fun SetupPinState.onPinEntryChanged(pinEntry: String){
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
}
private fun createSetupPinPresenter( private fun createSetupPinPresenter(
callback: PinCodeManager.Callback, callback: PinCodeManager.Callback,
lockScreenConfig: LockScreenConfig = aLockScreenConfig( lockScreenConfig: LockScreenConfig = aLockScreenConfig(

View file

@ -32,7 +32,6 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -44,13 +43,7 @@ class PinUnlockPresenterTest {
@Test @Test
fun `present - success verify flow`() = runTest { fun `present - success verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>() val presenter = createPinUnlockPresenter(this)
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -58,6 +51,7 @@ class PinUnlockPresenterTest {
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).isFalse() assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse() assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.isUnlocked).isFalse()
assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
} }
@ -77,20 +71,14 @@ class PinUnlockPresenterTest {
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(completePin) state.pinEntry.assertText(completePin)
assertThat(state.isUnlocked).isTrue()
} }
pinCodeVerified.await()
} }
} }
@Test @Test
fun `present - failure verify flow`() = runTest { fun `present - failure verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>() val presenter = createPinUnlockPresenter(this)
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -160,6 +148,7 @@ class PinUnlockPresenterTest {
biometricUnlockManager = biometricUnlockManager, biometricUnlockManager = biometricUnlockManager,
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
coroutineScope = scope, coroutineScope = scope,
pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
) )
} }
} }

View file

@ -18,21 +18,27 @@ package io.element.android.features.lockscreen.test
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class FakeLockScreenService : LockScreenService { class FakeLockScreenService : LockScreenService {
private var isSetupRequired: Boolean = false private var isPinSetup = MutableStateFlow(false)
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked) private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
override val lockState: StateFlow<LockScreenLockState> = _lockState override val lockState: StateFlow<LockScreenLockState> = _lockState
override suspend fun isSetupRequired(): Boolean { override fun isSetupRequired(): Flow<Boolean> {
return isSetupRequired return isPinSetup.map { !it }
} }
fun setIsSetupRequired(isSetupRequired: Boolean) { fun setIsPinSetup(isPinSetup: Boolean) {
this.isSetupRequired = isSetupRequired this.isPinSetup.value = isPinSetup
}
override fun isPinSetup(): Flow<Boolean> {
return isPinSetup
} }
fun setLockState(lockState: LockScreenLockState) { fun setLockState(lockState: LockScreenLockState) {