[flexiglass] Foundation for placeholder bouncer scene.
Foundational parts of the bouncer scene including application state (which is still not hooked up to real sources of truth), business logic, and a view-model that's only good enough for placeholder scene UI. Bug: 280877228 Test: unit tests included. Manually tested with the entire relation chain in the Compose Gallery testdbed app. Change-Id: I54449898006489fd02d1f318646d4e5317256077
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.data.repo
|
||||
|
||||
import com.android.systemui.dagger.SysUISingleton
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** Provides access to bouncer-related application state. */
|
||||
@SysUISingleton
|
||||
class BouncerRepository @Inject constructor() {
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
/** The user-facing message to show in the bouncer. */
|
||||
val message: StateFlow<String?> = _message.asStateFlow()
|
||||
|
||||
fun setMessage(message: String?) {
|
||||
_message.value = message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.domain.interactor
|
||||
|
||||
import android.content.Context
|
||||
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.dagger.qualifiers.Application
|
||||
import com.android.systemui.scene.domain.interactor.SceneInteractor
|
||||
import com.android.systemui.scene.shared.model.SceneKey
|
||||
import com.android.systemui.scene.shared.model.SceneModel
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Encapsulates business logic and application state accessing use-cases. */
|
||||
class BouncerInteractor
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Application private val applicationScope: CoroutineScope,
|
||||
@Application private val applicationContext: Context,
|
||||
private val repository: BouncerRepository,
|
||||
private val authenticationInteractor: AuthenticationInteractor,
|
||||
private val sceneInteractor: SceneInteractor,
|
||||
@Assisted private val containerName: String,
|
||||
) {
|
||||
|
||||
/** The user-facing message to show in the bouncer. */
|
||||
val message: StateFlow<String?> = repository.message
|
||||
|
||||
/**
|
||||
* The currently-configured authentication method. This determines how the authentication
|
||||
* challenge is completed in order to unlock an otherwise locked device.
|
||||
*/
|
||||
val authenticationMethod: StateFlow<AuthenticationMethodModel> =
|
||||
authenticationInteractor.authenticationMethod
|
||||
|
||||
init {
|
||||
applicationScope.launch {
|
||||
combine(
|
||||
sceneInteractor.currentScene(containerName),
|
||||
authenticationInteractor.authenticationMethod,
|
||||
::Pair,
|
||||
)
|
||||
.collect { (currentScene, authMethod) ->
|
||||
if (currentScene.key == SceneKey.Bouncer) {
|
||||
when (authMethod) {
|
||||
is AuthenticationMethodModel.None ->
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName,
|
||||
SceneModel(SceneKey.Gone),
|
||||
)
|
||||
is AuthenticationMethodModel.Swipe ->
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName,
|
||||
SceneModel(SceneKey.LockScreen),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Either shows the bouncer or unlocks the device, if the bouncer doesn't need to be shown.
|
||||
*
|
||||
* @param containerName The name of the scene container to show the bouncer in.
|
||||
* @param message An optional message to show to the user in the bouncer.
|
||||
*/
|
||||
fun showOrUnlockDevice(
|
||||
containerName: String,
|
||||
message: String? = null,
|
||||
) {
|
||||
if (authenticationInteractor.isAuthenticationRequired()) {
|
||||
repository.setMessage(message ?: promptMessage(authenticationMethod.value))
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName = containerName,
|
||||
scene = SceneModel(SceneKey.Bouncer),
|
||||
)
|
||||
} else {
|
||||
authenticationInteractor.unlockDevice()
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName = containerName,
|
||||
scene = SceneModel(SceneKey.Gone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the user-facing message back to the default according to the current authentication
|
||||
* method.
|
||||
*/
|
||||
fun resetMessage() {
|
||||
repository.setMessage(promptMessage(authenticationMethod.value))
|
||||
}
|
||||
|
||||
/** Removes the user-facing message. */
|
||||
fun clearMessage() {
|
||||
repository.setMessage(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to authenticate based on the given user input.
|
||||
*
|
||||
* If the input is correct, the device will be unlocked and the lock screen and bouncer will be
|
||||
* dismissed and hidden.
|
||||
*/
|
||||
fun authenticate(
|
||||
input: List<Any>,
|
||||
) {
|
||||
val isAuthenticated = authenticationInteractor.authenticate(input)
|
||||
if (isAuthenticated) {
|
||||
sceneInteractor.setCurrentScene(
|
||||
containerName = containerName,
|
||||
scene = SceneModel(SceneKey.Gone),
|
||||
)
|
||||
} else {
|
||||
repository.setMessage(errorMessage(authenticationMethod.value))
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptMessage(authMethod: AuthenticationMethodModel): String {
|
||||
return when (authMethod) {
|
||||
is AuthenticationMethodModel.PIN ->
|
||||
applicationContext.getString(R.string.keyguard_enter_your_pin)
|
||||
is AuthenticationMethodModel.Password ->
|
||||
applicationContext.getString(R.string.keyguard_enter_your_password)
|
||||
is AuthenticationMethodModel.Pattern ->
|
||||
applicationContext.getString(R.string.keyguard_enter_your_pattern)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun errorMessage(authMethod: AuthenticationMethodModel): String {
|
||||
return when (authMethod) {
|
||||
is AuthenticationMethodModel.PIN -> applicationContext.getString(R.string.kg_wrong_pin)
|
||||
is AuthenticationMethodModel.Password ->
|
||||
applicationContext.getString(R.string.kg_wrong_password)
|
||||
is AuthenticationMethodModel.Pattern ->
|
||||
applicationContext.getString(R.string.kg_wrong_pattern)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
containerName: String,
|
||||
): BouncerInteractor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.ui.viewmodel
|
||||
|
||||
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
|
||||
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
|
||||
import com.android.systemui.dagger.qualifiers.Application
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/** Holds UI state and handles user input on bouncer UIs. */
|
||||
class BouncerViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Application private val applicationScope: CoroutineScope,
|
||||
interactorFactory: BouncerInteractor.Factory,
|
||||
containerName: String,
|
||||
) {
|
||||
private val interactor: BouncerInteractor = interactorFactory.create(containerName)
|
||||
|
||||
/** The user-facing message to show in the bouncer. */
|
||||
val message: StateFlow<String> =
|
||||
interactor.message
|
||||
.map { it ?: "" }
|
||||
.stateIn(
|
||||
scope = applicationScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = interactor.message.value ?: "",
|
||||
)
|
||||
|
||||
/** Notifies that the authenticate button was clicked. */
|
||||
fun onAuthenticateButtonClicked() {
|
||||
// TODO(b/280877228): remove this and send the real input.
|
||||
interactor.authenticate(
|
||||
when (interactor.authenticationMethod.value) {
|
||||
is AuthenticationMethodModel.PIN -> listOf(1, 2, 3, 4)
|
||||
is AuthenticationMethodModel.Password -> "password".toList()
|
||||
is AuthenticationMethodModel.Pattern ->
|
||||
listOf(
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(2, 0),
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(2, 1),
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(2, 2),
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(1, 1),
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(0, 0),
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(0, 1),
|
||||
AuthenticationMethodModel.Pattern.PatternCoordinate(0, 2),
|
||||
)
|
||||
else -> emptyList()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Notifies that the emergency services button was clicked. */
|
||||
fun onEmergencyServicesButtonClicked() {
|
||||
// TODO(b/280877228): implement this.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.domain.interactor
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl
|
||||
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.coroutines.collectLastValue
|
||||
import com.android.systemui.scene.data.repository.fakeSceneContainerRepository
|
||||
import com.android.systemui.scene.domain.interactor.SceneInteractor
|
||||
import com.android.systemui.scene.shared.model.SceneKey
|
||||
import com.android.systemui.scene.shared.model.SceneModel
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@SmallTest
|
||||
@RunWith(JUnit4::class)
|
||||
class BouncerInteractorTest : SysuiTestCase() {
|
||||
|
||||
private val testScope = TestScope()
|
||||
private val authenticationInteractor =
|
||||
AuthenticationInteractor(
|
||||
applicationScope = testScope.backgroundScope,
|
||||
repository = AuthenticationRepositoryImpl(),
|
||||
)
|
||||
private val sceneInteractor =
|
||||
SceneInteractor(
|
||||
repository = fakeSceneContainerRepository(),
|
||||
)
|
||||
private val underTest =
|
||||
BouncerInteractor(
|
||||
applicationScope = testScope.backgroundScope,
|
||||
applicationContext = context,
|
||||
repository = BouncerRepository(),
|
||||
authenticationInteractor = authenticationInteractor,
|
||||
sceneInteractor = sceneInteractor,
|
||||
containerName = "container1",
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
overrideResource(R.string.keyguard_enter_your_pin, MESSAGE_ENTER_YOUR_PIN)
|
||||
overrideResource(R.string.keyguard_enter_your_password, MESSAGE_ENTER_YOUR_PASSWORD)
|
||||
overrideResource(R.string.keyguard_enter_your_pattern, MESSAGE_ENTER_YOUR_PATTERN)
|
||||
overrideResource(R.string.kg_wrong_pin, MESSAGE_WRONG_PIN)
|
||||
overrideResource(R.string.kg_wrong_password, MESSAGE_WRONG_PASSWORD)
|
||||
overrideResource(R.string.kg_wrong_pattern, MESSAGE_WRONG_PATTERN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinAuthMethod() =
|
||||
testScope.runTest {
|
||||
val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
|
||||
val message by collectLastValue(underTest.message)
|
||||
|
||||
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
|
||||
authenticationInteractor.lockDevice()
|
||||
underTest.showOrUnlockDevice("container1")
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
|
||||
|
||||
underTest.clearMessage()
|
||||
assertThat(message).isNull()
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
|
||||
|
||||
// Wrong input.
|
||||
underTest.authenticate(listOf(9, 8, 7))
|
||||
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
|
||||
|
||||
// Correct input.
|
||||
underTest.authenticate(listOf(1, 2, 3, 4))
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordAuthMethod() =
|
||||
testScope.runTest {
|
||||
val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
|
||||
val message by collectLastValue(underTest.message)
|
||||
authenticationInteractor.setAuthenticationMethod(
|
||||
AuthenticationMethodModel.Password("password")
|
||||
)
|
||||
authenticationInteractor.lockDevice()
|
||||
underTest.showOrUnlockDevice("container1")
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
|
||||
|
||||
underTest.clearMessage()
|
||||
assertThat(message).isNull()
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
|
||||
|
||||
// Wrong input.
|
||||
underTest.authenticate("alohamora".toList())
|
||||
assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
|
||||
|
||||
// Correct input.
|
||||
underTest.authenticate("password".toList())
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternAuthMethod() =
|
||||
testScope.runTest {
|
||||
val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
|
||||
val message by collectLastValue(underTest.message)
|
||||
authenticationInteractor.setAuthenticationMethod(
|
||||
AuthenticationMethodModel.Pattern(emptyList())
|
||||
)
|
||||
authenticationInteractor.lockDevice()
|
||||
underTest.showOrUnlockDevice("container1")
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
|
||||
|
||||
underTest.clearMessage()
|
||||
assertThat(message).isNull()
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
|
||||
|
||||
// Wrong input.
|
||||
underTest.authenticate(
|
||||
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
|
||||
)
|
||||
assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
|
||||
underTest.resetMessage()
|
||||
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
|
||||
|
||||
// Correct input.
|
||||
underTest.authenticate(emptyList())
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showOrUnlockDevice_notLocked_switchesToGoneScene() =
|
||||
testScope.runTest {
|
||||
val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
|
||||
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
|
||||
authenticationInteractor.unlockDevice()
|
||||
runCurrent()
|
||||
|
||||
underTest.showOrUnlockDevice("container1")
|
||||
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showOrUnlockDevice_authMethodNotSecure_switchesToGoneScene() =
|
||||
testScope.runTest {
|
||||
val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
|
||||
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
|
||||
authenticationInteractor.lockDevice()
|
||||
|
||||
underTest.showOrUnlockDevice("container1")
|
||||
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showOrUnlockDevice_customMessageShown() =
|
||||
testScope.runTest {
|
||||
val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
|
||||
val message by collectLastValue(underTest.message)
|
||||
authenticationInteractor.setAuthenticationMethod(
|
||||
AuthenticationMethodModel.Password("password")
|
||||
)
|
||||
authenticationInteractor.lockDevice()
|
||||
|
||||
val customMessage = "Hello there!"
|
||||
underTest.showOrUnlockDevice("container1", customMessage)
|
||||
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
assertThat(message).isEqualTo(customMessage)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MESSAGE_ENTER_YOUR_PIN = "Enter your PIN"
|
||||
private const val MESSAGE_ENTER_YOUR_PASSWORD = "Enter your password"
|
||||
private const val MESSAGE_ENTER_YOUR_PATTERN = "Enter your pattern"
|
||||
private const val MESSAGE_WRONG_PIN = "Wrong PIN"
|
||||
private const val MESSAGE_WRONG_PASSWORD = "Wrong password"
|
||||
private const val MESSAGE_WRONG_PATTERN = "Wrong pattern"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user