diff options
18 files changed, 560 insertions, 616 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt index 64ddbc7828ac..c961be946f79 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt @@ -35,8 +35,10 @@ import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobile import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy import com.android.systemui.user.data.repository.FakeUserRepository import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import java.util.function.Function +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runCurrent @@ -58,6 +60,7 @@ class AuthenticationRepositoryTest : SysuiTestCase() { private val testUtils = SceneTestUtils(this) private val testScope = testUtils.testScope + private val clock = FakeSystemClock() private val userRepository = FakeUserRepository() private lateinit var mobileConnectionsRepository: FakeMobileConnectionsRepository @@ -78,8 +81,10 @@ class AuthenticationRepositoryTest : SysuiTestCase() { underTest = AuthenticationRepositoryImpl( applicationScope = testScope.backgroundScope, - getSecurityMode = getSecurityMode, backgroundDispatcher = testUtils.testDispatcher, + flags = testUtils.sceneContainerFlags, + clock = clock, + getSecurityMode = getSecurityMode, userRepository = userRepository, lockPatternUtils = lockPatternUtils, broadcastDispatcher = fakeBroadcastDispatcher, @@ -141,22 +146,6 @@ class AuthenticationRepositoryTest : SysuiTestCase() { } @Test - fun reportAuthenticationAttempt_emitsAuthenticationChallengeResult() = - testScope.runTest { - val authenticationChallengeResults by - collectValues(underTest.authenticationChallengeResult) - - runCurrent() - underTest.reportAuthenticationAttempt(true) - runCurrent() - underTest.reportAuthenticationAttempt(false) - runCurrent() - underTest.reportAuthenticationAttempt(true) - - assertThat(authenticationChallengeResults).isEqualTo(listOf(true, false, true)) - } - - @Test fun isPinEnhancedPrivacyEnabled() = testScope.runTest { whenever(lockPatternUtils.isPinEnhancedPrivacyEnabled(USER_INFOS[0].id)) @@ -172,6 +161,45 @@ class AuthenticationRepositoryTest : SysuiTestCase() { assertThat(values.last()).isTrue() } + @Test + fun lockoutEndTimestamp() = + testScope.runTest { + val lockoutEndMs = clock.elapsedRealtime() + 30.seconds.inWholeMilliseconds + whenever(lockPatternUtils.getLockoutAttemptDeadline(USER_INFOS[0].id)) + .thenReturn(lockoutEndMs) + whenever(lockPatternUtils.getLockoutAttemptDeadline(USER_INFOS[1].id)).thenReturn(0) + + // Switch to a user who is not locked-out. + userRepository.setSelectedUserInfo(USER_INFOS[1]) + assertThat(underTest.lockoutEndTimestamp).isNull() + + // Switch back to the locked-out user, verify the timestamp is up-to-date. + userRepository.setSelectedUserInfo(USER_INFOS[0]) + assertThat(underTest.lockoutEndTimestamp).isEqualTo(lockoutEndMs) + + // After the lockout expires, null is returned. + clock.setElapsedRealtime(lockoutEndMs) + assertThat(underTest.lockoutEndTimestamp).isNull() + } + + @Test + fun hasLockoutOccurred() = + testScope.runTest { + val hasLockoutOccurred by collectLastValue(underTest.hasLockoutOccurred) + assertThat(hasLockoutOccurred).isFalse() + + underTest.reportLockoutStarted(1000) + assertThat(hasLockoutOccurred).isTrue() + + clock.setElapsedRealtime(clock.elapsedRealtime() + 60.seconds.inWholeMilliseconds) + + underTest.reportAuthenticationAttempt(isSuccessful = false) + assertThat(hasLockoutOccurred).isTrue() + + underTest.reportAuthenticationAttempt(isSuccessful = true) + assertThat(hasLockoutOccurred).isFalse() + } + private fun setSecurityModeAndDispatchBroadcast( securityMode: KeyguardSecurityModel.SecurityMode, ) { diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt index 08cd7edba6af..c113b3744bc0 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt @@ -21,14 +21,18 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel -import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.None +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Password +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pattern +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pin import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate import com.android.systemui.coroutines.collectLastValue import com.android.systemui.scene.SceneTestUtils import com.google.common.truth.Truth.assertThat +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -43,21 +47,23 @@ class AuthenticationInteractorTest : SysuiTestCase() { private val testScope = utils.testScope private val underTest = utils.authenticationInteractor() + private val onAuthenticationResult by + testScope.collectLastValue(underTest.onAuthenticationResult) + private val failedAuthenticationAttempts by + testScope.collectLastValue(underTest.failedAuthenticationAttempts) + @Test fun authenticationMethod() = testScope.runTest { val authMethod by collectLastValue(underTest.authenticationMethod) runCurrent() - assertThat(authMethod).isEqualTo(AuthenticationMethodModel.Pin) - assertThat(underTest.getAuthenticationMethod()).isEqualTo(AuthenticationMethodModel.Pin) + assertThat(authMethod).isEqualTo(Pin) + assertThat(underTest.getAuthenticationMethod()).isEqualTo(Pin) - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Password - ) + utils.authenticationRepository.setAuthenticationMethod(Password) - assertThat(authMethod).isEqualTo(AuthenticationMethodModel.Password) - assertThat(underTest.getAuthenticationMethod()) - .isEqualTo(AuthenticationMethodModel.Password) + assertThat(authMethod).isEqualTo(Password) + assertThat(underTest.getAuthenticationMethod()).isEqualTo(Password) } @Test @@ -66,51 +72,45 @@ class AuthenticationInteractorTest : SysuiTestCase() { val authMethod by collectLastValue(underTest.authenticationMethod) runCurrent() - utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.None) + utils.authenticationRepository.setAuthenticationMethod(None) - assertThat(authMethod).isEqualTo(AuthenticationMethodModel.None) - assertThat(underTest.getAuthenticationMethod()) - .isEqualTo(AuthenticationMethodModel.None) + assertThat(authMethod).isEqualTo(None) + assertThat(underTest.getAuthenticationMethod()).isEqualTo(None) } @Test fun authenticate_withCorrectPin_succeeds() = testScope.runTest { - val lockout by collectLastValue(underTest.lockout) - utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + utils.authenticationRepository.setAuthenticationMethod(Pin) - assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) - .isEqualTo(AuthenticationResult.SUCCEEDED) - assertThat(lockout).isNull() - assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(0) + assertSucceeded(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) } @Test fun authenticate_withIncorrectPin_fails() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + utils.authenticationRepository.setAuthenticationMethod(Pin) - assertThat(underTest.authenticate(listOf(9, 8, 7, 6, 5, 4))) - .isEqualTo(AuthenticationResult.FAILED) + assertFailed(underTest.authenticate(listOf(9, 8, 7, 6, 5, 4))) } @Test(expected = IllegalArgumentException::class) fun authenticate_withEmptyPin_throwsException() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + utils.authenticationRepository.setAuthenticationMethod(Pin) underTest.authenticate(listOf()) } @Test fun authenticate_withCorrectMaxLengthPin_succeeds() = testScope.runTest { - val pin = List(16) { 9 } + val correctMaxLengthPin = List(16) { 9 } utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) - overrideCredential(pin) + setAuthenticationMethod(Pin) + overrideCredential(correctMaxLengthPin) } - assertThat(underTest.authenticate(pin)).isEqualTo(AuthenticationResult.SUCCEEDED) + assertSucceeded(underTest.authenticate(correctMaxLengthPin)) } @Test @@ -122,88 +122,64 @@ class AuthenticationInteractorTest : SysuiTestCase() { // If the policy changes, there is work to do in SysUI. assertThat(DevicePolicyManager.MAX_PASSWORD_LENGTH).isLessThan(17) - utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) - assertThat(underTest.authenticate(List(17) { 9 })) - .isEqualTo(AuthenticationResult.FAILED) + utils.authenticationRepository.setAuthenticationMethod(Pin) + + assertFailed(underTest.authenticate(List(17) { 9 })) } @Test fun authenticate_withCorrectPassword_succeeds() = testScope.runTest { - val lockout by collectLastValue(underTest.lockout) - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Password - ) + utils.authenticationRepository.setAuthenticationMethod(Password) - assertThat(underTest.authenticate("password".toList())) - .isEqualTo(AuthenticationResult.SUCCEEDED) - assertThat(lockout).isNull() - assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(0) + assertSucceeded(underTest.authenticate("password".toList())) } @Test fun authenticate_withIncorrectPassword_fails() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Password - ) + utils.authenticationRepository.setAuthenticationMethod(Password) - assertThat(underTest.authenticate("alohomora".toList())) - .isEqualTo(AuthenticationResult.FAILED) + assertFailed(underTest.authenticate("alohomora".toList())) } @Test fun authenticate_withCorrectPattern_succeeds() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Pattern - ) + utils.authenticationRepository.setAuthenticationMethod(Pattern) - assertThat(underTest.authenticate(FakeAuthenticationRepository.PATTERN)) - .isEqualTo(AuthenticationResult.SUCCEEDED) + assertSucceeded(underTest.authenticate(FakeAuthenticationRepository.PATTERN)) } @Test fun authenticate_withIncorrectPattern_fails() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Pattern - ) - - assertThat( - underTest.authenticate( - listOf( - AuthenticationPatternCoordinate(x = 2, y = 0), - AuthenticationPatternCoordinate(x = 2, y = 1), - AuthenticationPatternCoordinate(x = 2, y = 2), - AuthenticationPatternCoordinate(x = 1, y = 2), - ) - ) + utils.authenticationRepository.setAuthenticationMethod(Pattern) + val wrongPattern = + listOf( + AuthenticationPatternCoordinate(x = 2, y = 0), + AuthenticationPatternCoordinate(x = 2, y = 1), + AuthenticationPatternCoordinate(x = 2, y = 2), + AuthenticationPatternCoordinate(x = 1, y = 2), ) - .isEqualTo(AuthenticationResult.FAILED) + + assertFailed(underTest.authenticate(wrongPattern)) } @Test fun tryAutoConfirm_withAutoConfirmPinAndShorterPin_returnsNull() = testScope.runTest { val isAutoConfirmEnabled by collectLastValue(underTest.isAutoConfirmEnabled) - val lockout by collectLastValue(underTest.lockout) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(true) } assertThat(isAutoConfirmEnabled).isTrue() + val shorterPin = + FakeAuthenticationRepository.DEFAULT_PIN.toMutableList().apply { removeLast() } - assertThat( - underTest.authenticate( - FakeAuthenticationRepository.DEFAULT_PIN.toMutableList().apply { - removeLast() - }, - tryAutoConfirm = true - ) - ) - .isEqualTo(AuthenticationResult.SKIPPED) - assertThat(lockout).isNull() + assertSkipped(underTest.authenticate(shorterPin, tryAutoConfirm = true)) + assertThat(underTest.lockoutEndTimestamp).isNull() assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(0) } @@ -212,18 +188,17 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val isAutoConfirmEnabled by collectLastValue(underTest.isAutoConfirmEnabled) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(true) } assertThat(isAutoConfirmEnabled).isTrue() - assertThat( - underTest.authenticate( - FakeAuthenticationRepository.DEFAULT_PIN.map { it + 1 }, - tryAutoConfirm = true - ) - ) - .isEqualTo(AuthenticationResult.FAILED) + val wrongPin = FakeAuthenticationRepository.DEFAULT_PIN.map { it + 1 } + + assertFailed( + underTest.authenticate(wrongPin, tryAutoConfirm = true), + assertNoResultEvents = true, + ) } @Test @@ -231,18 +206,17 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val isAutoConfirmEnabled by collectLastValue(underTest.isAutoConfirmEnabled) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(true) } assertThat(isAutoConfirmEnabled).isTrue() - assertThat( - underTest.authenticate( - FakeAuthenticationRepository.DEFAULT_PIN + listOf(7), - tryAutoConfirm = true - ) - ) - .isEqualTo(AuthenticationResult.FAILED) + val longerPin = FakeAuthenticationRepository.DEFAULT_PIN + listOf(7) + + assertFailed( + underTest.authenticate(longerPin, tryAutoConfirm = true), + assertNoResultEvents = true, + ) } @Test @@ -250,69 +224,54 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val isAutoConfirmEnabled by collectLastValue(underTest.isAutoConfirmEnabled) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(true) } assertThat(isAutoConfirmEnabled).isTrue() - assertThat( - underTest.authenticate( - FakeAuthenticationRepository.DEFAULT_PIN, - tryAutoConfirm = true - ) - ) - .isEqualTo(AuthenticationResult.SUCCEEDED) + val correctPin = FakeAuthenticationRepository.DEFAULT_PIN + + assertSucceeded(underTest.authenticate(correctPin, tryAutoConfirm = true)) } @Test fun tryAutoConfirm_withAutoConfirmCorrectPinButDuringLockout_returnsNull() = testScope.runTest { val isAutoConfirmEnabled by collectLastValue(underTest.isAutoConfirmEnabled) - val isUnlocked by collectLastValue(utils.deviceEntryRepository.isUnlocked) val hintedPinLength by collectLastValue(underTest.hintedPinLength) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(true) - setLockoutDuration(42) + reportLockoutStarted(42) } - val authResult = - underTest.authenticate( - FakeAuthenticationRepository.DEFAULT_PIN, - tryAutoConfirm = true - ) + val correctPin = FakeAuthenticationRepository.DEFAULT_PIN - assertThat(authResult).isEqualTo(AuthenticationResult.SKIPPED) + assertSkipped(underTest.authenticate(correctPin, tryAutoConfirm = true)) assertThat(isAutoConfirmEnabled).isFalse() - assertThat(isUnlocked).isFalse() assertThat(hintedPinLength).isNull() + assertThat(underTest.lockoutEndTimestamp).isNotNull() } @Test fun tryAutoConfirm_withoutAutoConfirmButCorrectPin_returnsNull() = testScope.runTest { utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(false) } - assertThat( - underTest.authenticate( - FakeAuthenticationRepository.DEFAULT_PIN, - tryAutoConfirm = true - ) - ) - .isEqualTo(AuthenticationResult.SKIPPED) + + val correctPin = FakeAuthenticationRepository.DEFAULT_PIN + + assertSkipped(underTest.authenticate(correctPin, tryAutoConfirm = true)) } @Test fun tryAutoConfirm_withoutCorrectPassword_returnsNull() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Password - ) + utils.authenticationRepository.setAuthenticationMethod(Password) - assertThat(underTest.authenticate("password".toList(), tryAutoConfirm = true)) - .isEqualTo(AuthenticationResult.SKIPPED) + assertSkipped(underTest.authenticate("password".toList(), tryAutoConfirm = true)) } @Test @@ -337,7 +296,6 @@ class AuthenticationInteractorTest : SysuiTestCase() { fun isAutoConfirmEnabled_featureEnabledButDisabledByLockout() = testScope.runTest { val isAutoConfirmEnabled by collectLastValue(underTest.isAutoConfirmEnabled) - val lockout by collectLastValue(underTest.lockout) utils.authenticationRepository.setAutoConfirmFeatureEnabled(true) // The feature is enabled. @@ -345,92 +303,104 @@ class AuthenticationInteractorTest : SysuiTestCase() { // Make many wrong attempts to trigger lockout. repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { - underTest.authenticate(listOf(5, 6, 7)) // Wrong PIN + assertFailed(underTest.authenticate(listOf(5, 6, 7))) // Wrong PIN } - assertThat(lockout).isNotNull() + assertThat(underTest.lockoutEndTimestamp).isNotNull() assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(1) // Lockout disabled auto-confirm. assertThat(isAutoConfirmEnabled).isFalse() // Move the clock forward one more second, to completely finish the lockout period: - advanceTimeBy(FakeAuthenticationRepository.LOCKOUT_DURATION_MS + 1000L) - assertThat(lockout).isNull() + advanceTimeBy( + FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS.seconds.plus(1.seconds) + ) + assertThat(underTest.lockoutEndTimestamp).isNull() // Auto-confirm is still disabled, because lockout occurred at least once in this // session. assertThat(isAutoConfirmEnabled).isFalse() // Correct PIN and unlocks successfully, resetting the 'session'. - assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) - .isEqualTo(AuthenticationResult.SUCCEEDED) + assertSucceeded(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) // Auto-confirm is re-enabled. assertThat(isAutoConfirmEnabled).isTrue() + } - assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(1) + @Test + fun failedAuthenticationAttempts() = + testScope.runTest { + val failedAuthenticationAttempts by + collectLastValue(underTest.failedAuthenticationAttempts) + + utils.authenticationRepository.setAuthenticationMethod(Pin) + val correctPin = FakeAuthenticationRepository.DEFAULT_PIN + + assertSucceeded(underTest.authenticate(correctPin)) + assertThat(failedAuthenticationAttempts).isEqualTo(0) + + // Make many wrong attempts, leading to lockout: + repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { index -> + underTest.authenticate(listOf(5, 6, 7)) // Wrong PIN + assertThat(failedAuthenticationAttempts).isEqualTo(index + 1) + } + + // Correct PIN, but locked out, so doesn't attempt it: + assertSkipped(underTest.authenticate(correctPin), assertNoResultEvents = false) + assertThat(failedAuthenticationAttempts) + .isEqualTo(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) + + // Move the clock forward to finish the lockout period: + advanceTimeBy(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS.seconds) + assertThat(failedAuthenticationAttempts) + .isEqualTo(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) + + // Correct PIN and no longer locked out so unlocks successfully: + assertSucceeded(underTest.authenticate(correctPin)) + assertThat(failedAuthenticationAttempts).isEqualTo(0) } @Test - fun lockout() = + fun lockoutEndTimestamp() = testScope.runTest { - val lockout by collectLastValue(underTest.lockout) - utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) - underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN) - assertThat(lockout).isNull() + utils.authenticationRepository.setAuthenticationMethod(Pin) + val correctPin = FakeAuthenticationRepository.DEFAULT_PIN + + underTest.authenticate(correctPin) + assertThat(underTest.lockoutEndTimestamp).isNull() // Make many wrong attempts, but just shy of what's needed to get locked out: repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) { underTest.authenticate(listOf(5, 6, 7)) // Wrong PIN - assertThat(lockout).isNull() + assertThat(underTest.lockoutEndTimestamp).isNull() } // Make one more wrong attempt, leading to lockout: underTest.authenticate(listOf(5, 6, 7)) // Wrong PIN - assertThat(lockout) - .isEqualTo( - AuthenticationLockoutModel( - failedAttemptCount = - FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT, - remainingSeconds = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS, - ) - ) + + val expectedLockoutEndTimestamp = + testScope.currentTime + FakeAuthenticationRepository.LOCKOUT_DURATION_MS + assertThat(underTest.lockoutEndTimestamp).isEqualTo(expectedLockoutEndTimestamp) assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(1) // Correct PIN, but locked out, so doesn't attempt it: - assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) - .isEqualTo(AuthenticationResult.SKIPPED) - assertThat(lockout) - .isEqualTo( - AuthenticationLockoutModel( - failedAttemptCount = - FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT, - remainingSeconds = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS, - ) - ) + assertSkipped(underTest.authenticate(correctPin), assertNoResultEvents = false) + assertThat(underTest.lockoutEndTimestamp).isEqualTo(expectedLockoutEndTimestamp) // Move the clock forward to ALMOST skip the lockout, leaving one second to go: - val lockoutTimeoutSec = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS - repeat(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS - 1) { time -> - advanceTimeBy(1000) - assertThat(lockout) - .isEqualTo( - AuthenticationLockoutModel( - failedAttemptCount = - FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT, - remainingSeconds = lockoutTimeoutSec - (time + 1), - ) - ) + repeat(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS - 1) { + advanceTimeBy(1.seconds) + assertThat(underTest.lockoutEndTimestamp).isEqualTo(expectedLockoutEndTimestamp) } // Move the clock forward one more second, to completely finish the lockout period: - advanceTimeBy(1000) - assertThat(lockout).isNull() + advanceTimeBy(1.seconds) + assertThat(underTest.lockoutEndTimestamp).isNull() // Correct PIN and no longer locked out so unlocks successfully: - assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) - .isEqualTo(AuthenticationResult.SUCCEEDED) - assertThat(lockout).isNull() + assertSucceeded(underTest.authenticate(correctPin)) + assertThat(underTest.lockoutEndTimestamp).isNull() } @Test @@ -438,7 +408,7 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val hintedPinLength by collectLastValue(underTest.hintedPinLength) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(false) } @@ -450,7 +420,7 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val hintedPinLength by collectLastValue(underTest.hintedPinLength) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) overrideCredential( buildList { repeat(utils.authenticationRepository.hintedPinLength - 1) { add(it + 1) } @@ -467,7 +437,7 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val hintedPinLength by collectLastValue(underTest.hintedPinLength) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) setAutoConfirmFeatureEnabled(true) overrideCredential( buildList { @@ -484,7 +454,7 @@ class AuthenticationInteractorTest : SysuiTestCase() { testScope.runTest { val hintedPinLength by collectLastValue(underTest.hintedPinLength) utils.authenticationRepository.apply { - setAuthenticationMethod(AuthenticationMethodModel.Pin) + setAuthenticationMethod(Pin) overrideCredential( buildList { repeat(utils.authenticationRepository.hintedPinLength + 1) { add(it + 1) } @@ -499,18 +469,45 @@ class AuthenticationInteractorTest : SysuiTestCase() { @Test fun authenticate_withTooShortPassword() = testScope.runTest { - utils.authenticationRepository.setAuthenticationMethod( - AuthenticationMethodModel.Password - ) - assertThat( - underTest.authenticate( - buildList { - repeat(utils.authenticationRepository.minPasswordLength - 1) { time -> - add("$time") - } - } - ) - ) - .isEqualTo(AuthenticationResult.SKIPPED) + utils.authenticationRepository.setAuthenticationMethod(Password) + + val tooShortPassword = buildList { + repeat(utils.authenticationRepository.minPasswordLength - 1) { time -> + add("$time") + } + } + assertSkipped(underTest.authenticate(tooShortPassword)) + } + + private fun assertSucceeded(authenticationResult: AuthenticationResult) { + assertThat(authenticationResult).isEqualTo(AuthenticationResult.SUCCEEDED) + assertThat(onAuthenticationResult).isTrue() + assertThat(underTest.lockoutEndTimestamp).isNull() + assertThat(utils.authenticationRepository.lockoutStartedReportCount).isEqualTo(0) + assertThat(failedAuthenticationAttempts).isEqualTo(0) + } + + private fun assertFailed( + authenticationResult: AuthenticationResult, + assertNoResultEvents: Boolean = false, + ) { + assertThat(authenticationResult).isEqualTo(AuthenticationResult.FAILED) + if (assertNoResultEvents) { + assertThat(onAuthenticationResult).isNull() + } else { + assertThat(onAuthenticationResult).isFalse() + } + } + + private fun assertSkipped( + authenticationResult: AuthenticationResult, + assertNoResultEvents: Boolean = true, + ) { + assertThat(authenticationResult).isEqualTo(AuthenticationResult.SKIPPED) + if (assertNoResultEvents) { + assertThat(onAuthenticationResult).isNull() + } else { + assertThat(onAuthenticationResult).isNotNull() } + } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt index 9b1df7c0ffc0..99c18744f9b4 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -21,15 +21,15 @@ import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository import com.android.systemui.authentication.domain.interactor.AuthenticationResult -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.coroutines.collectValues import com.android.systemui.keyguard.domain.interactor.KeyguardFaceAuthInteractor import com.android.systemui.res.R import com.android.systemui.scene.SceneTestUtils import com.google.common.truth.Truth.assertThat -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent @@ -79,7 +79,7 @@ class BouncerInteractorTest : SysuiTestCase() { utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) runCurrent() underTest.clearMessage() - assertThat(message).isEmpty() + assertThat(message).isNull() underTest.resetMessage() assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) @@ -149,7 +149,7 @@ class BouncerInteractorTest : SysuiTestCase() { // Incomplete input. assertThat(underTest.authenticate(listOf(1, 2), tryAutoConfirm = true)) .isEqualTo(AuthenticationResult.SKIPPED) - assertThat(message).isEmpty() + assertThat(message).isNull() // Correct input. assertThat( @@ -159,7 +159,7 @@ class BouncerInteractorTest : SysuiTestCase() { ) ) .isEqualTo(AuthenticationResult.SKIPPED) - assertThat(message).isEmpty() + assertThat(message).isNull() } @Test @@ -246,57 +246,40 @@ class BouncerInteractorTest : SysuiTestCase() { } @Test - fun lockout() = + fun lockoutStarted() = testScope.runTest { - val lockout by collectLastValue(underTest.lockout) + val lockoutStartedEvents by collectValues(underTest.onLockoutStarted) val message by collectLastValue(underTest.message) + utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) - assertThat(lockout).isNull() + assertThat(lockoutStartedEvents).isEmpty() + + // Try the wrong PIN repeatedly, until lockout is triggered: repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { times -> // Wrong PIN. assertThat(underTest.authenticate(listOf(6, 7, 8, 9))) .isEqualTo(AuthenticationResult.FAILED) if (times < FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) { - assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) + assertThat(lockoutStartedEvents).isEmpty() + assertThat(message).isNotEmpty() } } - assertThat(lockout) - .isEqualTo( - AuthenticationLockoutModel( - failedAttemptCount = - FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT, - remainingSeconds = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS, - ) - ) - assertTryAgainMessage( - message, - FakeAuthenticationRepository.LOCKOUT_DURATION_MS.milliseconds.inWholeSeconds.toInt() - ) - - // Correct PIN, but locked out, so doesn't change away from the bouncer scene: - assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) - .isEqualTo(AuthenticationResult.SKIPPED) - assertTryAgainMessage( - message, - FakeAuthenticationRepository.LOCKOUT_DURATION_MS.milliseconds.inWholeSeconds.toInt() - ) - - lockout?.remainingSeconds?.let { seconds -> - repeat(seconds) { time -> - advanceTimeBy(1000) - val remainingTimeSec = seconds - time - 1 - if (remainingTimeSec > 0) { - assertTryAgainMessage(message, remainingTimeSec) - } - } + assertThat(authenticationInteractor.lockoutEndTimestamp).isNotNull() + assertThat(lockoutStartedEvents.size).isEqualTo(1) + assertThat(message).isNull() + + // Advance the time to finish the lockout: + advanceTimeBy(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS.seconds) + assertThat(authenticationInteractor.lockoutEndTimestamp).isNull() + assertThat(message).isNull() + assertThat(lockoutStartedEvents.size).isEqualTo(1) + + // Trigger lockout again: + repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { + // Wrong PIN. + underTest.authenticate(listOf(6, 7, 8, 9)) } - assertThat(message).isEqualTo("") - assertThat(lockout).isNull() - - // Correct PIN and no longer locked out so changes to the Gone scene: - assertThat(underTest.authenticate(FakeAuthenticationRepository.DEFAULT_PIN)) - .isEqualTo(AuthenticationResult.SUCCEEDED) - assertThat(lockout).isNull() + assertThat(lockoutStartedEvents.size).isEqualTo(2) } @Test @@ -326,13 +309,6 @@ class BouncerInteractorTest : SysuiTestCase() { verify(keyguardFaceAuthInteractor).onPrimaryBouncerUserInput() } - private fun assertTryAgainMessage( - message: String?, - time: Int, - ) { - assertThat(message).isEqualTo("Try again in $time seconds.") - } - companion object { private const val MESSAGE_ENTER_YOUR_PIN = "Enter your PIN" private const val MESSAGE_ENTER_YOUR_PASSWORD = "Enter your password" diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt index 16a935943dbf..4be9b0a49a69 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -135,19 +136,47 @@ class BouncerViewModelTest : SysuiTestCase() { fun message() = testScope.runTest { val message by collectLastValue(underTest.message) - val lockout by collectLastValue(bouncerInteractor.lockout) utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) assertThat(message?.isUpdateAnimated).isTrue() repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { - // Wrong PIN. - bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) + bouncerInteractor.authenticate(WRONG_PIN) } assertThat(message?.isUpdateAnimated).isFalse() - lockout?.remainingSeconds?.let { remainingSeconds -> - advanceTimeBy(remainingSeconds.seconds.inWholeMilliseconds) + val lockoutEndMs = authenticationInteractor.lockoutEndTimestamp ?: 0 + advanceTimeBy(lockoutEndMs - testScope.currentTime) + assertThat(message?.isUpdateAnimated).isTrue() + } + + @Test + fun lockoutMessage() = + testScope.runTest { + val authMethodViewModel by collectLastValue(underTest.authMethodViewModel) + val message by collectLastValue(underTest.message) + utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + assertThat(utils.authenticationRepository.lockoutEndTimestamp).isNull() + assertThat(authMethodViewModel?.lockoutMessageId).isNotNull() + + repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { times -> + bouncerInteractor.authenticate(WRONG_PIN) + if (times < FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) { + assertThat(message?.text).isEqualTo(bouncerInteractor.message.value) + assertThat(message?.isUpdateAnimated).isTrue() + } } + val lockoutSeconds = FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS + assertTryAgainMessage(message?.text, lockoutSeconds) + assertThat(message?.isUpdateAnimated).isFalse() + + repeat(FakeAuthenticationRepository.LOCKOUT_DURATION_SECONDS) { time -> + advanceTimeBy(1.seconds) + val remainingSeconds = lockoutSeconds - time - 1 + if (remainingSeconds > 0) { + assertTryAgainMessage(message?.text, remainingSeconds) + } + } + assertThat(message?.text).isEmpty() assertThat(message?.isUpdateAnimated).isTrue() } @@ -160,32 +189,30 @@ class BouncerViewModelTest : SysuiTestCase() { authViewModel?.isInputEnabled ?: emptyFlow() } ) - val lockout by collectLastValue(bouncerInteractor.lockout) utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) assertThat(isInputEnabled).isTrue() repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { - // Wrong PIN. - bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) + bouncerInteractor.authenticate(WRONG_PIN) } assertThat(isInputEnabled).isFalse() - lockout?.remainingSeconds?.let { remainingSeconds -> - advanceTimeBy(remainingSeconds.seconds.inWholeMilliseconds) - } + val lockoutEndMs = authenticationInteractor.lockoutEndTimestamp ?: 0 + advanceTimeBy(lockoutEndMs - testScope.currentTime) assertThat(isInputEnabled).isTrue() } @Test fun dialogMessage() = testScope.runTest { + val authMethodViewModel by collectLastValue(underTest.authMethodViewModel) val dialogMessage by collectLastValue(underTest.dialogMessage) utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + assertThat(authMethodViewModel?.lockoutMessageId).isNotNull() repeat(FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT) { - // Wrong PIN. assertThat(dialogMessage).isNull() - bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) + bouncerInteractor.authenticate(WRONG_PIN) } assertThat(dialogMessage).isNotEmpty() @@ -241,4 +268,15 @@ class BouncerViewModelTest : SysuiTestCase() { AuthenticationMethodModel.Sim, ) } + + private fun assertTryAgainMessage( + message: String?, + time: Int, + ) { + assertThat(message).isEqualTo("Try again in $time seconds.") + } + + companion object { + private val WRONG_PIN = FakeAuthenticationRepository.DEFAULT_PIN.map { it + 1 } + } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt index 6d6baa57bb9d..64e6e5707d75 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -19,7 +19,6 @@ package com.android.systemui.bouncer.ui.viewmodel import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues @@ -28,6 +27,7 @@ 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 kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -45,11 +45,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { private val utils = SceneTestUtils(this) private val testScope = utils.testScope - private val authenticationRepository = utils.authenticationRepository - private val authenticationInteractor = - utils.authenticationInteractor( - repository = authenticationRepository, - ) + private val authenticationInteractor = utils.authenticationInteractor() private val sceneInteractor = utils.sceneInteractor() private val bouncerInteractor = utils.bouncerInteractor( @@ -61,12 +57,13 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { authenticationInteractor = authenticationInteractor, actionButtonInteractor = utils.bouncerActionButtonInteractor(), ) + private val isInputEnabled = MutableStateFlow(true) private val underTest = PasswordBouncerViewModel( viewModelScope = testScope.backgroundScope, interactor = bouncerInteractor, - isInputEnabled = MutableStateFlow(true).asStateFlow(), + isInputEnabled.asStateFlow(), ) @Before @@ -123,8 +120,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { @Test fun onAuthenticateKeyPressed_whenCorrect() = testScope.runTest { - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) lockDeviceAndOpenPasswordBouncer() underTest.onPasswordInputChanged("password") @@ -169,8 +165,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { @Test fun onAuthenticateKeyPressed_correctAfterWrong() = testScope.runTest { - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) val message by collectLastValue(bouncerViewModel.message) val password by collectLastValue(underTest.password) lockDeviceAndOpenPasswordBouncer() @@ -333,19 +328,15 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { ) { if (isLockedOut) { repeat(failedAttemptCount) { - authenticationRepository.reportAuthenticationAttempt(false) + utils.authenticationRepository.reportAuthenticationAttempt(false) } - val remainingTimeSeconds = 30 - authenticationRepository.setLockoutDuration(remainingTimeSeconds * 1000) - authenticationRepository.lockout.value = - AuthenticationLockoutModel( - failedAttemptCount = failedAttemptCount, - remainingSeconds = remainingTimeSeconds, - ) + utils.authenticationRepository.reportLockoutStarted( + 30.seconds.inWholeMilliseconds.toInt() + ) } else { - authenticationRepository.reportAuthenticationAttempt(true) - authenticationRepository.lockout.value = null + utils.authenticationRepository.reportAuthenticationAttempt(true) } + isInputEnabled.value = !isLockedOut runCurrent() } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt index 8971423edd52..ed7609999804 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt @@ -111,8 +111,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { @Test fun onDragEnd_whenCorrect() = testScope.runTest { - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) val selectedDots by collectLastValue(underTest.selectedDots) val currentDot by collectLastValue(underTest.currentDot) lockDeviceAndOpenPatternBouncer() @@ -334,8 +333,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { @Test fun onDragEnd_correctAfterWrong() = testScope.runTest { - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) val message by collectLastValue(bouncerViewModel.message) val selectedDots by collectLastValue(underTest.selectedDots) val currentDot by collectLastValue(underTest.currentDot) diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt index c30e405ab911..db98d7632910 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -201,8 +201,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { @Test fun onAuthenticateButtonClicked_whenCorrect() = testScope.runTest { - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) lockDeviceAndOpenPinBouncer() FakeAuthenticationRepository.DEFAULT_PIN.forEach(underTest::onPinButtonClicked) @@ -236,8 +235,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { @Test fun onAuthenticateButtonClicked_correctAfterWrong() = testScope.runTest { - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) val message by collectLastValue(bouncerViewModel.message) val pin by collectLastValue(underTest.pinInput.map { it.getPin() }) lockDeviceAndOpenPinBouncer() @@ -265,8 +263,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { fun onAutoConfirm_whenCorrect() = testScope.runTest { utils.authenticationRepository.setAutoConfirmFeatureEnabled(true) - val authResult by - collectLastValue(authenticationInteractor.authenticationChallengeResult) + val authResult by collectLastValue(authenticationInteractor.onAuthenticationResult) lockDeviceAndOpenPinBouncer() FakeAuthenticationRepository.DEFAULT_PIN.forEach(underTest::onPinButtonClicked) diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/data/repository/DeviceEntryRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/data/repository/DeviceEntryRepositoryTest.kt index d3049d9080f3..565049baf6f4 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/data/repository/DeviceEntryRepositoryTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/data/repository/DeviceEntryRepositoryTest.kt @@ -99,7 +99,7 @@ class DeviceEntryRepositoryTest : SysuiTestCase() { } @Test - fun reportSuccessfulAuthentication_shouldUpdateIsUnlocked() = + fun reportSuccessfulAuthentication_updatesIsUnlocked() = testScope.runTest { val isUnlocked by collectLastValue(underTest.isUnlocked) assertThat(isUnlocked).isFalse() diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractorTest.kt index 910097eece52..ea19cb799b10 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractorTest.kt @@ -19,6 +19,7 @@ package com.android.systemui.deviceentry.domain.interactor import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues @@ -346,12 +347,14 @@ class DeviceEntryInteractorTest : SysuiTestCase() { } @Test - fun successfulAuthenticationChallengeAttempt_updatedIsUnlockedState() = + fun successfulAuthenticationChallengeAttempt_updatesIsUnlockedState() = testScope.runTest { val isUnlocked by collectLastValue(underTest.isUnlocked) + utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + utils.deviceEntryRepository.setLockscreenEnabled(true) assertThat(isUnlocked).isFalse() - utils.authenticationRepository.reportAuthenticationAttempt(true) + authenticationInteractor.authenticate(FakeAuthenticationRepository.DEFAULT_PIN) assertThat(isUnlocked).isTrue() } diff --git a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt index fda23b7f2a9c..f2b55f456f00 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt @@ -24,13 +24,13 @@ import android.os.UserHandle import com.android.internal.widget.LockPatternUtils import com.android.internal.widget.LockscreenCredential import com.android.keyguard.KeyguardSecurityModel -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationResultModel import com.android.systemui.broadcast.BroadcastDispatcher import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.scene.shared.flag.SceneContainerFlags import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository import com.android.systemui.user.data.repository.UserRepository import com.android.systemui.util.kotlin.pairwise @@ -43,9 +43,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -60,12 +58,6 @@ import kotlinx.coroutines.withContext /** Defines interface for classes that can access authentication-related application state. */ interface AuthenticationRepository { /** - * Emits the result whenever a PIN/Pattern/Password security challenge is attempted by the user - * in order to unlock the device. - */ - val authenticationChallengeResult: SharedFlow<Boolean> - - /** * The exact length a PIN should be for us to enable PIN length hinting. * * A PIN that's shorter or longer than this is not eligible for the UI to render hints showing @@ -80,16 +72,6 @@ interface AuthenticationRepository { val isPatternVisible: StateFlow<Boolean> /** - * The current authentication lockout (aka "throttling") state, set when the user has to wait - * before being able to try another authentication attempt. `null` indicates throttling isn't - * active. - */ - val lockout: MutableStateFlow<AuthenticationLockoutModel?> - - /** Whether throttling has occurred at least once since the last successful authentication. */ - val hasLockoutOccurred: MutableStateFlow<Boolean> - - /** * Whether the auto confirm feature is enabled for the currently-selected user. * * Note that the length of the PIN is also important to take into consideration, please see @@ -98,6 +80,28 @@ interface AuthenticationRepository { val isAutoConfirmFeatureEnabled: StateFlow<Boolean> /** + * The number of failed authentication attempts for the selected user since their last + * successful authentication. + */ + val failedAuthenticationAttempts: StateFlow<Int> + + /** + * Timestamp for when the current lockout (aka "throttling") will end, allowing the user to + * attempt authentication again. Returns `null` if no lockout is active. + * + * Note that the value is in milliseconds and matches [SystemClock.elapsedRealtime]. + * + * Also note that the value may change when the selected user is changed. + */ + val lockoutEndTimestamp: Long? + + /** + * Whether lockout has occurred at least once since the last successful authentication of any + * user. + */ + val hasLockoutOccurred: StateFlow<Boolean> + + /** * The currently-configured authentication method. This determines how the authentication * challenge needs to be completed in order to unlock an otherwise locked device. * @@ -142,23 +146,6 @@ interface AuthenticationRepository { /** Reports that the user has entered a temporary device lockout (throttling). */ suspend fun reportLockoutStarted(durationMs: Int) - /** Returns the current number of failed authentication attempts. */ - suspend fun getFailedAuthenticationAttemptCount(): Int - - /** - * Returns the timestamp for when the current lockout will end, allowing the user to attempt - * authentication again. - * - * Note that this is in milliseconds and it matches [SystemClock.elapsedRealtime]. - */ - suspend fun getLockoutEndTimestamp(): Long - - /** - * Sets the lockout timeout duration (time during which the user should not be allowed to - * attempt authentication). - */ - suspend fun setLockoutDuration(durationMs: Int) - /** * Checks the given [LockscreenCredential] to see if it's correct, returning an * [AuthenticationResultModel] representing what happened. @@ -172,6 +159,8 @@ class AuthenticationRepositoryImpl constructor( @Application private val applicationScope: CoroutineScope, @Background private val backgroundDispatcher: CoroutineDispatcher, + flags: SceneContainerFlags, + private val clock: SystemClock, private val getSecurityMode: Function<Int, KeyguardSecurityModel.SecurityMode>, private val userRepository: UserRepository, private val lockPatternUtils: LockPatternUtils, @@ -179,8 +168,6 @@ constructor( mobileConnectionsRepository: MobileConnectionsRepository, ) : AuthenticationRepository { - override val authenticationChallengeResult = MutableSharedFlow<Boolean>() - override val hintedPinLength: Int = 6 override val isPatternVisible: StateFlow<Boolean> = @@ -189,10 +176,6 @@ constructor( getFreshValue = lockPatternUtils::isVisiblePatternEnabled, ) - override val lockout: MutableStateFlow<AuthenticationLockoutModel?> = MutableStateFlow(null) - - override val hasLockoutOccurred: MutableStateFlow<Boolean> = MutableStateFlow(false) - override val isAutoConfirmFeatureEnabled: StateFlow<Boolean> = refreshingFlow( initialValue = false, @@ -234,6 +217,31 @@ constructor( getFreshValue = { userId -> lockPatternUtils.isPinEnhancedPrivacyEnabled(userId) }, ) + private val _failedAuthenticationAttempts = MutableStateFlow(0) + override val failedAuthenticationAttempts: StateFlow<Int> = + _failedAuthenticationAttempts.asStateFlow() + + override val lockoutEndTimestamp: Long? + get() = + lockPatternUtils.getLockoutAttemptDeadline(selectedUserId).takeIf { + clock.elapsedRealtime() < it + } + + private val _hasLockoutOccurred = MutableStateFlow(false) + override val hasLockoutOccurred: StateFlow<Boolean> = _hasLockoutOccurred.asStateFlow() + + init { + if (flags.isEnabled()) { + // Hydrate failedAuthenticationAttempts initially and whenever the selected user + // changes. + applicationScope.launch { + userRepository.selectedUserInfo.collect { + _failedAuthenticationAttempts.value = getFailedAuthenticationAttemptCount() + } + } + } + } + override suspend fun getAuthenticationMethod(): AuthenticationMethodModel { return withContext(backgroundDispatcher) { blockingAuthenticationMethodInternal(selectedUserId) @@ -248,35 +256,20 @@ constructor( withContext(backgroundDispatcher) { if (isSuccessful) { lockPatternUtils.reportSuccessfulPasswordAttempt(selectedUserId) + _hasLockoutOccurred.value = false } else { lockPatternUtils.reportFailedPasswordAttempt(selectedUserId) } - authenticationChallengeResult.emit(isSuccessful) + _failedAuthenticationAttempts.value = getFailedAuthenticationAttemptCount() } } override suspend fun reportLockoutStarted(durationMs: Int) { - return withContext(backgroundDispatcher) { - lockPatternUtils.reportPasswordLockout(durationMs, selectedUserId) - } - } - - override suspend fun getFailedAuthenticationAttemptCount(): Int { - return withContext(backgroundDispatcher) { - lockPatternUtils.getCurrentFailedPasswordAttempts(selectedUserId) - } - } - - override suspend fun getLockoutEndTimestamp(): Long { - return withContext(backgroundDispatcher) { - lockPatternUtils.getLockoutAttemptDeadline(selectedUserId) - } - } - - override suspend fun setLockoutDuration(durationMs: Int) { + lockPatternUtils.setLockoutAttemptDeadline(selectedUserId, durationMs) withContext(backgroundDispatcher) { - lockPatternUtils.setLockoutAttemptDeadline(selectedUserId, durationMs) + lockPatternUtils.reportPasswordLockout(durationMs, selectedUserId) } + _hasLockoutOccurred.value = true } override suspend fun checkCredential( @@ -292,6 +285,12 @@ constructor( } } + private suspend fun getFailedAuthenticationAttemptCount(): Int { + return withContext(backgroundDispatcher) { + lockPatternUtils.getCurrentFailedPasswordAttempts(selectedUserId) + } + } + private val selectedUserId: Int get() = userRepository.getSelectedUserInfo().id diff --git a/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt index 797154e85082..c85ffe6ca56f 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt @@ -16,36 +16,25 @@ package com.android.systemui.authentication.domain.interactor -import com.android.app.tracing.TraceUtils.Companion.withContext import com.android.internal.widget.LockPatternView import com.android.internal.widget.LockscreenCredential import com.android.systemui.authentication.data.repository.AuthenticationRepository -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application -import com.android.systemui.dagger.qualifiers.Background -import com.android.systemui.user.data.repository.UserRepository import com.android.systemui.util.time.SystemClock import javax.inject.Inject -import kotlin.math.ceil -import kotlin.math.max -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch /** * Hosts application business logic related to user authentication. @@ -59,10 +48,7 @@ class AuthenticationInteractor @Inject constructor( @Application private val applicationScope: CoroutineScope, - @Background private val backgroundDispatcher: CoroutineDispatcher, private val repository: AuthenticationRepository, - private val userRepository: UserRepository, - private val clock: SystemClock, ) { /** * The currently-configured authentication method. This determines how the authentication @@ -85,13 +71,6 @@ constructor( val authenticationMethod: Flow<AuthenticationMethodModel> = repository.authenticationMethod /** - * The current authentication lockout (aka "throttling") state, set when the user has to wait - * before being able to try another authentication attempt. `null` indicates lockout isn't - * active. - */ - val lockout: StateFlow<AuthenticationLockoutModel?> = repository.lockout - - /** * Whether the auto confirm feature is enabled for the currently-selected user. * * Note that the length of the PIN is also important to take into consideration, please see @@ -130,26 +109,35 @@ constructor( /** Whether the pattern should be visible for the currently-selected user. */ val isPatternVisible: StateFlow<Boolean> = repository.isPatternVisible + private val _onAuthenticationResult = MutableSharedFlow<Boolean>() /** * Emits the outcome (successful or unsuccessful) whenever a PIN/Pattern/Password security * challenge is attempted by the user in order to unlock the device. */ - val authenticationChallengeResult: SharedFlow<Boolean> = - repository.authenticationChallengeResult + val onAuthenticationResult: SharedFlow<Boolean> = _onAuthenticationResult.asSharedFlow() /** Whether the "enhanced PIN privacy" setting is enabled for the current user. */ val isPinEnhancedPrivacyEnabled: StateFlow<Boolean> = repository.isPinEnhancedPrivacyEnabled - private var lockoutCountdownJob: Job? = null + /** + * The number of failed authentication attempts for the selected user since the last successful + * authentication. + */ + val failedAuthenticationAttempts: StateFlow<Int> = repository.failedAuthenticationAttempts - init { - applicationScope.launch { - userRepository.selectedUserInfo - .map { it.id } - .distinctUntilChanged() - .collect { onSelectedUserChanged() } - } - } + /** + * Timestamp for when the current lockout (aka "throttling") will end, allowing the user to + * attempt authentication again. Returns `null` if no lockout is active. + * + * To be notified whenever a lockout is started, the caller should subscribe to + * [onAuthenticationResult]. + * + * Note that the value is in milliseconds and matches [SystemClock.elapsedRealtime]. + * + * Also note that the value may change when the selected user is changed. + */ + val lockoutEndTimestamp: Long? + get() = repository.lockoutEndTimestamp /** * Returns the currently-configured authentication method. This determines how the @@ -190,7 +178,7 @@ constructor( val skipCheck = when { // Lockout is active, the UI layer should not have called this; skip the attempt. - lockout.value != null -> true + repository.lockoutEndTimestamp != null -> true // The input is too short; skip the attempt. input.isTooShort(authMethod) -> true // Auto-confirm attempt when the feature is not enabled; skip the attempt. @@ -211,27 +199,16 @@ constructor( credential.zeroize() if (authenticationResult.isSuccessful || !tryAutoConfirm) { - repository.reportAuthenticationAttempt( - isSuccessful = authenticationResult.isSuccessful, - ) + repository.reportAuthenticationAttempt(authenticationResult.isSuccessful) } // Check if lockout should start and, if so, kick off the countdown: if (!authenticationResult.isSuccessful && authenticationResult.lockoutDurationMs > 0) { - repository.apply { - setLockoutDuration(durationMs = authenticationResult.lockoutDurationMs) - reportLockoutStarted(durationMs = authenticationResult.lockoutDurationMs) - hasLockoutOccurred.value = true - } - startLockoutCountdown() + repository.reportLockoutStarted(authenticationResult.lockoutDurationMs) } - if (authenticationResult.isSuccessful) { - // Since authentication succeeded, refresh lockout to make sure the state is completely - // reflecting the upstream source of truth. - refreshLockout() - - repository.hasLockoutOccurred.value = false + if (authenticationResult.isSuccessful || !tryAutoConfirm) { + _onAuthenticationResult.emit(authenticationResult.isSuccessful) } return if (authenticationResult.isSuccessful) { @@ -249,54 +226,6 @@ constructor( } } - /** Starts refreshing the lockout state every second. */ - private suspend fun startLockoutCountdown() { - cancelLockoutCountdown() - lockoutCountdownJob = - applicationScope.launch { - while (refreshLockout()) { - delay(1.seconds.inWholeMilliseconds) - } - } - } - - /** Cancels any lockout state countdown started in [startLockoutCountdown]. */ - private fun cancelLockoutCountdown() { - lockoutCountdownJob?.cancel() - lockoutCountdownJob = null - } - - /** Notifies that the currently-selected user has changed. */ - private suspend fun onSelectedUserChanged() { - cancelLockoutCountdown() - if (refreshLockout()) { - startLockoutCountdown() - } - } - - /** - * Refreshes the lockout state, hydrating the repository with the latest state. - * - * @return Whether lockout is active or not. - */ - private suspend fun refreshLockout(): Boolean { - withContext("$TAG#refreshLockout", backgroundDispatcher) { - val failedAttemptCount = async { repository.getFailedAuthenticationAttemptCount() } - val deadline = async { repository.getLockoutEndTimestamp() } - val remainingMs = max(0, deadline.await() - clock.elapsedRealtime()) - repository.lockout.value = - if (remainingMs > 0) { - AuthenticationLockoutModel( - failedAttemptCount = failedAttemptCount.await(), - remainingSeconds = ceil(remainingMs / 1000f).toInt(), - ) - } else { - null // Lockout ended. - } - } - return repository.lockout.value != null - } - private fun AuthenticationMethodModel.createCredential( input: List<Any> ): LockscreenCredential? { 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 724c0fe1e4e4..1095abe24b47 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 @@ -19,7 +19,6 @@ package com.android.systemui.bouncer.domain.interactor import android.content.Context import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.domain.interactor.AuthenticationResult -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.data.repository.BouncerRepository import com.android.systemui.classifier.FalsingClassifier @@ -29,17 +28,15 @@ import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.keyguard.domain.interactor.KeyguardFaceAuthInteractor import com.android.systemui.power.domain.interactor.PowerInteractor import com.android.systemui.res.R -import com.android.systemui.scene.shared.flag.SceneContainerFlags -import com.android.systemui.util.kotlin.pairwise import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch /** Encapsulates business logic and application state accessing use-cases. */ @@ -52,33 +49,12 @@ constructor( private val repository: BouncerRepository, private val authenticationInteractor: AuthenticationInteractor, private val keyguardFaceAuthInteractor: KeyguardFaceAuthInteractor, - flags: SceneContainerFlags, private val falsingInteractor: FalsingInteractor, private val powerInteractor: PowerInteractor, private val simBouncerInteractor: SimBouncerInteractor, ) { - - /** The user-facing message to show in the bouncer. */ - val message: StateFlow<String?> = - combine(repository.message, authenticationInteractor.lockout) { message, lockout -> - messageOrLockoutMessage(message, lockout) - } - .stateIn( - scope = applicationScope, - started = SharingStarted.WhileSubscribed(), - initialValue = - messageOrLockoutMessage( - repository.message.value, - authenticationInteractor.lockout.value, - ) - ) - - /** - * The current authentication lockout (aka "throttling") state, set when the user has to wait - * before being able to try another authentication attempt. `null` indicates lockout isn't - * active. - */ - val lockout: StateFlow<AuthenticationLockoutModel?> = authenticationInteractor.lockout + /** The user-facing message to show in the bouncer when lockout is not active. */ + val message: StateFlow<String?> = repository.message /** Whether the auto confirm feature is enabled for the currently-selected user. */ val isAutoConfirmEnabled: StateFlow<Boolean> = authenticationInteractor.isAutoConfirmEnabled @@ -101,18 +77,13 @@ constructor( /** Emits a [Unit] each time the IME (keyboard) is hidden by the user. */ val onImeHiddenByUser: SharedFlow<Unit> = _onImeHiddenByUser - init { - if (flags.isEnabled()) { - // Clear the message if moved from locked-out to no-longer locked-out. - applicationScope.launch { - lockout.pairwise().collect { (previous, current) -> - if (previous != null && current == null) { - clearMessage() - } - } + /** Emits a [Unit] each time a lockout is started for the selected user. */ + val onLockoutStarted: Flow<Unit> = + authenticationInteractor.onAuthenticationResult + .filter { successfullyAuthenticated -> + !successfullyAuthenticated && authenticationInteractor.lockoutEndTimestamp != null } - } - } + .map {} /** Notifies that the user has places down a pointer, not necessarily dragging just yet. */ fun onDown() { @@ -188,7 +159,7 @@ constructor( } if (authenticationInteractor.getAuthenticationMethod() == AuthenticationMethodModel.Sim) { - // We authenticate sim in SimInteractor + // SIM is authenticated in SimBouncerInteractor. return AuthenticationResult.SKIPPED } @@ -196,18 +167,20 @@ constructor( // 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) - if ( - authResult == AuthenticationResult.FAILED || - (authResult == AuthenticationResult.SKIPPED && !tryAutoConfirm) - ) { - showErrorMessage() - } - authResult - } - .await() + val authResult = + applicationScope + .async { authenticationInteractor.authenticate(input, tryAutoConfirm) } + .await() + + if (authenticationInteractor.lockoutEndTimestamp != null) { + clearMessage() + } else if ( + authResult == AuthenticationResult.FAILED || + (authResult == AuthenticationResult.SKIPPED && !tryAutoConfirm) + ) { + showErrorMessage() + } + return authResult } /** @@ -250,19 +223,4 @@ constructor( else -> "" } } - - private fun messageOrLockoutMessage( - message: String?, - lockoutModel: AuthenticationLockoutModel?, - ): String { - return when { - lockoutModel != null -> - applicationContext.getString( - com.android.internal.R.string.lockscreen_too_many_failed_attempts_countdown, - lockoutModel.remainingSeconds, - ) - message != null -> message - else -> "" - } - } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt index 4b1434323886..be6cf85a5a0e 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -19,6 +19,7 @@ package com.android.systemui.bouncer.ui.viewmodel import android.content.Context import android.graphics.Bitmap import androidx.core.graphics.drawable.toBitmap +import com.android.internal.R import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerActionButtonInteractor @@ -34,19 +35,24 @@ import com.android.systemui.scene.shared.flag.SceneContainerFlags import com.android.systemui.user.ui.viewmodel.UserActionViewModel import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel import com.android.systemui.user.ui.viewmodel.UserViewModel +import com.android.systemui.util.time.SystemClock import dagger.Module import dagger.Provides +import kotlin.math.ceil +import kotlin.math.max +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow 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.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.job @@ -58,13 +64,14 @@ class BouncerViewModel( @Application private val applicationScope: CoroutineScope, @Main private val mainDispatcher: CoroutineDispatcher, private val bouncerInteractor: BouncerInteractor, - authenticationInteractor: AuthenticationInteractor, + private val authenticationInteractor: AuthenticationInteractor, flags: SceneContainerFlags, selectedUser: Flow<UserViewModel>, users: Flow<List<UserViewModel>>, userSwitcherMenu: Flow<List<UserActionViewModel>>, actionButtonInteractor: BouncerActionButtonInteractor, private val simBouncerInteractor: SimBouncerInteractor, + private val clock: SystemClock, ) { val selectedUserImage: StateFlow<Bitmap?> = selectedUser @@ -104,15 +111,6 @@ class BouncerViewModel( val isUserSwitcherVisible: Boolean get() = bouncerInteractor.isUserSwitcherVisible - private val isInputEnabled: StateFlow<Boolean> = - bouncerInteractor.lockout - .map { it == null } - .stateIn( - scope = applicationScope, - started = SharingStarted.WhileSubscribed(), - initialValue = bouncerInteractor.lockout.value == null, - ) - // Handle to the scope of the child ViewModel (stored in [authMethod]). private var childViewModelScope: CoroutineScope? = null private val _dialogMessage = MutableStateFlow<String?>(null) @@ -138,19 +136,22 @@ class BouncerViewModel( */ val dialogMessage: StateFlow<String?> = _dialogMessage.asStateFlow() + /** + * A message shown when the user has attempted the wrong credential too many times and now must + * wait a while before attempting to authenticate again. + * + * This is updated every second (countdown) during the lockout duration. When lockout is not + * active, this is `null` and no lockout message should be shown. + */ + private val lockoutMessage = MutableStateFlow<String?>(null) + /** The user-facing message to show in the bouncer. */ val message: StateFlow<MessageViewModel> = - combine(bouncerInteractor.message, bouncerInteractor.lockout) { message, lockout -> - toMessageViewModel(message, isLockedOut = lockout != null) - } + combine(bouncerInteractor.message, lockoutMessage) { _, _ -> createMessageViewModel() } .stateIn( scope = applicationScope, started = SharingStarted.WhileSubscribed(), - initialValue = - toMessageViewModel( - message = bouncerInteractor.message.value, - isLockedOut = bouncerInteractor.lockout.value != null, - ), + initialValue = createMessageViewModel(), ) /** @@ -194,31 +195,78 @@ class BouncerViewModel( initialValue = isFoldSplitRequired(authMethodViewModel.value), ) + private val isInputEnabled: StateFlow<Boolean> = + lockoutMessage + .map { it == null } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = authenticationInteractor.lockoutEndTimestamp == null, + ) + + private var lockoutCountdownJob: Job? = null + init { if (flags.isEnabled()) { applicationScope.launch { - combine(bouncerInteractor.lockout, authMethodViewModel) { - lockout, - authMethodViewModel -> - if (lockout != null && authMethodViewModel != null) { + bouncerInteractor.onLockoutStarted.collect { + showLockoutDialog() + startLockoutCountdown() + } + } + + applicationScope.launch { + // Update the lockout countdown whenever the selected user is switched. + selectedUser.collect { startLockoutCountdown() } + } + } + } + + /** Notifies that the dialog has been dismissed by the user. */ + fun onDialogDismissed() { + _dialogMessage.value = null + } + + private fun showLockoutDialog() { + applicationScope.launch { + val failedAttempts = authenticationInteractor.failedAuthenticationAttempts.value + _dialogMessage.value = + authMethodViewModel.value?.lockoutMessageId?.let { messageId -> + applicationContext.getString( + messageId, + failedAttempts, + remainingLockoutSeconds() + ) + } + } + } + + /** Shows the countdown message and refreshes it every second. */ + private fun startLockoutCountdown() { + lockoutCountdownJob?.cancel() + lockoutCountdownJob = + applicationScope.launch { + do { + val remainingSeconds = remainingLockoutSeconds() + lockoutMessage.value = + if (remainingSeconds > 0) { applicationContext.getString( - authMethodViewModel.lockoutMessageId, - lockout.failedAttemptCount, - lockout.remainingSeconds, + R.string.lockscreen_too_many_failed_attempts_countdown, + remainingSeconds, ) } else { null } - } - .distinctUntilChanged() - .collect { dialogMessage -> _dialogMessage.value = dialogMessage } + delay(1.seconds) + } while (remainingSeconds > 0) + lockoutCountdownJob = null } - } } - /** Notifies that the dialog has been dismissed by the user. */ - fun onDialogDismissed() { - _dialogMessage.value = null + private fun remainingLockoutSeconds(): Int { + val endTimestampMs = authenticationInteractor.lockoutEndTimestamp ?: 0 + val remainingMs = max(0, endTimestampMs - clock.elapsedRealtime()) + return ceil(remainingMs / 1000f).toInt() } private fun isSideBySideSupported(authMethod: AuthMethodBouncerViewModel?): Boolean { @@ -229,12 +277,11 @@ class BouncerViewModel( return authMethod !is PasswordBouncerViewModel } - private fun toMessageViewModel( - message: String?, - isLockedOut: Boolean, - ): MessageViewModel { + private fun createMessageViewModel(): MessageViewModel { + val isLockedOut = lockoutMessage.value != null return MessageViewModel( - text = message ?: "", + // A lockout message takes precedence over the non-lockout message. + text = lockoutMessage.value ?: bouncerInteractor.message.value ?: "", isUpdateAnimated = !isLockedOut, ) } @@ -328,6 +375,7 @@ object BouncerViewModelModule { userSwitcherViewModel: UserSwitcherViewModel, actionButtonInteractor: BouncerActionButtonInteractor, simBouncerInteractor: SimBouncerInteractor, + clock: SystemClock, ): BouncerViewModel { return BouncerViewModel( applicationContext = applicationContext, @@ -341,6 +389,7 @@ object BouncerViewModelModule { userSwitcherMenu = userSwitcherViewModel.menu, actionButtonInteractor = actionButtonInteractor, simBouncerInteractor = simBouncerInteractor, + clock = clock, ) } } 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 b68271767fc2..8e14778f1aa7 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 @@ -56,13 +56,11 @@ class PasswordBouncerViewModel( /** Whether the UI should request focus on the text field element. */ val isTextFieldFocusRequested = - combine(interactor.lockout, isTextFieldFocused) { throttling, hasFocus -> - throttling == null && !hasFocus - } + combine(isInputEnabled, isTextFieldFocused) { hasInput, hasFocus -> hasInput && !hasFocus } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), - initialValue = interactor.lockout.value == null && !isTextFieldFocused.value, + initialValue = isInputEnabled.value && !isTextFieldFocused.value, ) override fun onHidden() { @@ -104,7 +102,7 @@ class PasswordBouncerViewModel( * hidden. */ suspend fun onImeVisibilityChanged(isVisible: Boolean) { - if (isImeVisible && !isVisible && interactor.lockout.value == null) { + if (isImeVisible && !isVisible && isInputEnabled.value) { interactor.onImeHiddenByUser() } diff --git a/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractor.kt b/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractor.kt index 47be8ab0c0a2..f6a9570fc94c 100644 --- a/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryInteractor.kt @@ -159,7 +159,7 @@ constructor( fun attemptDeviceEntry() { // TODO (b/307768356), // 1. Check if the device is already authenticated by trust agent/passive biometrics - // 2. show SPFS/UDFPS bouncer if it is available AlternateBouncerInteractor.show + // 2. Show SPFS/UDFPS bouncer if it is available AlternateBouncerInteractor.show // 3. For face auth only setups trigger face auth, delay transitioning to bouncer for // a small amount of time. // 4. Transition to bouncer scene @@ -197,8 +197,8 @@ constructor( init { if (flags.isEnabled()) { applicationScope.launch { - authenticationInteractor.authenticationChallengeResult.collectLatest { successful -> - if (successful) { + authenticationInteractor.onAuthenticationResult.collectLatest { isSuccessful -> + if (isSuccessful) { repository.reportSuccessfulAuthentication() } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/data/repository/FakeAuthenticationRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/data/repository/FakeAuthenticationRepository.kt index 7c5696c716d8..848650810582 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/data/repository/FakeAuthenticationRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/data/repository/FakeAuthenticationRepository.kt @@ -20,7 +20,6 @@ import com.android.internal.widget.LockPatternUtils import com.android.internal.widget.LockPatternView import com.android.internal.widget.LockscreenCredential import com.android.keyguard.KeyguardSecurityModel.SecurityMode -import com.android.systemui.authentication.shared.model.AuthenticationLockoutModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate import com.android.systemui.authentication.shared.model.AuthenticationResultModel @@ -29,7 +28,6 @@ import dagger.Binds import dagger.Module import dagger.Provides import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -40,15 +38,11 @@ class FakeAuthenticationRepository( private val currentTime: () -> Long, ) : AuthenticationRepository { - override val authenticationChallengeResult = MutableSharedFlow<Boolean>() - override val hintedPinLength: Int = HINTING_PIN_LENGTH private val _isPatternVisible = MutableStateFlow(true) override val isPatternVisible: StateFlow<Boolean> = _isPatternVisible.asStateFlow() - override val lockout: MutableStateFlow<AuthenticationLockoutModel?> = MutableStateFlow(null) - override val hasLockoutOccurred = MutableStateFlow(false) private val _isAutoConfirmFeatureEnabled = MutableStateFlow(false) @@ -68,8 +62,6 @@ class FakeAuthenticationRepository( override val isPinEnhancedPrivacyEnabled: StateFlow<Boolean> = _isPinEnhancedPrivacyEnabled.asStateFlow() - private var failedAttemptCount = 0 - private var lockoutEndTimestamp = 0L private var credentialOverride: List<Any>? = null private var securityMode: SecurityMode = DEFAULT_AUTHENTICATION_METHOD.toSecurityMode() @@ -89,11 +81,27 @@ class FakeAuthenticationRepository( } override suspend fun reportAuthenticationAttempt(isSuccessful: Boolean) { - failedAttemptCount = if (isSuccessful) 0 else failedAttemptCount + 1 - authenticationChallengeResult.emit(isSuccessful) + if (isSuccessful) { + _failedAuthenticationAttempts.value = 0 + _lockoutEndTimestamp = null + hasLockoutOccurred.value = false + lockoutStartedReportCount = 0 + } else { + _failedAuthenticationAttempts.value++ + } } + private var _failedAuthenticationAttempts = MutableStateFlow(0) + override val failedAuthenticationAttempts: StateFlow<Int> = + _failedAuthenticationAttempts.asStateFlow() + + private var _lockoutEndTimestamp: Long? = null + override val lockoutEndTimestamp: Long? + get() = if (currentTime() < (_lockoutEndTimestamp ?: 0)) _lockoutEndTimestamp else null + override suspend fun reportLockoutStarted(durationMs: Int) { + _lockoutEndTimestamp = (currentTime() + durationMs).takeIf { durationMs > 0 } + hasLockoutOccurred.value = true lockoutStartedReportCount++ } @@ -101,25 +109,10 @@ class FakeAuthenticationRepository( return (credentialOverride ?: DEFAULT_PIN).size } - override suspend fun getFailedAuthenticationAttemptCount(): Int { - return failedAttemptCount - } - - override suspend fun getLockoutEndTimestamp(): Long { - return lockoutEndTimestamp - } - fun setAutoConfirmFeatureEnabled(isEnabled: Boolean) { _isAutoConfirmFeatureEnabled.value = isEnabled } - override suspend fun setLockoutDuration(durationMs: Int) { - lockoutEndTimestamp = if (durationMs > 0) currentTime() + durationMs else 0 - if (durationMs > 0) { - hasLockoutOccurred.value = true - } - } - override suspend fun checkCredential( credential: LockscreenCredential ): AuthenticationResultModel { @@ -136,8 +129,8 @@ class FakeAuthenticationRepository( else -> error("Unexpected credential type ${credential.type}!") } - return if (isSuccessful || failedAttemptCount < MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) { - hasLockoutOccurred.value = false + val failedAttempts = _failedAuthenticationAttempts.value + return if (isSuccessful || failedAttempts < MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT - 1) { AuthenticationResultModel( isSuccessful = isSuccessful, lockoutDurationMs = 0, diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorKosmos.kt index 060ca4c7e912..05cb059a00cd 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorKosmos.kt @@ -19,17 +19,11 @@ package com.android.systemui.authentication.domain.interactor import com.android.systemui.authentication.data.repository.authenticationRepository import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.applicationCoroutineScope -import com.android.systemui.kosmos.testDispatcher -import com.android.systemui.user.data.repository.userRepository -import com.android.systemui.util.time.fakeSystemClock val Kosmos.authenticationInteractor by Kosmos.Fixture { AuthenticationInteractor( applicationScope = applicationCoroutineScope, repository = authenticationRepository, - backgroundDispatcher = testDispatcher, - userRepository = userRepository, - clock = fakeSystemClock, ) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt index 0b41926ed13e..25b97b3dab5d 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt @@ -91,7 +91,6 @@ import com.android.systemui.telephony.data.repository.FakeTelephonyRepository import com.android.systemui.telephony.data.repository.TelephonyRepository import com.android.systemui.telephony.domain.interactor.TelephonyInteractor import com.android.systemui.user.data.repository.FakeUserRepository -import com.android.systemui.user.data.repository.UserRepository import com.android.systemui.user.domain.interactor.SelectedUserInteractor import com.android.systemui.user.ui.viewmodel.UserActionViewModel import com.android.systemui.user.ui.viewmodel.UserViewModel @@ -174,7 +173,7 @@ class SceneTestUtils( mobileConnectionsRepository = mobileConnectionsRepository, ) - val userRepository: UserRepository by lazy { + val userRepository: FakeUserRepository by lazy { FakeUserRepository().apply { val users = listOf(UserInfo(/* id= */ 0, "name", /* flags= */ 0)) setUserInfos(users) @@ -236,9 +235,6 @@ class SceneTestUtils( return AuthenticationInteractor( applicationScope = applicationScope(), repository = repository, - backgroundDispatcher = testDispatcher, - userRepository = userRepository, - clock = mock { whenever(elapsedRealtime()).thenAnswer { testScope.currentTime } } ) } @@ -274,7 +270,6 @@ class SceneTestUtils( repository = bouncerRepository, authenticationInteractor = authenticationInteractor, keyguardFaceAuthInteractor = keyguardFaceAuthInteractor, - flags = sceneContainerFlags, falsingInteractor = falsingInteractor(), powerInteractor = powerInteractor(), simBouncerInteractor = simBouncerInteractor, @@ -312,6 +307,7 @@ class SceneTestUtils( userSwitcherMenu = flowOf(createMenuActions()), actionButtonInteractor = actionButtonInteractor, simBouncerInteractor = simBouncerInteractor, + clock = mock { whenever(elapsedRealtime()).thenAnswer { testScope.currentTime } }, ) } |