diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt index 8a8557aa1f436fc4e94368ffe5c1e23587bfaff1..df22a7023ebfafe19caf9dfe758da1a883279015 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -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() diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt index 199036f09e9a10170599e50a748d758db3923d45..9b2f2baba94bde8fcec02e1c3229654331894a76 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -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 -> diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt index d95b70c85fe0f5f3f2781a4a5bf9374ccf8c7956..4546bea3b89b09271e9c2217b4a27ca810bf537a 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt @@ -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 diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt index d21479746744d91e8d2ae05f2c303ddbea2ee327..9e10f29a00f94c54c87bcbe38c8db160452ae938 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt @@ -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 { diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt index 1985c37e1d5da20ce64ece6803da134088c58a79..497276b479960d738e1b155e2e9f65dbd9164fa2 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt @@ -42,6 +42,7 @@ class PatternBouncerViewModel( ) : AuthMethodBouncerViewModel( isInputEnabled = isInputEnabled, + interactor = interactor, ) { /** The number of columns in the dot grid. */ diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt index dc5c5288df9f245642537b82dc319c66df4d113d..8e6421ed3f0ab1bc348d7b2917dc2aa26aa5ce60 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -37,6 +37,7 @@ class PinBouncerViewModel( ) : AuthMethodBouncerViewModel( isInputEnabled = isInputEnabled, + interactor = interactor, ) { val pinShapes = PinShapeAdapter(applicationContext) diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt index 6205c277bbd900c68973ab8b989178da8de096bf..77d8102fff2e4a4d40ef87a73f7594f6a8d1d42f 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -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, diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt index 7af8a042540203fe131eaf08ff62060bf4eee868..9011c2f296c3753694e57a164cb5640880c03cc7 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt @@ -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) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt index 8df29e4d4e585a2237a54cad5da00b9d4b8ba8c6..3375184c1cf6003f8401e2065840f63adb2ed210 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -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 { diff --git a/packages/SystemUI/tests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt b/packages/SystemUI/tests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt index 9c8d14de1c74d69cce4c7310e31ab9aced584741..6b918c6ba15b5c5ea40f5b83e7b101ab57f46046 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt @@ -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 @@ -159,6 +160,8 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() { repository = keyguardRepository, ) + private var bouncerSceneJob: Job? = null + @Before fun setUp() { shadeHeaderViewModel = @@ -288,7 +291,7 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() { @Test fun withAuthMethodNone_deviceWakeUp_skipsLockscreen() = testScope.runTest { - setAuthMethod(AuthenticationMethodModel.None) + setAuthMethod(DomainLayerAuthenticationMethodModel.None) putDeviceToSleep(instantlyLockDevice = false) assertCurrentScene(SceneKey.Lockscreen) @@ -299,7 +302,7 @@ class SceneFrameworkIntegrationTest : SysuiTestCase() { @Test fun withAuthMethodSwipe_deviceWakeUp_doesNotSkipLockscreen() = testScope.runTest { - setAuthMethod(AuthenticationMethodModel.Swipe) + setAuthMethod(DomainLayerAuthenticationMethodModel.Swipe) putDeviceToSleep(instantlyLockDevice = false) assertCurrentScene(SceneKey.Lockscreen) @@ -364,6 +367,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. * @@ -396,7 +416,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. @@ -455,6 +477,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() } /** @@ -573,4 +608,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() + } }