Merge "[flexiglass] Password bouncer beahvior fixes." into main
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 0b13383..eb06889 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
@@ -16,12 +16,10 @@
package com.android.systemui.bouncer.ui.composable
+import android.view.ViewTreeObserver
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
@@ -30,46 +28,56 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
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
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowInsetsCompat
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,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
+ val isTextFieldFocusRequested by viewModel.isTextFieldFocusRequested.collectAsState()
+ LaunchedEffect(isTextFieldFocusRequested) {
+ if (isTextFieldFocusRequested) {
+ focusRequester.requestFocus()
+ }
+ }
+ val (isTextFieldFocused, onTextFieldFocusChanged) = remember { mutableStateOf(false) }
+ LaunchedEffect(isTextFieldFocused) {
+ viewModel.onTextFieldFocusChanged(isFocused = isTextFieldFocused)
+ }
+
val password: String by viewModel.password.collectAsState()
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)
+ val isImeVisible by isSoftwareKeyboardVisible()
LaunchedEffect(isImeVisible) { viewModel.onImeVisibilityChanged(isImeVisible) }
DisposableEffect(Unit) {
viewModel.onShown()
-
- // When the UI comes up, request focus on the TextField to bring up the software keyboard.
- focusRequester.requestFocus()
-
onDispose { viewModel.onHidden() }
}
@@ -104,16 +112,39 @@
onDone = { viewModel.onAuthenticateKeyPressed() },
),
modifier =
- Modifier.focusRequester(focusRequester).drawBehind {
- drawLine(
- color = color,
- start = Offset(x = 0f, y = size.height - lineWidthPx),
- end = Offset(size.width, y = size.height - lineWidthPx),
- strokeWidth = lineWidthPx,
- )
- },
+ Modifier.focusRequester(focusRequester)
+ .onFocusChanged { onTextFieldFocusChanged(it.isFocused) }
+ .drawBehind {
+ drawLine(
+ color = color,
+ start = Offset(x = 0f, y = size.height - lineWidthPx),
+ end = Offset(size.width, y = size.height - lineWidthPx),
+ strokeWidth = lineWidthPx,
+ )
+ },
)
Spacer(Modifier.height(100.dp))
}
}
+
+/** Returns a [State] with `true` when the IME/keyboard is visible and `false` when it's not. */
+@Composable
+fun isSoftwareKeyboardVisible(): State<Boolean> {
+ val view = LocalView.current
+ val viewTreeObserver = view.viewTreeObserver
+
+ return produceState(
+ initialValue = false,
+ key1 = viewTreeObserver,
+ ) {
+ val listener =
+ ViewTreeObserver.OnGlobalLayoutListener {
+ value = view.rootWindowInsets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
+ }
+
+ viewTreeObserver.addOnGlobalLayoutListener(listener)
+
+ awaitDispose { viewTreeObserver.removeOnGlobalLayoutListener(listener) }
+ }
+}
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 b598631..7c46339 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
@@ -105,9 +105,9 @@
val isUserSwitcherVisible: Boolean
get() = repository.isUserSwitcherVisible
- private val _onImeHidden = MutableSharedFlow<Unit>()
- /** Provide the onImeHidden events from the bouncer */
- val onImeHidden: SharedFlow<Unit> = _onImeHidden
+ private val _onImeHiddenByUser = MutableSharedFlow<Unit>()
+ /** Emits a [Unit] each time the IME (keyboard) is hidden by the user. */
+ val onImeHiddenByUser: SharedFlow<Unit> = _onImeHiddenByUser
init {
if (flags.isEnabled()) {
@@ -230,9 +230,9 @@
repository.setMessage(errorMessage(authenticationInteractor.getAuthenticationMethod()))
}
- /** Notifies the interactor that the input method editor has been hidden. */
- suspend fun onImeHidden() {
- _onImeHidden.emit(Unit)
+ /** Notifies that the input method editor (software keyboard) has been hidden by the user. */
+ suspend fun onImeHiddenByUser() {
+ _onImeHiddenByUser.emit(Unit)
}
private fun promptMessage(authMethod: AuthenticationMethodModel): String {
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 8024874..e379dab 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
@@ -46,9 +46,6 @@
*/
val animateFailure: StateFlow<Boolean> = _animateFailure.asStateFlow()
- /** Whether the input method editor (for example, the software keyboard) is visible. */
- private var isImeVisible: Boolean = false
-
/** The authentication method that corresponds to this view model. */
abstract val authenticationMethod: AuthenticationMethodModel
@@ -68,7 +65,7 @@
/**
* Notifies that the UI has been hidden from the user (after any transitions have completed).
*/
- fun onHidden() {
+ open fun onHidden() {
clearInput()
interactor.resetMessage()
}
@@ -79,18 +76,6 @@
}
/**
- * Notifies that the input method editor (for example, the software keyboard) has been shown or
- * hidden.
- */
- suspend fun onImeVisibilityChanged(isVisible: Boolean) {
- if (isImeVisible && !isVisible) {
- interactor.onImeHidden()
- }
-
- isImeVisible = isVisible
- }
-
- /**
* Notifies that the failure animation has been shown. This should be called to consume a `true`
* value in [animateFailure].
*/
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 a15698e..45d18128 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
@@ -21,8 +21,11 @@
import com.android.systemui.res.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
/** Holds UI state and handles user input for the password bouncer UI. */
class PasswordBouncerViewModel(
@@ -45,6 +48,32 @@
override val throttlingMessageId = R.string.kg_too_many_failed_password_attempts_dialog_message
+ /** Whether the input method editor (for example, the software keyboard) is visible. */
+ private var isImeVisible: Boolean = false
+
+ /** Whether the text field element currently has focus. */
+ private val isTextFieldFocused = MutableStateFlow(false)
+
+ /** Whether the UI should request focus on the text field element. */
+ val isTextFieldFocusRequested =
+ combine(
+ interactor.isThrottled,
+ isTextFieldFocused,
+ ) { isThrottled, hasFocus ->
+ !isThrottled && !hasFocus
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = !interactor.isThrottled.value && !isTextFieldFocused.value,
+ )
+
+ override fun onHidden() {
+ super.onHidden()
+ isImeVisible = false
+ isTextFieldFocused.value = false
+ }
+
override fun clearInput() {
_password.value = ""
}
@@ -72,4 +101,21 @@
tryAuthenticate()
}
}
+
+ /**
+ * Notifies that the input method editor (for example, the software keyboard) has been shown or
+ * hidden.
+ */
+ suspend fun onImeVisibilityChanged(isVisible: Boolean) {
+ if (isImeVisible && !isVisible && !interactor.isThrottled.value) {
+ interactor.onImeHiddenByUser()
+ }
+
+ isImeVisible = isVisible
+ }
+
+ /** Notifies that the password text field has gained or lost focus. */
+ fun onTextFieldFocusChanged(isFocused: Boolean) {
+ isTextFieldFocused.value = isFocused
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt
index 1c5330e..d42fde6 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/SceneContainerStartable.kt
@@ -128,7 +128,7 @@
private fun automaticallySwitchScenes() {
applicationScope.launch {
// TODO (b/308001302): Move this to a bouncer specific interactor.
- bouncerInteractor.onImeHidden.collectLatest {
+ bouncerInteractor.onImeHiddenByUser.collectLatest {
if (sceneInteractor.desiredScene.value.key == SceneKey.Bouncer) {
sceneInteractor.changeScene(
scene = SceneModel(SceneKey.Lockscreen),
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 1e80732..83fb17f 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
@@ -319,10 +319,10 @@
@Test
fun imeHiddenEvent_isTriggered() =
testScope.runTest {
- val imeHiddenEvent by collectLastValue(underTest.onImeHidden)
+ val imeHiddenEvent by collectLastValue(underTest.onImeHiddenByUser)
runCurrent()
- underTest.onImeHidden()
+ underTest.onImeHiddenByUser()
runCurrent()
assertThat(imeHiddenEvent).isNotNull()
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 63c992b..45c186d 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
@@ -23,8 +23,6 @@
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
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
@@ -76,18 +74,4 @@
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isFalse()
}
-
- @Test
- fun onImeVisibilityChanged() =
- testScope.runTest {
- sceneInteractor.changeScene(SceneModel(SceneKey.Bouncer), "")
- sceneInteractor.onSceneChanged(SceneModel(SceneKey.Bouncer), "")
- val onImeHidden by collectLastValue(bouncerInteractor.onImeHidden)
-
- underTest.onImeVisibilityChanged(true)
- assertThat(onImeHidden).isNull()
-
- underTest.onImeVisibilityChanged(false)
- assertThat(onImeHidden).isNotNull()
- }
}
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 9b1e958..937c703 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
@@ -20,7 +20,9 @@
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
+import com.android.systemui.authentication.shared.model.AuthenticationThrottlingModel
import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.coroutines.collectValues
import com.android.systemui.res.R
import com.android.systemui.scene.SceneTestUtils
import com.android.systemui.scene.shared.model.SceneKey
@@ -43,7 +45,11 @@
private val utils = SceneTestUtils(this)
private val testScope = utils.testScope
- private val authenticationInteractor = utils.authenticationInteractor()
+ private val authenticationRepository = utils.authenticationRepository
+ private val authenticationInteractor =
+ utils.authenticationInteractor(
+ repository = authenticationRepository,
+ )
private val sceneInteractor = utils.sceneInteractor()
private val bouncerInteractor =
utils.bouncerInteractor(
@@ -207,6 +213,101 @@
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
+ @Test
+ fun onImeVisibilityChanged_false_doesNothing() =
+ testScope.runTest {
+ val events by collectValues(bouncerInteractor.onImeHiddenByUser)
+ assertThat(events).isEmpty()
+
+ underTest.onImeVisibilityChanged(isVisible = false)
+ assertThat(events).isEmpty()
+ }
+
+ @Test
+ fun onImeVisibilityChanged_falseAfterTrue_emitsOnImeHiddenByUserEvent() =
+ testScope.runTest {
+ val events by collectValues(bouncerInteractor.onImeHiddenByUser)
+ assertThat(events).isEmpty()
+
+ underTest.onImeVisibilityChanged(isVisible = true)
+ assertThat(events).isEmpty()
+
+ underTest.onImeVisibilityChanged(isVisible = false)
+ assertThat(events).hasSize(1)
+
+ underTest.onImeVisibilityChanged(isVisible = true)
+ assertThat(events).hasSize(1)
+
+ underTest.onImeVisibilityChanged(isVisible = false)
+ assertThat(events).hasSize(2)
+ }
+
+ @Test
+ fun onImeVisibilityChanged_falseAfterTrue_whileThrottling_doesNothing() =
+ testScope.runTest {
+ val events by collectValues(bouncerInteractor.onImeHiddenByUser)
+ assertThat(events).isEmpty()
+ underTest.onImeVisibilityChanged(isVisible = true)
+ setThrottling(true)
+
+ underTest.onImeVisibilityChanged(isVisible = false)
+
+ assertThat(events).isEmpty()
+ }
+
+ @Test
+ fun isTextFieldFocusRequested_initiallyTrue() =
+ testScope.runTest {
+ val isTextFieldFocusRequested by collectLastValue(underTest.isTextFieldFocusRequested)
+ assertThat(isTextFieldFocusRequested).isTrue()
+ }
+
+ @Test
+ fun isTextFieldFocusRequested_focusGained_becomesFalse() =
+ testScope.runTest {
+ val isTextFieldFocusRequested by collectLastValue(underTest.isTextFieldFocusRequested)
+
+ underTest.onTextFieldFocusChanged(isFocused = true)
+
+ assertThat(isTextFieldFocusRequested).isFalse()
+ }
+
+ @Test
+ fun isTextFieldFocusRequested_focusLost_becomesTrue() =
+ testScope.runTest {
+ val isTextFieldFocusRequested by collectLastValue(underTest.isTextFieldFocusRequested)
+ underTest.onTextFieldFocusChanged(isFocused = true)
+
+ underTest.onTextFieldFocusChanged(isFocused = false)
+
+ assertThat(isTextFieldFocusRequested).isTrue()
+ }
+
+ @Test
+ fun isTextFieldFocusRequested_focusLostWhileThrottling_staysFalse() =
+ testScope.runTest {
+ val isTextFieldFocusRequested by collectLastValue(underTest.isTextFieldFocusRequested)
+ underTest.onTextFieldFocusChanged(isFocused = true)
+ setThrottling(true)
+
+ underTest.onTextFieldFocusChanged(isFocused = false)
+
+ assertThat(isTextFieldFocusRequested).isFalse()
+ }
+
+ @Test
+ fun isTextFieldFocusRequested_throttlingCountdownEnds_becomesTrue() =
+ testScope.runTest {
+ val isTextFieldFocusRequested by collectLastValue(underTest.isTextFieldFocusRequested)
+ underTest.onTextFieldFocusChanged(isFocused = true)
+ setThrottling(true)
+ underTest.onTextFieldFocusChanged(isFocused = false)
+
+ setThrottling(false)
+
+ assertThat(isTextFieldFocusRequested).isTrue()
+ }
+
private fun TestScope.switchToScene(toScene: SceneKey) {
val currentScene by collectLastValue(sceneInteractor.desiredScene)
val bouncerShown = currentScene?.key != SceneKey.Bouncer && toScene == SceneKey.Bouncer
@@ -226,6 +327,35 @@
switchToScene(SceneKey.Bouncer)
}
+ private suspend fun TestScope.setThrottling(
+ isThrottling: Boolean,
+ failedAttemptCount: Int = 5,
+ ) {
+ if (isThrottling) {
+ repeat(failedAttemptCount) {
+ authenticationRepository.reportAuthenticationAttempt(false)
+ }
+ val remainingTimeMs = 30_000
+ authenticationRepository.setThrottleDuration(remainingTimeMs)
+ authenticationRepository.setThrottling(
+ AuthenticationThrottlingModel(
+ failedAttemptCount = failedAttemptCount,
+ remainingMs = remainingTimeMs,
+ )
+ )
+ } else {
+ authenticationRepository.reportAuthenticationAttempt(true)
+ authenticationRepository.setThrottling(
+ AuthenticationThrottlingModel(
+ failedAttemptCount = failedAttemptCount,
+ remainingMs = 0,
+ )
+ )
+ }
+
+ runCurrent()
+ }
+
companion object {
private const val ENTER_YOUR_PASSWORD = "Enter your password"
private const val WRONG_PASSWORD = "Wrong password"
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 c953743..18b7168 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
@@ -30,6 +30,7 @@
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerActionButtonInteractor
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
+import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.flags.Flags
@@ -777,11 +778,11 @@
private suspend fun TestScope.dismissIme(
showImeBeforeDismissing: Boolean = true,
) {
- bouncerViewModel.authMethodViewModel.value?.apply {
+ (bouncerViewModel.authMethodViewModel.value as? PasswordBouncerViewModel)?.let {
if (showImeBeforeDismissing) {
- onImeVisibilityChanged(true)
+ it.onImeVisibilityChanged(true)
}
- onImeVisibilityChanged(false)
+ it.onImeVisibilityChanged(false)
runCurrent()
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt b/packages/SystemUI/tests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt
index adc1c61..3cb97e3 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt
@@ -479,7 +479,7 @@
underTest.start()
runCurrent()
- bouncerInteractor.onImeHidden()
+ bouncerInteractor.onImeHiddenByUser()
runCurrent()
assertThat(currentSceneKey).isEqualTo(SceneKey.Lockscreen)