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
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.integrationpoint.NodeActivity
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.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
@@ -53,6 +54,7 @@ class MainActivity : NodeActivity() {
installSplashScreen()
super.onCreate(savedInstanceState)
appBindings = bindings()
appBindings.lockScreenService().handleSecureFlag(this)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
MainContent(appBindings)

View File

@@ -17,6 +17,7 @@
package io.element.android.x.di
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.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@@ -27,4 +28,5 @@ interface AppBindings {
fun snackbarDispatcher(): SnackbarDispatcher
fun tracingService(): TracingService
fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,14 @@
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.launchIn
import kotlinx.coroutines.flow.onEach
interface LockScreenService {
/**
@@ -28,5 +35,34 @@ interface LockScreenService {
* Check if setting up the lock screen is required.
* @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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
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 javax.inject.Inject
import kotlin.time.Duration
@@ -113,14 +117,23 @@ class DefaultLockScreenService @Inject constructor(
}
}
override suspend fun isSetupRequired(): Boolean {
return lockScreenConfig.isPinMandatory
&& featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
&& !pinCodeManager.isPinCodeAvailable()
override fun isPinSetup(): Flow<Boolean> {
return combine(
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinUnlock),
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 {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
if (isPinSetup().first()) {
delay(gracePeriod)
_lockScreenState.value = LockScreenLockState.Locked
}

View File

@@ -20,7 +20,6 @@ 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
@@ -30,8 +29,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
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.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
@@ -46,7 +43,6 @@ import kotlinx.parcelize.Parcelize
class LockScreenFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
@@ -71,26 +67,14 @@ class LockScreenFlowNode @AssistedInject constructor(
data object Settings : NavTarget
}
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
plugins<LockScreenEntryPoint.Callback>().forEach {
it.onSetupCompleted()
private class OnSetupDoneCallback(private val plugins: List<LockScreenEntryPoint.Callback>) : LockScreenSetupFlowNode.Callback {
override fun onSetupDone() {
plugins.forEach {
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 {
return when (navTarget) {
NavTarget.Unlock -> {
@@ -98,7 +82,8 @@ class LockScreenFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
}
NavTarget.Setup -> {
createNode<LockScreenSetupFlowNode>(buildContext)
val callback = OnSetupDoneCallback(plugins())
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Settings -> {
createNode<LockScreenSettingsFlowNode>(buildContext)

View File

@@ -24,6 +24,7 @@ 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.CancellationException
import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber
import java.security.InvalidKeyException
@@ -86,7 +87,12 @@ class DefaultBiometricUnlock(
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo, cryptoObject)
return deferredAuthenticationResult.await()
return try {
deferredAuthenticationResult.await()
} catch (cancellation: CancellationException) {
prompt.cancelAuthentication()
BiometricUnlock.AuthenticationResult.Failure(cancellation)
}
}
@Throws(KeyPermanentlyInvalidatedException::class)
@@ -110,7 +116,6 @@ private class AuthenticationCallback(
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
callbacks.forEach { it.onBiometricUnlockFailed(null) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
}
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.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
@@ -46,7 +47,7 @@ class DefaultPinCodeManager @Inject constructor(
callbacks.remove(callback)
}
override suspend fun isPinCodeAvailable(): Boolean {
override fun hasPinCode(): Flow<Boolean> {
return lockScreenStore.hasPinCode()
}

View File

@@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.pin
import kotlinx.coroutines.flow.Flow
/**
* This interface is the main interface to manage the pin code.
* 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.
*/
suspend fun isPinCodeAvailable(): Boolean
fun hasPinCode(): Flow<Boolean>
/**
* @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.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.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.setup.pin.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.SessionScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -50,7 +49,6 @@ 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,
@@ -68,44 +66,39 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
data object Unlock : NavTarget
@Parcelize
data object Setup : NavTarget
data object SetupPin : NavTarget
@Parcelize
data object Settings : NavTarget
}
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() {
navigateUp()
}
}
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Settings)
}
}
init {
override fun onBuilt() {
super.onBuilt()
lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) {
val hasPinCode = pinCodeManager.hasPinCode().first()
if (hasPinCode) {
backstack.newRoot(NavTarget.Unlock)
} else {
backstack.newRoot(NavTarget.Setup)
backstack.newRoot(NavTarget.SetupPin)
}
}
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
biometricUnlockManager.addCallback(biometricUnlockCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
biometricUnlockManager.removeCallback(biometricUnlockCallback)
}
)
}
@@ -114,25 +107,26 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
return when (navTarget) {
NavTarget.Unlock -> {
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
}
NavTarget.Setup -> {
val callback = object : LockScreenSetupFlowNode.Callback {
override fun onSetupDone() {
val callback = object : PinUnlockNode.Callback {
override fun onUnlock() {
backstack.newRoot(NavTarget.Settings)
}
}
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.SetupPin -> {
createNode<SetupPinNode>(buildContext)
}
NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback {
override fun onChangePinClicked() {
backstack.push(NavTarget.Setup)
backstack.push(NavTarget.SetupPin)
}
}
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Unknown -> node(buildContext) { }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@
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.
* 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.
*/
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 ->
preferences[pinCodeKey] != null
}.first()
}
}
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
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
@@ -35,15 +37,30 @@ class PinUnlockNode @AssistedInject constructor(
private val presenter: PinUnlockPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onUnlock()
}
data class Inputs(
val isInAppUnlock: Boolean
) : NodeInputs
private val inputs: Inputs = inputs()
private fun onUnlock() {
plugins<Callback>().forEach {
it.onUnlock()
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LaunchedEffect(state.isUnlocked) {
if (state.isUnlocked) {
onUnlock()
}
}
PinUnlockView(
state = state,
isInAppUnlock = inputs.isInAppUnlock,

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
package io.element.android.features.lockscreen.impl.pin
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.libraries.cryptography.impl.AESEncryptionDecryptionService
@@ -32,10 +33,13 @@ class DefaultPinCodeManagerTest {
@Test
fun `given a pin code when create and delete assert no pin code left`() = runTest {
pinCodeManager.createPinCode("1234")
assertThat(pinCodeManager.isPinCodeAvailable()).isTrue()
pinCodeManager.deletePinCode()
assertThat(pinCodeManager.isPinCodeAvailable()).isFalse()
pinCodeManager.hasPinCode().test {
assertThat(awaitItem()).isFalse()
pinCodeManager.createPinCode("1234")
assertThat(awaitItem()).isTrue()
pinCodeManager.deletePinCode()
assertThat(awaitItem()).isFalse()
}
}
@Test

View File

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

View File

@@ -60,14 +60,14 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
state.onPinEntryChanged(halfCompletePin)
}
awaitItem().also { state ->
state.choosePinEntry.assertText(halfCompletePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
state.onPinEntryChanged(blacklistedPin)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(blacklistedPin)
@@ -77,7 +77,7 @@ class SetupPinPresenterTest {
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
state.onPinEntryChanged(completePin)
}
consumeItemsUntilPredicate {
it.isConfirmationStep
@@ -85,7 +85,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
state.onPinEntryChanged(mismatchedPin)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin)
@@ -98,7 +98,7 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isFalse()
assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
state.onPinEntryChanged(completePin)
}
consumeItemsUntilPredicate {
it.isConfirmationStep
@@ -106,7 +106,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
state.onPinEntryChanged(completePin)
}
awaitItem().also { state ->
state.choosePinEntry.assertText(completePin)
@@ -116,6 +116,10 @@ class SetupPinPresenterTest {
}
}
private fun SetupPinState.onPinEntryChanged(pinEntry: String){
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
}
private fun createSetupPinPresenter(
callback: PinCodeManager.Callback,
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.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -44,13 +43,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - success verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
val presenter = createPinUnlockPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -58,6 +51,7 @@ class PinUnlockPresenterTest {
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.isUnlocked).isFalse()
assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
}
@@ -77,20 +71,14 @@ class PinUnlockPresenterTest {
}
awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(completePin)
assertThat(state.isUnlocked).isTrue()
}
pinCodeVerified.await()
}
}
@Test
fun `present - failure verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
val presenter = createPinUnlockPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -160,6 +148,7 @@ class PinUnlockPresenterTest {
biometricUnlockManager = biometricUnlockManager,
matrixClient = FakeMatrixClient(),
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.LockScreenService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class FakeLockScreenService : LockScreenService {
private var isSetupRequired: Boolean = false
private var isPinSetup = MutableStateFlow(false)
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
override val lockState: StateFlow<LockScreenLockState> = _lockState
override suspend fun isSetupRequired(): Boolean {
return isSetupRequired
override fun isSetupRequired(): Flow<Boolean> {
return isPinSetup.map { !it }
}
fun setIsSetupRequired(isSetupRequired: Boolean) {
this.isSetupRequired = isSetupRequired
fun setIsPinSetup(isPinSetup: Boolean) {
this.isPinSetup.value = isPinSetup
}
override fun isPinSetup(): Flow<Boolean> {
return isPinSetup
}
fun setLockState(lockState: LockScreenLockState) {