Skip to content
Snippets Groups Projects
Commit 3ae7e4a9 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Navigate away from bouncer when the IME is hidden.

Note that we need to wait for the IME to show before we handle its
dismissal or we risk a race condition where the logic that checks if the
IME is closed runs before the IME gets a chance to be shown.

Fix: 299343836
Test: added unit tests
Test: manually verified that dismissing the keyboard in the password
bouncer also moves back to the lockscreen scene. Done with 3 button
navigation and with gesture-based navigation.
Test: manually verified that entering the correct password works to
unlock the device.

Change-Id: I77e8dd32abe776f9df42c4fd757de3d889df9bb2
parent 1560fc08
No related branches found
No related tags found
No related merge requests found
Showing
with 174 additions and 5 deletions
......@@ -17,8 +17,11 @@
package com.android.systemui.bouncer.ui.composable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
......@@ -29,6 +32,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
......@@ -44,6 +48,7 @@ import androidx.compose.ui.unit.dp
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
/** UI for the input part of a password-requiring version of the bouncer. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun PasswordBouncer(
viewModel: PasswordBouncerViewModel,
......@@ -54,6 +59,10 @@ internal fun PasswordBouncer(
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
val density = LocalDensity.current
val isImeVisible by rememberUpdatedState(WindowInsets.imeAnimationTarget.getBottom(density) > 0)
LaunchedEffect(isImeVisible) { viewModel.onImeVisibilityChanged(isImeVisible) }
LaunchedEffect(Unit) {
// When the UI comes up, request focus on the TextField to bring up the software keyboard.
focusRequester.requestFocus()
......
......@@ -225,6 +225,20 @@ constructor(
repository.setMessage(errorMessage(authenticationInteractor.getAuthenticationMethod()))
}
/** If the bouncer is showing, hides the bouncer and return to the lockscreen scene. */
fun hide(
loggingReason: String,
) {
if (sceneInteractor.desiredScene.value.key != SceneKey.Bouncer) {
return
}
sceneInteractor.changeScene(
scene = SceneModel(SceneKey.Lockscreen),
loggingReason = loggingReason,
)
}
private fun promptMessage(authMethod: AuthenticationMethodModel): String {
return when (authMethod) {
is AuthenticationMethodModel.Pin ->
......
......@@ -16,6 +16,7 @@
package com.android.systemui.bouncer.ui.viewmodel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
......@@ -28,6 +29,7 @@ sealed class AuthMethodBouncerViewModel(
* being able to attempt to unlock the device.
*/
val isInputEnabled: StateFlow<Boolean>,
private val interactor: BouncerInteractor,
) {
private val _animateFailure = MutableStateFlow(false)
......@@ -37,6 +39,9 @@ sealed class AuthMethodBouncerViewModel(
*/
val animateFailure: StateFlow<Boolean> = _animateFailure.asStateFlow()
/** Whether the input method editor (for example, the software keyboard) is visible. */
private var isImeVisible: Boolean = false
/**
* Notifies that the failure animation has been shown. This should be called to consume a `true`
* value in [animateFailure].
......@@ -45,6 +50,21 @@ sealed class AuthMethodBouncerViewModel(
_animateFailure.value = false
}
/**
* Notifies that the input method editor (for example, the software keyboard) has been shown or
* hidden.
*/
fun onImeVisibilityChanged(isVisible: Boolean) {
if (isImeVisible && !isVisible) {
// The IME has gone from visible to invisible, dismiss the bouncer.
interactor.hide(
loggingReason = "IME hidden",
)
}
isImeVisible = isVisible
}
/** Ask the UI to show the failure animation. */
protected fun showFailureAnimation() {
_animateFailure.value = true
......
......@@ -31,6 +31,7 @@ class PasswordBouncerViewModel(
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
interactor = interactor,
) {
private val _password = MutableStateFlow("")
......@@ -60,6 +61,10 @@ class PasswordBouncerViewModel(
/** Notifies that the user has pressed the key for attempting to authenticate the password. */
fun onAuthenticateKeyPressed() {
val password = _password.value.toCharArray().toList()
if (password.isEmpty()) {
return
}
_password.value = ""
applicationScope.launch {
......
......@@ -42,6 +42,7 @@ class PatternBouncerViewModel(
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
interactor = interactor,
) {
/** The number of columns in the dot grid. */
......
......@@ -37,6 +37,7 @@ class PinBouncerViewModel(
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
interactor = interactor,
) {
val pinShapes = PinShapeAdapter(applicationContext)
......
......@@ -353,6 +353,34 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(throttling).isEqualTo(AuthenticationThrottlingModel())
}
@Test
fun hide_whenOnBouncerScene_hidesBouncerAndGoesToLockscreenScene() =
testScope.runTest {
sceneInteractor.changeScene(SceneModel(SceneKey.Bouncer), "")
sceneInteractor.onSceneChanged(SceneModel(SceneKey.Bouncer), "")
val currentScene by collectLastValue(sceneInteractor.desiredScene)
val bouncerSceneKey = currentScene?.key
assertThat(bouncerSceneKey).isEqualTo(SceneKey.Bouncer)
underTest.hide("")
assertThat(currentScene?.key).isEqualTo(SceneKey.Lockscreen)
}
@Test
fun hide_whenNotOnBouncerScene_doesNothing() =
testScope.runTest {
sceneInteractor.changeScene(SceneModel(SceneKey.Shade), "")
sceneInteractor.onSceneChanged(SceneModel(SceneKey.Shade), "")
val currentScene by collectLastValue(sceneInteractor.desiredScene)
val notBouncerSceneKey = currentScene?.key
assertThat(notBouncerSceneKey).isNotEqualTo(SceneKey.Bouncer)
underTest.hide("")
assertThat(currentScene?.key).isEqualTo(notBouncerSceneKey)
}
private fun assertTryAgainMessage(
message: String?,
time: Int,
......
......@@ -22,6 +22,8 @@ import com.android.systemui.authentication.data.model.AuthenticationMethodModel
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.scene.SceneTestUtils
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.SceneModel
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
......@@ -39,6 +41,7 @@ class AuthMethodBouncerViewModelTest : SysuiTestCase() {
utils.authenticationInteractor(
utils.authenticationRepository(),
)
private val sceneInteractor = utils.sceneInteractor()
private val underTest =
PinBouncerViewModel(
applicationContext = context,
......@@ -46,7 +49,7 @@ class AuthMethodBouncerViewModelTest : SysuiTestCase() {
interactor =
utils.bouncerInteractor(
authenticationInteractor = authenticationInteractor,
sceneInteractor = utils.sceneInteractor(),
sceneInteractor = sceneInteractor,
),
isInputEnabled = MutableStateFlow(true),
)
......@@ -75,4 +78,22 @@ class AuthMethodBouncerViewModelTest : SysuiTestCase() {
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isFalse()
}
@Test
fun onImeVisibilityChanged() =
testScope.runTest {
val desiredScene by collectLastValue(sceneInteractor.desiredScene)
sceneInteractor.changeScene(SceneModel(SceneKey.Bouncer), "")
sceneInteractor.onSceneChanged(SceneModel(SceneKey.Bouncer), "")
assertThat(desiredScene?.key).isEqualTo(SceneKey.Bouncer)
underTest.onImeVisibilityChanged(false)
assertThat(desiredScene?.key).isEqualTo(SceneKey.Bouncer)
underTest.onImeVisibilityChanged(true)
assertThat(desiredScene?.key).isEqualTo(SceneKey.Bouncer)
underTest.onImeVisibilityChanged(false)
assertThat(desiredScene?.key).isEqualTo(SceneKey.Lockscreen)
}
}
......@@ -156,6 +156,29 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@Test
fun onAuthenticateKeyPressed_whenEmpty() =
testScope.runTest {
val currentScene by collectLastValue(sceneInteractor.desiredScene)
val message by collectLastValue(bouncerViewModel.message)
val password by collectLastValue(underTest.password)
utils.authenticationRepository.setAuthenticationMethod(
AuthenticationMethodModel.Password
)
utils.authenticationRepository.setUnlocked(false)
sceneInteractor.changeScene(SceneModel(SceneKey.Bouncer), "reason")
sceneInteractor.onSceneChanged(SceneModel(SceneKey.Bouncer), "reason")
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
underTest.onShown()
// Enter nothing.
underTest.onAuthenticateKeyPressed()
assertThat(password).isEqualTo("")
assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@Test
fun onAuthenticateKeyPressed_correctAfterWrong() =
testScope.runTest {
......
......@@ -22,7 +22,6 @@ import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.authentication.domain.model.AuthenticationMethodModel as DomainLayerAuthenticationMethodModel
import com.android.systemui.authentication.domain.model.AuthenticationMethodModel
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.keyguard.shared.model.WakefulnessState
......@@ -48,7 +47,9 @@ import com.android.systemui.util.mockito.mock
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
......@@ -155,6 +156,8 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
repository = keyguardRepository,
)
private var bouncerSceneJob: Job? = null
@Before
fun setUp() {
shadeHeaderViewModel =
......@@ -284,7 +287,7 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
@Test
fun withAuthMethodNone_deviceWakeUp_skipsLockscreen() =
testScope.runTest {
setAuthMethod(AuthenticationMethodModel.None)
setAuthMethod(DomainLayerAuthenticationMethodModel.None)
putDeviceToSleep(instantlyLockDevice = false)
assertCurrentScene(SceneKey.Lockscreen)
......@@ -295,7 +298,7 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
@Test
fun withAuthMethodSwipe_deviceWakeUp_doesNotSkipLockscreen() =
testScope.runTest {
setAuthMethod(AuthenticationMethodModel.Swipe)
setAuthMethod(DomainLayerAuthenticationMethodModel.Swipe)
putDeviceToSleep(instantlyLockDevice = false)
assertCurrentScene(SceneKey.Lockscreen)
......@@ -360,6 +363,23 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
assertCurrentScene(SceneKey.Lockscreen)
}
@Test
fun dismissingIme_whileOnPasswordBouncer_navigatesToLockscreen() =
testScope.runTest {
setAuthMethod(DomainLayerAuthenticationMethodModel.Password)
val upDestinationSceneKey by
collectLastValue(lockscreenSceneViewModel.upDestinationSceneKey)
assertThat(upDestinationSceneKey).isEqualTo(SceneKey.Bouncer)
emulateUserDrivenTransition(
to = upDestinationSceneKey,
)
dismissIme()
assertCurrentScene(SceneKey.Lockscreen)
emulateUiSceneTransition()
}
/**
* Asserts that the current scene in the view-model matches what's expected.
*
......@@ -392,7 +412,9 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
// Set the lockscreen enabled bit _before_ set the auth method as the code picks up on the
// lockscreen enabled bit _after_ the auth method is changed and the lockscreen enabled bit
// is not an observable that can trigger a new evaluation.
authenticationRepository.setLockscreenEnabled(authMethod !is AuthenticationMethodModel.None)
authenticationRepository.setLockscreenEnabled(
authMethod !is DomainLayerAuthenticationMethodModel.None
)
authenticationRepository.setAuthenticationMethod(authMethod.toDataLayer())
if (!authMethod.isSecure) {
// When the auth method is not secure, the device is never considered locked.
......@@ -450,6 +472,19 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
assertWithMessage("Visibility mismatch after scene transition from $from to ${to.key}!")
.that(sceneContainerViewModel.isVisible.value)
.isEqualTo(expectedVisible)
bouncerSceneJob =
if (to.key == SceneKey.Bouncer) {
testScope.backgroundScope.launch {
bouncerViewModel.authMethod.collect {
// Do nothing. Need this to turn this otherwise cold flow, hot.
}
}
} else {
bouncerSceneJob?.cancel()
null
}
runCurrent()
}
/**
......@@ -568,4 +603,16 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() {
lockDevice()
}
}
/** Emulates the dismissal of the IME (soft keyboard). */
private fun TestScope.dismissIme(
showImeBeforeDismissing: Boolean = true,
) {
if (showImeBeforeDismissing) {
bouncerViewModel.authMethod.value?.onImeVisibilityChanged(true)
}
bouncerViewModel.authMethod.value?.onImeVisibilityChanged(false)
runCurrent()
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment