Merge "[flexiglass] Bouncer throttling - data and domain layers." into udc-dev
This commit is contained in:
@@ -5129,4 +5129,5 @@
|
||||
<java-symbol type="style" name="ThemeOverlay.DeviceDefault.Dark.ActionBar.Accent" />
|
||||
|
||||
<java-symbol type="drawable" name="focus_event_pressed_key_background" />
|
||||
<java-symbol type="string" name="lockscreen_too_many_failed_attempts_countdown" />
|
||||
</resources>
|
||||
|
||||
@@ -51,6 +51,12 @@ interface AuthenticationRepository {
|
||||
*/
|
||||
val isBypassEnabled: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Number of consecutively failed authentication attempts. This resets to `0` when
|
||||
* authentication succeeds.
|
||||
*/
|
||||
val failedAuthenticationAttempts: StateFlow<Int>
|
||||
|
||||
/** See [isUnlocked]. */
|
||||
fun setUnlocked(isUnlocked: Boolean)
|
||||
|
||||
@@ -59,6 +65,9 @@ interface AuthenticationRepository {
|
||||
|
||||
/** See [isBypassEnabled]. */
|
||||
fun setBypassEnabled(isBypassEnabled: Boolean)
|
||||
|
||||
/** See [failedAuthenticationAttempts]. */
|
||||
fun setFailedAuthenticationAttempts(failedAuthenticationAttempts: Int)
|
||||
}
|
||||
|
||||
class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationRepository {
|
||||
@@ -75,6 +84,10 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit
|
||||
private val _isBypassEnabled = MutableStateFlow(false)
|
||||
override val isBypassEnabled: StateFlow<Boolean> = _isBypassEnabled.asStateFlow()
|
||||
|
||||
private val _failedAuthenticationAttempts = MutableStateFlow(0)
|
||||
override val failedAuthenticationAttempts: StateFlow<Int> =
|
||||
_failedAuthenticationAttempts.asStateFlow()
|
||||
|
||||
override fun setUnlocked(isUnlocked: Boolean) {
|
||||
_isUnlocked.value = isUnlocked
|
||||
}
|
||||
@@ -86,6 +99,10 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit
|
||||
override fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) {
|
||||
_authenticationMethod.value = authenticationMethod
|
||||
}
|
||||
|
||||
override fun setFailedAuthenticationAttempts(failedAuthenticationAttempts: Int) {
|
||||
_failedAuthenticationAttempts.value = failedAuthenticationAttempts
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -75,6 +75,12 @@ constructor(
|
||||
*/
|
||||
val isBypassEnabled: StateFlow<Boolean> = repository.isBypassEnabled
|
||||
|
||||
/**
|
||||
* Number of consecutively failed authentication attempts. This resets to `0` when
|
||||
* authentication succeeds.
|
||||
*/
|
||||
val failedAuthenticationAttempts: StateFlow<Int> = repository.failedAuthenticationAttempts
|
||||
|
||||
init {
|
||||
// UNLOCKS WHEN AUTH METHOD REMOVED.
|
||||
//
|
||||
@@ -130,7 +136,12 @@ constructor(
|
||||
}
|
||||
|
||||
if (isSuccessful) {
|
||||
repository.setFailedAuthenticationAttempts(0)
|
||||
repository.setUnlocked(true)
|
||||
} else {
|
||||
repository.setFailedAuthenticationAttempts(
|
||||
repository.failedAuthenticationAttempts.value + 1
|
||||
)
|
||||
}
|
||||
|
||||
return isSuccessful
|
||||
|
||||
@@ -36,8 +36,10 @@ sealed class AuthenticationMethodModel(
|
||||
|
||||
data class Password(val password: String) : AuthenticationMethodModel(isSecure = true)
|
||||
|
||||
data class Pattern(val coordinates: List<PatternCoordinate>) :
|
||||
AuthenticationMethodModel(isSecure = true) {
|
||||
data class Pattern(
|
||||
val coordinates: List<PatternCoordinate>,
|
||||
val isPatternVisible: Boolean = true,
|
||||
) : AuthenticationMethodModel(isSecure = true) {
|
||||
|
||||
data class PatternCoordinate(
|
||||
val x: Int,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package com.android.systemui.bouncer.data.repo
|
||||
|
||||
import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel
|
||||
import com.android.systemui.dagger.SysUISingleton
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -29,7 +30,15 @@ class BouncerRepository @Inject constructor() {
|
||||
/** The user-facing message to show in the bouncer. */
|
||||
val message: StateFlow<String?> = _message.asStateFlow()
|
||||
|
||||
private val _throttling = MutableStateFlow<AuthenticationThrottledModel?>(null)
|
||||
/** The current authentication throttling state. If `null`, there's no throttling. */
|
||||
val throttling: StateFlow<AuthenticationThrottledModel?> = _throttling.asStateFlow()
|
||||
|
||||
fun setMessage(message: String?) {
|
||||
_message.value = message
|
||||
}
|
||||
|
||||
fun setThrottling(throttling: AuthenticationThrottledModel?) {
|
||||
_throttling.value = throttling
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
package com.android.systemui.bouncer.domain.interactor
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
|
||||
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
|
||||
import com.android.systemui.bouncer.data.repo.BouncerRepository
|
||||
import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel
|
||||
import com.android.systemui.dagger.qualifiers.Application
|
||||
import com.android.systemui.scene.domain.interactor.SceneInteractor
|
||||
import com.android.systemui.scene.shared.model.SceneKey
|
||||
@@ -29,8 +31,11 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Encapsulates business logic and application state accessing use-cases. */
|
||||
@@ -46,7 +51,22 @@ constructor(
|
||||
) {
|
||||
|
||||
/** The user-facing message to show in the bouncer. */
|
||||
val message: StateFlow<String?> = repository.message
|
||||
val message: StateFlow<String?> =
|
||||
combine(
|
||||
repository.message,
|
||||
repository.throttling,
|
||||
) { message, throttling ->
|
||||
messageOrThrottlingMessage(message, throttling)
|
||||
}
|
||||
.stateIn(
|
||||
scope = applicationScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue =
|
||||
messageOrThrottlingMessage(
|
||||
repository.message.value,
|
||||
repository.throttling.value,
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* The currently-configured authentication method. This determines how the authentication
|
||||
@@ -55,6 +75,9 @@ constructor(
|
||||
val authenticationMethod: StateFlow<AuthenticationMethodModel> =
|
||||
authenticationInteractor.authenticationMethod
|
||||
|
||||
/** The current authentication throttling state. If `null`, there's no throttling. */
|
||||
val throttling: StateFlow<AuthenticationThrottledModel?> = repository.throttling
|
||||
|
||||
init {
|
||||
applicationScope.launch {
|
||||
combine(
|
||||
@@ -129,14 +152,39 @@ constructor(
|
||||
fun authenticate(
|
||||
input: List<Any>,
|
||||
) {
|
||||
if (repository.throttling.value != null) {
|
||||
return
|
||||
}
|
||||
|
||||
val isAuthenticated = authenticationInteractor.authenticate(input)
|
||||
if (isAuthenticated) {
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName = containerName,
|
||||
scene = SceneModel(SceneKey.Gone),
|
||||
)
|
||||
} else {
|
||||
repository.setMessage(errorMessage(authenticationMethod.value))
|
||||
val failedAttempts = authenticationInteractor.failedAuthenticationAttempts.value
|
||||
when {
|
||||
isAuthenticated -> {
|
||||
repository.setThrottling(null)
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName = containerName,
|
||||
scene = SceneModel(SceneKey.Gone),
|
||||
)
|
||||
}
|
||||
failedAttempts >= THROTTLE_AGGRESSIVELY_AFTER || failedAttempts % THROTTLE_EVERY == 0 ->
|
||||
applicationScope.launch {
|
||||
var remainingDurationSec = THROTTLE_DURATION_SEC
|
||||
while (remainingDurationSec > 0) {
|
||||
repository.setThrottling(
|
||||
AuthenticationThrottledModel(
|
||||
failedAttemptCount = failedAttempts,
|
||||
totalDurationSec = THROTTLE_DURATION_SEC,
|
||||
remainingDurationSec = remainingDurationSec,
|
||||
)
|
||||
)
|
||||
remainingDurationSec--
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
repository.setThrottling(null)
|
||||
clearMessage()
|
||||
}
|
||||
else -> repository.setMessage(errorMessage(authenticationMethod.value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +211,31 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageOrThrottlingMessage(
|
||||
message: String?,
|
||||
throttling: AuthenticationThrottledModel?,
|
||||
): String {
|
||||
return when {
|
||||
throttling != null ->
|
||||
applicationContext.getString(
|
||||
com.android.internal.R.string.lockscreen_too_many_failed_attempts_countdown,
|
||||
throttling.remainingDurationSec,
|
||||
)
|
||||
message != null -> message
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
containerName: String,
|
||||
): BouncerInteractor
|
||||
}
|
||||
|
||||
companion object {
|
||||
@VisibleForTesting const val THROTTLE_DURATION_SEC = 30
|
||||
@VisibleForTesting const val THROTTLE_AGGRESSIVELY_AFTER = 15
|
||||
@VisibleForTesting const val THROTTLE_EVERY = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.systemui.bouncer.shared.model
|
||||
|
||||
/**
|
||||
* Models application state for when further authentication attempts are being throttled due to too
|
||||
* many consecutive failed authentication attempts.
|
||||
*/
|
||||
data class AuthenticationThrottledModel(
|
||||
/** Total number of failed attempts so far. */
|
||||
val failedAttemptCount: Int,
|
||||
/** Total amount of time the user has to wait before attempting again. */
|
||||
val totalDurationSec: Int,
|
||||
/** Remaining amount of time the user has to wait before attempting again. */
|
||||
val remainingDurationSec: Int,
|
||||
)
|
||||
@@ -145,50 +145,59 @@ class AuthenticationInteractorTest : SysuiTestCase() {
|
||||
@Test
|
||||
fun authenticate_withCorrectPin_returnsTrueAndUnlocksDevice() =
|
||||
testScope.runTest {
|
||||
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
|
||||
val isUnlocked by collectLastValue(underTest.isUnlocked)
|
||||
underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
|
||||
assertThat(isUnlocked).isFalse()
|
||||
|
||||
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
|
||||
assertThat(isUnlocked).isTrue()
|
||||
assertThat(failedAttemptCount).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticate_withIncorrectPin_returnsFalseAndDoesNotUnlockDevice() =
|
||||
testScope.runTest {
|
||||
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
|
||||
val isUnlocked by collectLastValue(underTest.isUnlocked)
|
||||
underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
|
||||
assertThat(isUnlocked).isFalse()
|
||||
|
||||
assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse()
|
||||
assertThat(isUnlocked).isFalse()
|
||||
assertThat(failedAttemptCount).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticate_withCorrectPassword_returnsTrueAndUnlocksDevice() =
|
||||
testScope.runTest {
|
||||
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
|
||||
val isUnlocked by collectLastValue(underTest.isUnlocked)
|
||||
underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password"))
|
||||
assertThat(isUnlocked).isFalse()
|
||||
|
||||
assertThat(underTest.authenticate("password".toList())).isTrue()
|
||||
assertThat(isUnlocked).isTrue()
|
||||
assertThat(failedAttemptCount).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticate_withIncorrectPassword_returnsFalseAndDoesNotUnlockDevice() =
|
||||
testScope.runTest {
|
||||
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
|
||||
val isUnlocked by collectLastValue(underTest.isUnlocked)
|
||||
underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password"))
|
||||
assertThat(isUnlocked).isFalse()
|
||||
|
||||
assertThat(underTest.authenticate("alohomora".toList())).isFalse()
|
||||
assertThat(isUnlocked).isFalse()
|
||||
assertThat(failedAttemptCount).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticate_withCorrectPattern_returnsTrueAndUnlocksDevice() =
|
||||
testScope.runTest {
|
||||
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
|
||||
val isUnlocked by collectLastValue(underTest.isUnlocked)
|
||||
underTest.setAuthenticationMethod(
|
||||
AuthenticationMethodModel.Pattern(
|
||||
@@ -230,11 +239,13 @@ class AuthenticationInteractorTest : SysuiTestCase() {
|
||||
)
|
||||
.isTrue()
|
||||
assertThat(isUnlocked).isTrue()
|
||||
assertThat(failedAttemptCount).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticate_withIncorrectPattern_returnsFalseAndDoesNotUnlockDevice() =
|
||||
testScope.runTest {
|
||||
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
|
||||
val isUnlocked by collectLastValue(underTest.isUnlocked)
|
||||
underTest.setAuthenticationMethod(
|
||||
AuthenticationMethodModel.Pattern(
|
||||
@@ -276,6 +287,7 @@ class AuthenticationInteractorTest : SysuiTestCase() {
|
||||
)
|
||||
.isFalse()
|
||||
assertThat(isUnlocked).isFalse()
|
||||
assertThat(failedAttemptCount).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.android.systemui.scene.shared.model.SceneModel
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
@@ -75,7 +76,7 @@ class BouncerInteractorTest : SysuiTestCase() {
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
|
||||
|
||||
underTest.clearMessage()
|
||||
assertThat(message).isNull()
|
||||
assertThat(message).isEmpty()
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
|
||||
@@ -107,7 +108,7 @@ class BouncerInteractorTest : SysuiTestCase() {
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
|
||||
|
||||
underTest.clearMessage()
|
||||
assertThat(message).isNull()
|
||||
assertThat(message).isEmpty()
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
|
||||
@@ -139,7 +140,7 @@ class BouncerInteractorTest : SysuiTestCase() {
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
|
||||
|
||||
underTest.clearMessage()
|
||||
assertThat(message).isNull()
|
||||
assertThat(message).isEmpty()
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
|
||||
@@ -201,6 +202,56 @@ class BouncerInteractorTest : SysuiTestCase() {
|
||||
assertThat(message).isEqualTo(customMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun throttling() =
|
||||
testScope.runTest {
|
||||
val throttling by collectLastValue(underTest.throttling)
|
||||
val message by collectLastValue(underTest.message)
|
||||
val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
|
||||
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
|
||||
assertThat(throttling).isNull()
|
||||
assertThat(message).isEqualTo("")
|
||||
assertThat(isUnlocked).isFalse()
|
||||
repeat(BouncerInteractor.THROTTLE_EVERY) { times ->
|
||||
// Wrong PIN.
|
||||
underTest.authenticate(listOf(6, 7, 8, 9))
|
||||
if (times < BouncerInteractor.THROTTLE_EVERY - 1) {
|
||||
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
|
||||
}
|
||||
}
|
||||
assertThat(throttling).isNotNull()
|
||||
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
|
||||
|
||||
// Correct PIN, but throttled, so doesn't unlock:
|
||||
underTest.authenticate(listOf(1, 2, 3, 4))
|
||||
assertThat(isUnlocked).isFalse()
|
||||
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
|
||||
|
||||
throttling?.totalDurationSec?.let { seconds ->
|
||||
repeat(seconds) { time ->
|
||||
advanceTimeBy(1000)
|
||||
val remainingTime = seconds - time - 1
|
||||
if (remainingTime > 0) {
|
||||
assertTryAgainMessage(message, remainingTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(message).isEqualTo("")
|
||||
assertThat(throttling).isNull()
|
||||
assertThat(isUnlocked).isFalse()
|
||||
|
||||
// Correct PIN and no longer throttled so unlocks:
|
||||
underTest.authenticate(listOf(1, 2, 3, 4))
|
||||
assertThat(isUnlocked).isTrue()
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user