Loading packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +3 −2 Original line number Diff line number Diff line Loading @@ -48,13 +48,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.SceneScope import com.android.systemui.res.R import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.dagger.SysUISingleton import com.android.systemui.res.R import com.android.systemui.scene.shared.model.Direction import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel Loading Loading @@ -104,7 +104,8 @@ private fun SceneScope.BouncerScene( modifier: Modifier = Modifier, ) { val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState() val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState() val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethodViewModel.collectAsState() val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState() var dialog: Dialog? by remember { mutableStateOf(null) } val backgroundColor = MaterialTheme.colorScheme.surface Loading packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt +34 −13 Original line number Diff line number Diff line Loading @@ -185,9 +185,6 @@ constructor( /** Whether the pattern should be visible for the currently-selected user. */ val isPatternVisible: StateFlow<Boolean> = repository.isPatternVisible /** The minimal length of a pattern. */ val minPatternLength: Int = repository.minPatternLength private var throttlingCountdownJob: Job? = null init { Loading Loading @@ -243,39 +240,46 @@ constructor( * Attempts to authenticate the user and unlock the device. * * If [tryAutoConfirm] is `true`, authentication is attempted if and only if the auth method * supports auto-confirming, and the input's length is at least the code's length. Otherwise, * `null` is returned. * supports auto-confirming, and the input's length is at least the required length. Otherwise, * `AuthenticationResult.SKIPPED` is returned. * * @param input The input from the user to try to authenticate with. This can be a list of * different things, based on the current authentication method. * @param tryAutoConfirm `true` if called while the user inputs the code, without an explicit * request to validate. * @return `true` if the authentication succeeded and the device is now unlocked; `false` when * authentication failed, `null` if the check was not performed. * @return The result of this authentication attempt. */ suspend fun authenticate(input: List<Any>, tryAutoConfirm: Boolean = false): Boolean? { suspend fun authenticate( input: List<Any>, tryAutoConfirm: Boolean = false ): AuthenticationResult { if (input.isEmpty()) { throw IllegalArgumentException("Input was empty!") } val authMethod = getAuthenticationMethod() val skipCheck = when { // We're being throttled, the UI layer should not have called this; skip the // attempt. isThrottled.value -> true // The pattern is too short; skip the attempt. authMethod == DomainLayerAuthenticationMethodModel.Pattern && input.size < repository.minPatternLength -> true // Auto-confirm attempt when the feature is not enabled; skip the attempt. tryAutoConfirm && !isAutoConfirmEnabled.value -> true // Auto-confirm should skip the attempt if the pin entered is too short. tryAutoConfirm && input.size < repository.getPinLength() -> true tryAutoConfirm && authMethod == DomainLayerAuthenticationMethodModel.Pin && input.size < repository.getPinLength() -> true else -> false } if (skipCheck) { return null return AuthenticationResult.SKIPPED } // Attempt to authenticate: val authMethod = getAuthenticationMethod() val credential = authMethod.createCredential(input) ?: return null val credential = authMethod.createCredential(input) ?: return AuthenticationResult.SKIPPED val authenticationResult = repository.checkCredential(credential) credential.zeroize() Loading @@ -299,7 +303,11 @@ constructor( refreshThrottling() } return authenticationResult.isSuccessful return if (authenticationResult.isSuccessful) { AuthenticationResult.SUCCEEDED } else { AuthenticationResult.FAILED } } /** Starts refreshing the throttling state every second. */ Loading Loading @@ -383,3 +391,16 @@ constructor( } } } /** Result of a user authentication attempt. */ enum class AuthenticationResult { /** Authentication succeeded and the device is now unlocked. */ SUCCEEDED, /** Authentication failed and the device remains unlocked. */ FAILED, /** * Authentication was not performed, e.g. due to insufficient input, and the device remains * unlocked. */ SKIPPED, } packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +32 −22 Original line number Diff line number Diff line Loading @@ -17,8 +17,8 @@ package com.android.systemui.bouncer.domain.interactor import android.content.Context import com.android.systemui.res.R import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.domain.interactor.AuthenticationResult import com.android.systemui.authentication.domain.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationThrottlingModel import com.android.systemui.bouncer.data.repository.BouncerRepository Loading @@ -26,6 +26,7 @@ import com.android.systemui.classifier.FalsingClassifier import com.android.systemui.classifier.domain.interactor.FalsingInteractor import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.res.R import com.android.systemui.scene.domain.interactor.SceneInteractor import com.android.systemui.scene.shared.flag.SceneContainerFlags import com.android.systemui.scene.shared.model.SceneKey Loading @@ -34,6 +35,7 @@ import com.android.systemui.util.kotlin.pairwise import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine Loading Loading @@ -92,9 +94,6 @@ constructor( /** Whether the pattern should be visible for the currently-selected user. */ val isPatternVisible: StateFlow<Boolean> = authenticationInteractor.isPatternVisible /** The minimal length of a pattern. */ val minPatternLength = authenticationInteractor.minPatternLength init { if (flags.isEnabled()) { // Clear the message if moved from throttling to no-longer throttling. Loading Loading @@ -184,33 +183,44 @@ constructor( * dismissed and hidden. * * If [tryAutoConfirm] is `true`, authentication is attempted if and only if the auth method * supports auto-confirming, and the input's length is at least the code's length. Otherwise, * `null` is returned. * supports auto-confirming, and the input's length is at least the required length. Otherwise, * `AuthenticationResult.SKIPPED` is returned. * * @param input The input from the user to try to authenticate with. This can be a list of * different things, based on the current authentication method. * @param tryAutoConfirm `true` if called while the user inputs the code, without an explicit * request to validate. * @return `true` if the authentication succeeded and the device is now unlocked; `false` when * authentication failed, `null` if the check was not performed. * @return The result of this authentication attempt. */ suspend fun authenticate( input: List<Any>, tryAutoConfirm: Boolean = false, ): Boolean? { val isAuthenticated = authenticationInteractor.authenticate(input, tryAutoConfirm) ?: return null if (isAuthenticated) { ): AuthenticationResult { if (input.isEmpty()) { return AuthenticationResult.SKIPPED } // Switching to the application scope here since this method is often called from // view-models, whose lifecycle (and thus scope) is shorter than this interactor. // This allows the task to continue running properly even when the calling scope has been // cancelled. return applicationScope .async { val authResult = authenticationInteractor.authenticate(input, tryAutoConfirm) when (authResult) { // Authentication succeeded. AuthenticationResult.SUCCEEDED -> sceneInteractor.changeScene( scene = SceneModel(SceneKey.Gone), loggingReason = "successful authentication", ) } else { showErrorMessage() // Authentication failed. AuthenticationResult.FAILED -> showErrorMessage() // Authentication skipped. AuthenticationResult.SKIPPED -> if (!tryAutoConfirm) showErrorMessage() } return isAuthenticated authResult } .await() } /** Loading @@ -221,7 +231,7 @@ constructor( * For example, if the user entered a pattern that's too short, the system can show the error * message without having the attempt trigger throttling. */ suspend fun showErrorMessage() { private suspend fun showErrorMessage() { repository.setMessage(errorMessage(authenticationInteractor.getAuthenticationMethod())) } Loading packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt +60 −8 Original line number Diff line number Diff line Loading @@ -16,12 +16,21 @@ package com.android.systemui.bouncer.ui.viewmodel import android.annotation.StringRes import android.util.Log import com.android.systemui.authentication.domain.interactor.AuthenticationResult import com.android.systemui.authentication.domain.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed class AuthMethodBouncerViewModel( protected val viewModelScope: CoroutineScope, protected val interactor: BouncerInteractor, /** * Whether user input is enabled. * Loading @@ -29,7 +38,6 @@ sealed class AuthMethodBouncerViewModel( * being able to attempt to unlock the device. */ val isInputEnabled: StateFlow<Boolean>, private val interactor: BouncerInteractor, ) { private val _animateFailure = MutableStateFlow(false) Loading @@ -42,12 +50,26 @@ sealed class AuthMethodBouncerViewModel( /** 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 /** * Notifies that the failure animation has been shown. This should be called to consume a `true` * value in [animateFailure]. * String resource ID of the failure message to be shown during throttling. * * The message must include 2 number parameters: the first one indicating how many unsuccessful * attempts were made, and the second one indicating in how many seconds throttling will expire. */ fun onFailureAnimationShown() { _animateFailure.value = false @get:StringRes abstract val throttlingMessageId: Int /** Notifies that the UI has been shown to the user. */ fun onShown() { clearInput() interactor.resetMessage() } /** Notifies that the user has placed down a pointer. */ fun onDown() { interactor.onDown() } /** Loading @@ -65,8 +87,38 @@ sealed class AuthMethodBouncerViewModel( isImeVisible = isVisible } /** Ask the UI to show the failure animation. */ protected fun showFailureAnimation() { _animateFailure.value = true /** * Notifies that the failure animation has been shown. This should be called to consume a `true` * value in [animateFailure]. */ fun onFailureAnimationShown() { _animateFailure.value = false } /** Clears any previously-entered input. */ protected abstract fun clearInput() /** Returns the input entered so far. */ protected abstract fun getInput(): List<Any> /** * Attempts to authenticate the user using the current input value. * * @see BouncerInteractor.authenticate */ protected fun tryAuthenticate(useAutoConfirm: Boolean = false) { viewModelScope.launch { Log.d("Danny", "tryAuthenticate(useAutoConfirm=$useAutoConfirm)") val authenticationResult = interactor.authenticate(getInput(), useAutoConfirm) Log.d("Danny", "result = $authenticationResult") if (authenticationResult == AuthenticationResult.SKIPPED && useAutoConfirm) { return@launch } _animateFailure.value = authenticationResult != AuthenticationResult.SUCCEEDED // TODO(b/291528545): On success, this should only be cleared after the view is animated // away). clearInput() } } } packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +68 −65 Original line number Diff line number Diff line Loading @@ -17,16 +17,19 @@ package com.android.systemui.bouncer.ui.viewmodel import android.content.Context import com.android.systemui.res.R import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.domain.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.scene.shared.flag.SceneContainerFlags import javax.inject.Inject import kotlin.math.ceil import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow Loading @@ -35,6 +38,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.job import kotlinx.coroutines.launch /** Holds UI state and handles user input on bouncer UIs. */ Loading @@ -44,8 +48,9 @@ class BouncerViewModel constructor( @Application private val applicationContext: Context, @Application private val applicationScope: CoroutineScope, @Main private val mainDispatcher: CoroutineDispatcher, private val bouncerInteractor: BouncerInteractor, private val authenticationInteractor: AuthenticationInteractor, authenticationInteractor: AuthenticationInteractor, flags: SceneContainerFlags, ) { private val isInputEnabled: StateFlow<Boolean> = Loading @@ -57,91 +62,45 @@ constructor( initialValue = !bouncerInteractor.isThrottled.value, ) private val pin: PinBouncerViewModel by lazy { PinBouncerViewModel( applicationContext = applicationContext, applicationScope = applicationScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) } private val password: PasswordBouncerViewModel by lazy { PasswordBouncerViewModel( applicationScope = applicationScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) } private val pattern: PatternBouncerViewModel by lazy { PatternBouncerViewModel( applicationContext = applicationContext, applicationScope = applicationScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) } /** View-model for the current UI, based on the current authentication method. */ val authMethod: StateFlow<AuthMethodBouncerViewModel?> = val authMethodViewModel: StateFlow<AuthMethodBouncerViewModel?> = authenticationInteractor.authenticationMethod .map { authenticationMethod -> when (authenticationMethod) { is AuthenticationMethodModel.Pin -> pin is AuthenticationMethodModel.Password -> password is AuthenticationMethodModel.Pattern -> pattern else -> null } } .map(::getChildViewModel) .stateIn( scope = applicationScope, started = SharingStarted.WhileSubscribed(), initialValue = null, ) // Handle to the scope of the child ViewModel (stored in [authMethod]). private var childViewModelScope: CoroutineScope? = null init { if (flags.isEnabled()) { applicationScope.launch { bouncerInteractor.isThrottled .map { isThrottled -> if (isThrottled) { when (authenticationInteractor.getAuthenticationMethod()) { is AuthenticationMethodModel.Pin -> R.string.kg_too_many_failed_pin_attempts_dialog_message is AuthenticationMethodModel.Password -> R.string.kg_too_many_failed_password_attempts_dialog_message is AuthenticationMethodModel.Pattern -> R.string.kg_too_many_failed_pattern_attempts_dialog_message else -> null }?.let { stringResourceId -> combine(bouncerInteractor.isThrottled, authMethodViewModel) { isThrottled, authMethodViewModel -> if (isThrottled && authMethodViewModel != null) { applicationContext.getString( stringResourceId, authMethodViewModel.throttlingMessageId, bouncerInteractor.throttling.value.failedAttemptCount, ceil(bouncerInteractor.throttling.value.remainingMs / 1000f) .toInt(), ) } } else { null } } .distinctUntilChanged() .collect { dialogMessageOrNull -> if (dialogMessageOrNull != null) { _throttlingDialogMessage.value = dialogMessageOrNull } } .collect { dialogMessage -> _throttlingDialogMessage.value = dialogMessage } } } } /** The user-facing message to show in the bouncer. */ val message: StateFlow<MessageViewModel> = combine( bouncerInteractor.message, bouncerInteractor.isThrottled, ) { message, isThrottled -> combine(bouncerInteractor.message, bouncerInteractor.isThrottled) { message, isThrottled -> toMessageViewModel(message, isThrottled) } .stateIn( Loading Loading @@ -186,6 +145,50 @@ constructor( ) } private fun getChildViewModel( authenticationMethod: AuthenticationMethodModel, ): AuthMethodBouncerViewModel? { // If the current child view-model matches the authentication method, reuse it instead of // creating a new instance. val childViewModel = authMethodViewModel.value if (authenticationMethod == childViewModel?.authenticationMethod) { return childViewModel } childViewModelScope?.cancel() val newViewModelScope = createChildCoroutineScope(applicationScope) childViewModelScope = newViewModelScope return when (authenticationMethod) { is AuthenticationMethodModel.Pin -> PinBouncerViewModel( applicationContext = applicationContext, viewModelScope = newViewModelScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) is AuthenticationMethodModel.Password -> PasswordBouncerViewModel( viewModelScope = newViewModelScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) is AuthenticationMethodModel.Pattern -> PatternBouncerViewModel( applicationContext = applicationContext, viewModelScope = newViewModelScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) else -> null } } private fun createChildCoroutineScope(parentScope: CoroutineScope): CoroutineScope { return CoroutineScope( SupervisorJob(parent = parentScope.coroutineContext.job) + mainDispatcher ) } data class MessageViewModel( val text: String, Loading Loading
packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +3 −2 Original line number Diff line number Diff line Loading @@ -48,13 +48,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.SceneScope import com.android.systemui.res.R import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.dagger.SysUISingleton import com.android.systemui.res.R import com.android.systemui.scene.shared.model.Direction import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel Loading Loading @@ -104,7 +104,8 @@ private fun SceneScope.BouncerScene( modifier: Modifier = Modifier, ) { val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState() val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState() val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethodViewModel.collectAsState() val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState() var dialog: Dialog? by remember { mutableStateOf(null) } val backgroundColor = MaterialTheme.colorScheme.surface Loading
packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt +34 −13 Original line number Diff line number Diff line Loading @@ -185,9 +185,6 @@ constructor( /** Whether the pattern should be visible for the currently-selected user. */ val isPatternVisible: StateFlow<Boolean> = repository.isPatternVisible /** The minimal length of a pattern. */ val minPatternLength: Int = repository.minPatternLength private var throttlingCountdownJob: Job? = null init { Loading Loading @@ -243,39 +240,46 @@ constructor( * Attempts to authenticate the user and unlock the device. * * If [tryAutoConfirm] is `true`, authentication is attempted if and only if the auth method * supports auto-confirming, and the input's length is at least the code's length. Otherwise, * `null` is returned. * supports auto-confirming, and the input's length is at least the required length. Otherwise, * `AuthenticationResult.SKIPPED` is returned. * * @param input The input from the user to try to authenticate with. This can be a list of * different things, based on the current authentication method. * @param tryAutoConfirm `true` if called while the user inputs the code, without an explicit * request to validate. * @return `true` if the authentication succeeded and the device is now unlocked; `false` when * authentication failed, `null` if the check was not performed. * @return The result of this authentication attempt. */ suspend fun authenticate(input: List<Any>, tryAutoConfirm: Boolean = false): Boolean? { suspend fun authenticate( input: List<Any>, tryAutoConfirm: Boolean = false ): AuthenticationResult { if (input.isEmpty()) { throw IllegalArgumentException("Input was empty!") } val authMethod = getAuthenticationMethod() val skipCheck = when { // We're being throttled, the UI layer should not have called this; skip the // attempt. isThrottled.value -> true // The pattern is too short; skip the attempt. authMethod == DomainLayerAuthenticationMethodModel.Pattern && input.size < repository.minPatternLength -> true // Auto-confirm attempt when the feature is not enabled; skip the attempt. tryAutoConfirm && !isAutoConfirmEnabled.value -> true // Auto-confirm should skip the attempt if the pin entered is too short. tryAutoConfirm && input.size < repository.getPinLength() -> true tryAutoConfirm && authMethod == DomainLayerAuthenticationMethodModel.Pin && input.size < repository.getPinLength() -> true else -> false } if (skipCheck) { return null return AuthenticationResult.SKIPPED } // Attempt to authenticate: val authMethod = getAuthenticationMethod() val credential = authMethod.createCredential(input) ?: return null val credential = authMethod.createCredential(input) ?: return AuthenticationResult.SKIPPED val authenticationResult = repository.checkCredential(credential) credential.zeroize() Loading @@ -299,7 +303,11 @@ constructor( refreshThrottling() } return authenticationResult.isSuccessful return if (authenticationResult.isSuccessful) { AuthenticationResult.SUCCEEDED } else { AuthenticationResult.FAILED } } /** Starts refreshing the throttling state every second. */ Loading Loading @@ -383,3 +391,16 @@ constructor( } } } /** Result of a user authentication attempt. */ enum class AuthenticationResult { /** Authentication succeeded and the device is now unlocked. */ SUCCEEDED, /** Authentication failed and the device remains unlocked. */ FAILED, /** * Authentication was not performed, e.g. due to insufficient input, and the device remains * unlocked. */ SKIPPED, }
packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +32 −22 Original line number Diff line number Diff line Loading @@ -17,8 +17,8 @@ package com.android.systemui.bouncer.domain.interactor import android.content.Context import com.android.systemui.res.R import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.domain.interactor.AuthenticationResult import com.android.systemui.authentication.domain.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationThrottlingModel import com.android.systemui.bouncer.data.repository.BouncerRepository Loading @@ -26,6 +26,7 @@ import com.android.systemui.classifier.FalsingClassifier import com.android.systemui.classifier.domain.interactor.FalsingInteractor import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.res.R import com.android.systemui.scene.domain.interactor.SceneInteractor import com.android.systemui.scene.shared.flag.SceneContainerFlags import com.android.systemui.scene.shared.model.SceneKey Loading @@ -34,6 +35,7 @@ import com.android.systemui.util.kotlin.pairwise import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine Loading Loading @@ -92,9 +94,6 @@ constructor( /** Whether the pattern should be visible for the currently-selected user. */ val isPatternVisible: StateFlow<Boolean> = authenticationInteractor.isPatternVisible /** The minimal length of a pattern. */ val minPatternLength = authenticationInteractor.minPatternLength init { if (flags.isEnabled()) { // Clear the message if moved from throttling to no-longer throttling. Loading Loading @@ -184,33 +183,44 @@ constructor( * dismissed and hidden. * * If [tryAutoConfirm] is `true`, authentication is attempted if and only if the auth method * supports auto-confirming, and the input's length is at least the code's length. Otherwise, * `null` is returned. * supports auto-confirming, and the input's length is at least the required length. Otherwise, * `AuthenticationResult.SKIPPED` is returned. * * @param input The input from the user to try to authenticate with. This can be a list of * different things, based on the current authentication method. * @param tryAutoConfirm `true` if called while the user inputs the code, without an explicit * request to validate. * @return `true` if the authentication succeeded and the device is now unlocked; `false` when * authentication failed, `null` if the check was not performed. * @return The result of this authentication attempt. */ suspend fun authenticate( input: List<Any>, tryAutoConfirm: Boolean = false, ): Boolean? { val isAuthenticated = authenticationInteractor.authenticate(input, tryAutoConfirm) ?: return null if (isAuthenticated) { ): AuthenticationResult { if (input.isEmpty()) { return AuthenticationResult.SKIPPED } // Switching to the application scope here since this method is often called from // view-models, whose lifecycle (and thus scope) is shorter than this interactor. // This allows the task to continue running properly even when the calling scope has been // cancelled. return applicationScope .async { val authResult = authenticationInteractor.authenticate(input, tryAutoConfirm) when (authResult) { // Authentication succeeded. AuthenticationResult.SUCCEEDED -> sceneInteractor.changeScene( scene = SceneModel(SceneKey.Gone), loggingReason = "successful authentication", ) } else { showErrorMessage() // Authentication failed. AuthenticationResult.FAILED -> showErrorMessage() // Authentication skipped. AuthenticationResult.SKIPPED -> if (!tryAutoConfirm) showErrorMessage() } return isAuthenticated authResult } .await() } /** Loading @@ -221,7 +231,7 @@ constructor( * For example, if the user entered a pattern that's too short, the system can show the error * message without having the attempt trigger throttling. */ suspend fun showErrorMessage() { private suspend fun showErrorMessage() { repository.setMessage(errorMessage(authenticationInteractor.getAuthenticationMethod())) } Loading
packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt +60 −8 Original line number Diff line number Diff line Loading @@ -16,12 +16,21 @@ package com.android.systemui.bouncer.ui.viewmodel import android.annotation.StringRes import android.util.Log import com.android.systemui.authentication.domain.interactor.AuthenticationResult import com.android.systemui.authentication.domain.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed class AuthMethodBouncerViewModel( protected val viewModelScope: CoroutineScope, protected val interactor: BouncerInteractor, /** * Whether user input is enabled. * Loading @@ -29,7 +38,6 @@ sealed class AuthMethodBouncerViewModel( * being able to attempt to unlock the device. */ val isInputEnabled: StateFlow<Boolean>, private val interactor: BouncerInteractor, ) { private val _animateFailure = MutableStateFlow(false) Loading @@ -42,12 +50,26 @@ sealed class AuthMethodBouncerViewModel( /** 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 /** * Notifies that the failure animation has been shown. This should be called to consume a `true` * value in [animateFailure]. * String resource ID of the failure message to be shown during throttling. * * The message must include 2 number parameters: the first one indicating how many unsuccessful * attempts were made, and the second one indicating in how many seconds throttling will expire. */ fun onFailureAnimationShown() { _animateFailure.value = false @get:StringRes abstract val throttlingMessageId: Int /** Notifies that the UI has been shown to the user. */ fun onShown() { clearInput() interactor.resetMessage() } /** Notifies that the user has placed down a pointer. */ fun onDown() { interactor.onDown() } /** Loading @@ -65,8 +87,38 @@ sealed class AuthMethodBouncerViewModel( isImeVisible = isVisible } /** Ask the UI to show the failure animation. */ protected fun showFailureAnimation() { _animateFailure.value = true /** * Notifies that the failure animation has been shown. This should be called to consume a `true` * value in [animateFailure]. */ fun onFailureAnimationShown() { _animateFailure.value = false } /** Clears any previously-entered input. */ protected abstract fun clearInput() /** Returns the input entered so far. */ protected abstract fun getInput(): List<Any> /** * Attempts to authenticate the user using the current input value. * * @see BouncerInteractor.authenticate */ protected fun tryAuthenticate(useAutoConfirm: Boolean = false) { viewModelScope.launch { Log.d("Danny", "tryAuthenticate(useAutoConfirm=$useAutoConfirm)") val authenticationResult = interactor.authenticate(getInput(), useAutoConfirm) Log.d("Danny", "result = $authenticationResult") if (authenticationResult == AuthenticationResult.SKIPPED && useAutoConfirm) { return@launch } _animateFailure.value = authenticationResult != AuthenticationResult.SUCCEEDED // TODO(b/291528545): On success, this should only be cleared after the view is animated // away). clearInput() } } }
packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +68 −65 Original line number Diff line number Diff line Loading @@ -17,16 +17,19 @@ package com.android.systemui.bouncer.ui.viewmodel import android.content.Context import com.android.systemui.res.R import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.domain.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.scene.shared.flag.SceneContainerFlags import javax.inject.Inject import kotlin.math.ceil import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow Loading @@ -35,6 +38,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.job import kotlinx.coroutines.launch /** Holds UI state and handles user input on bouncer UIs. */ Loading @@ -44,8 +48,9 @@ class BouncerViewModel constructor( @Application private val applicationContext: Context, @Application private val applicationScope: CoroutineScope, @Main private val mainDispatcher: CoroutineDispatcher, private val bouncerInteractor: BouncerInteractor, private val authenticationInteractor: AuthenticationInteractor, authenticationInteractor: AuthenticationInteractor, flags: SceneContainerFlags, ) { private val isInputEnabled: StateFlow<Boolean> = Loading @@ -57,91 +62,45 @@ constructor( initialValue = !bouncerInteractor.isThrottled.value, ) private val pin: PinBouncerViewModel by lazy { PinBouncerViewModel( applicationContext = applicationContext, applicationScope = applicationScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) } private val password: PasswordBouncerViewModel by lazy { PasswordBouncerViewModel( applicationScope = applicationScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) } private val pattern: PatternBouncerViewModel by lazy { PatternBouncerViewModel( applicationContext = applicationContext, applicationScope = applicationScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) } /** View-model for the current UI, based on the current authentication method. */ val authMethod: StateFlow<AuthMethodBouncerViewModel?> = val authMethodViewModel: StateFlow<AuthMethodBouncerViewModel?> = authenticationInteractor.authenticationMethod .map { authenticationMethod -> when (authenticationMethod) { is AuthenticationMethodModel.Pin -> pin is AuthenticationMethodModel.Password -> password is AuthenticationMethodModel.Pattern -> pattern else -> null } } .map(::getChildViewModel) .stateIn( scope = applicationScope, started = SharingStarted.WhileSubscribed(), initialValue = null, ) // Handle to the scope of the child ViewModel (stored in [authMethod]). private var childViewModelScope: CoroutineScope? = null init { if (flags.isEnabled()) { applicationScope.launch { bouncerInteractor.isThrottled .map { isThrottled -> if (isThrottled) { when (authenticationInteractor.getAuthenticationMethod()) { is AuthenticationMethodModel.Pin -> R.string.kg_too_many_failed_pin_attempts_dialog_message is AuthenticationMethodModel.Password -> R.string.kg_too_many_failed_password_attempts_dialog_message is AuthenticationMethodModel.Pattern -> R.string.kg_too_many_failed_pattern_attempts_dialog_message else -> null }?.let { stringResourceId -> combine(bouncerInteractor.isThrottled, authMethodViewModel) { isThrottled, authMethodViewModel -> if (isThrottled && authMethodViewModel != null) { applicationContext.getString( stringResourceId, authMethodViewModel.throttlingMessageId, bouncerInteractor.throttling.value.failedAttemptCount, ceil(bouncerInteractor.throttling.value.remainingMs / 1000f) .toInt(), ) } } else { null } } .distinctUntilChanged() .collect { dialogMessageOrNull -> if (dialogMessageOrNull != null) { _throttlingDialogMessage.value = dialogMessageOrNull } } .collect { dialogMessage -> _throttlingDialogMessage.value = dialogMessage } } } } /** The user-facing message to show in the bouncer. */ val message: StateFlow<MessageViewModel> = combine( bouncerInteractor.message, bouncerInteractor.isThrottled, ) { message, isThrottled -> combine(bouncerInteractor.message, bouncerInteractor.isThrottled) { message, isThrottled -> toMessageViewModel(message, isThrottled) } .stateIn( Loading Loading @@ -186,6 +145,50 @@ constructor( ) } private fun getChildViewModel( authenticationMethod: AuthenticationMethodModel, ): AuthMethodBouncerViewModel? { // If the current child view-model matches the authentication method, reuse it instead of // creating a new instance. val childViewModel = authMethodViewModel.value if (authenticationMethod == childViewModel?.authenticationMethod) { return childViewModel } childViewModelScope?.cancel() val newViewModelScope = createChildCoroutineScope(applicationScope) childViewModelScope = newViewModelScope return when (authenticationMethod) { is AuthenticationMethodModel.Pin -> PinBouncerViewModel( applicationContext = applicationContext, viewModelScope = newViewModelScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) is AuthenticationMethodModel.Password -> PasswordBouncerViewModel( viewModelScope = newViewModelScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) is AuthenticationMethodModel.Pattern -> PatternBouncerViewModel( applicationContext = applicationContext, viewModelScope = newViewModelScope, interactor = bouncerInteractor, isInputEnabled = isInputEnabled, ) else -> null } } private fun createChildCoroutineScope(parentScope: CoroutineScope): CoroutineScope { return CoroutineScope( SupervisorJob(parent = parentScope.coroutineContext.job) + mainDispatcher ) } data class MessageViewModel( val text: String, Loading