From 71754a15563aaabfd16fd13e9c9b4d0c1ed06a89 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Thu, 4 May 2023 15:49:04 -0700 Subject: [PATCH 1/5] [flexiglass] Authentication logic. Creates a System UI "auth" module that can handle logic surrounding application state like device unlocked, the selected authentication method (e.g. PIN, password, pattern), and whether bypass is enabled. Also handles business logic like locking and unlocking the device and making sure to automatically treat the device as unlocked when the authentication method is "None". Bug: 280883900 Test: Unit tests. Manual verification through testbed app. Change-Id: I32b7b58582afda32be40b321a3e3cbd964fabcfa --- .../authentication/AuthenticationModule.kt | 28 ++ .../repository/AuthenticationRepository.kt | 94 ++++++ .../interactor/AuthenticationInteractor.kt | 204 ++++++++++++ .../shared/model/AuthenticationMethodModel.kt | 47 +++ .../systemui/dagger/SystemUIModule.java | 2 + .../AuthenticationInteractorTest.kt | 291 ++++++++++++++++++ 6 files changed, 666 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/authentication/AuthenticationModule.kt create mode 100644 packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt create mode 100644 packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/authentication/AuthenticationModule.kt b/packages/SystemUI/src/com/android/systemui/authentication/AuthenticationModule.kt new file mode 100644 index 0000000000000..7c394a62a5f42 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/authentication/AuthenticationModule.kt @@ -0,0 +1,28 @@ +/* + * 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.authentication + +import com.android.systemui.authentication.data.repository.AuthenticationRepositoryModule +import dagger.Module + +@Module( + includes = + [ + AuthenticationRepositoryModule::class, + ], +) +object AuthenticationModule 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 new file mode 100644 index 0000000000000..cd195f6f9f97f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt @@ -0,0 +1,94 @@ +/* + * 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.authentication.data.repository + +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import dagger.Binds +import dagger.Module +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Defines interface for classes that can access authentication-related application state. */ +interface AuthenticationRepository { + + /** + * Whether the device is unlocked. + * + * A device that is not yet unlocked requires unlocking by completing an authentication + * challenge according to the current authentication method. + * + * Note that this state has no real bearing on whether the lock screen is showing or dismissed. + */ + val isUnlocked: StateFlow + + /** + * The currently-configured authentication method. This determines how the authentication + * challenge is completed in order to unlock an otherwise locked device. + */ + val authenticationMethod: StateFlow + + /** + * Whether lock screen bypass is enabled. When enabled, the lock screen will be automatically + * dismisses once the authentication challenge is completed. For example, completing a biometric + * authentication challenge via face unlock or fingerprint sensor can automatically bypass the + * lock screen. + */ + val isBypassEnabled: StateFlow + + /** See [isUnlocked]. */ + fun setUnlocked(isUnlocked: Boolean) + + /** See [authenticationMethod]. */ + fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) + + /** See [isBypassEnabled]. */ + fun setBypassEnabled(isBypassEnabled: Boolean) +} + +class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationRepository { + // TODO(b/280883900): get data from real data sources in SysUI. + + private val _isUnlocked = MutableStateFlow(false) + override val isUnlocked: StateFlow = _isUnlocked.asStateFlow() + + private val _authenticationMethod = + MutableStateFlow(AuthenticationMethodModel.PIN(1234)) + override val authenticationMethod: StateFlow = + _authenticationMethod.asStateFlow() + + private val _isBypassEnabled = MutableStateFlow(false) + override val isBypassEnabled: StateFlow = _isBypassEnabled.asStateFlow() + + override fun setUnlocked(isUnlocked: Boolean) { + _isUnlocked.value = isUnlocked + } + + override fun setBypassEnabled(isBypassEnabled: Boolean) { + _isBypassEnabled.value = isBypassEnabled + } + + override fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) { + _authenticationMethod.value = authenticationMethod + } +} + +@Module +interface AuthenticationRepositoryModule { + @Binds fun repository(impl: AuthenticationRepositoryImpl): AuthenticationRepository +} 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 new file mode 100644 index 0000000000000..5aea930401d9d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt @@ -0,0 +1,204 @@ +/* + * 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.authentication.domain.interactor + +import com.android.systemui.authentication.data.repository.AuthenticationRepository +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Hosts application business logic related to authentication. */ +@SysUISingleton +class AuthenticationInteractor +@Inject +constructor( + @Application applicationScope: CoroutineScope, + private val repository: AuthenticationRepository, +) { + /** + * The currently-configured authentication method. This determines how the authentication + * challenge is completed in order to unlock an otherwise locked device. + */ + val authenticationMethod: StateFlow = repository.authenticationMethod + + /** + * Whether the device is unlocked. + * + * A device that is not yet unlocked requires unlocking by completing an authentication + * challenge according to the current authentication method. + * + * Note that this state has no real bearing on whether the lock screen is showing or dismissed. + */ + val isUnlocked: StateFlow = + combine(authenticationMethod, repository.isUnlocked) { authMethod, isUnlocked -> + isUnlockedWithAuthMethod( + isUnlocked = isUnlocked, + authMethod = authMethod, + ) + } + .stateIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + initialValue = + isUnlockedWithAuthMethod( + isUnlocked = repository.isUnlocked.value, + authMethod = repository.authenticationMethod.value, + ) + ) + + /** + * Whether lock screen bypass is enabled. When enabled, the lock screen will be automatically + * dismisses once the authentication challenge is completed. For example, completing a biometric + * authentication challenge via face unlock or fingerprint sensor can automatically bypass the + * lock screen. + */ + val isBypassEnabled: StateFlow = repository.isBypassEnabled + + init { + // UNLOCKS WHEN AUTH METHOD REMOVED. + // + // Unlocks the device if the auth method becomes None. + applicationScope.launch { + repository.authenticationMethod.collect { + if (it is AuthenticationMethodModel.None) { + unlockDevice() + } + } + } + } + + /** + * Returns `true` if the device currently requires authentication before content can be viewed; + * `false` if content can be displayed without unlocking first. + */ + fun isAuthenticationRequired(): Boolean { + return !isUnlocked.value && authenticationMethod.value.isSecure + } + + /** + * Unlocks the device, assuming that the authentication challenge has been completed + * successfully. + */ + fun unlockDevice() { + repository.setUnlocked(true) + } + + /** + * Locks the device. From now on, the device will remain locked until [authenticate] is called + * with the correct input. + */ + fun lockDevice() { + repository.setUnlocked(false) + } + + /** + * Attempts to authenticate the user and unlock the device. + * + * @param input The input from the user to try to authenticate with. This can be a list of + * different things, based on the current authentication method. + * @return `true` if the authentication succeeded and the device is now unlocked; `false` + * otherwise. + */ + fun authenticate(input: List): Boolean { + val isSuccessful = + when (val authMethod = this.authenticationMethod.value) { + is AuthenticationMethodModel.PIN -> input.asCode() == authMethod.code + is AuthenticationMethodModel.Password -> input.asPassword() == authMethod.password + is AuthenticationMethodModel.Pattern -> input.asPattern() == authMethod.coordinates + else -> true + } + + if (isSuccessful) { + repository.setUnlocked(true) + } + + return isSuccessful + } + + /** Triggers a biometric-powered unlock of the device. */ + fun biometricUnlock() { + // TODO(b/280883900): only allow this if the biometric is enabled and there's a match. + repository.setUnlocked(true) + } + + /** See [authenticationMethod]. */ + fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) { + repository.setAuthenticationMethod(authenticationMethod) + } + + /** See [isBypassEnabled]. */ + fun toggleBypassEnabled() { + repository.setBypassEnabled(!repository.isBypassEnabled.value) + } + + companion object { + private fun isUnlockedWithAuthMethod( + isUnlocked: Boolean, + authMethod: AuthenticationMethodModel, + ): Boolean { + return if (authMethod is AuthenticationMethodModel.None) { + true + } else { + isUnlocked + } + } + + /** + * Returns a PIN code from the given list. It's assumed the given list elements are all + * [Int]. + */ + private fun List.asCode(): Int? { + if (isEmpty()) { + return null + } + + var code = 0 + map { it as Int }.forEach { integer -> code = code * 10 + integer } + + return code + } + + /** + * Returns a password from the given list. It's assumed the given list elements are all + * [Char]. + */ + private fun List.asPassword(): String { + val anyList = this + return buildString { anyList.forEach { append(it as Char) } } + } + + /** + * Returns a list of [AuthenticationMethodModel.Pattern.PatternCoordinate] from the given + * list. It's assumed the given list elements are all + * [AuthenticationMethodModel.Pattern.PatternCoordinate]. + */ + private fun List.asPattern(): + List { + val anyList = this + return buildList { + anyList.forEach { add(it as AuthenticationMethodModel.Pattern.PatternCoordinate) } + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt b/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt new file mode 100644 index 0000000000000..83250b6384244 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt @@ -0,0 +1,47 @@ +/* + * 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.authentication.shared.model + +/** Enumerates all known authentication methods. */ +sealed class AuthenticationMethodModel( + /** + * Whether the authentication method is considered to be "secure". + * + * "Secure" authentication methods require authentication to unlock the device. Non-secure auth + * methods simply require user dismissal. + */ + open val isSecure: Boolean, +) { + /** There is no authentication method on the device. We shouldn't even show the lock screen. */ + object None : AuthenticationMethodModel(isSecure = false) + + /** The most basic authentication method. The lock screen can be swiped away when displayed. */ + object Swipe : AuthenticationMethodModel(isSecure = false) + + data class PIN(val code: Int) : AuthenticationMethodModel(isSecure = true) + + data class Password(val password: String) : AuthenticationMethodModel(isSecure = true) + + data class Pattern(val coordinates: List) : + AuthenticationMethodModel(isSecure = true) { + + data class PatternCoordinate( + val x: Int, + val y: Int, + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index dff2c0e91f1fd..b98b23e2e2205 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -32,6 +32,7 @@ import com.android.systemui.accessibility.AccessibilityModule; import com.android.systemui.accessibility.data.repository.AccessibilityRepositoryModule; import com.android.systemui.appops.dagger.AppOpsModule; import com.android.systemui.assist.AssistModule; +import com.android.systemui.authentication.AuthenticationModule; import com.android.systemui.biometrics.AlternateUdfpsTouchProvider; import com.android.systemui.biometrics.FingerprintInteractiveToAuthProvider; import com.android.systemui.biometrics.UdfpsDisplayModeProvider; @@ -146,6 +147,7 @@ import javax.inject.Named; AccessibilityRepositoryModule.class, AppOpsModule.class, AssistModule.class, + AuthenticationModule.class, BiometricsModule.class, BouncerViewModule.class, ClipboardOverlayModule.class, diff --git a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt new file mode 100644 index 0000000000000..2e62bebfe3f1a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt @@ -0,0 +1,291 @@ +/* + * 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.authentication.domain.interactor + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.AuthenticationRepository +import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.coroutines.collectLastValue +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.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class AuthenticationInteractorTest : SysuiTestCase() { + + private val testScope = TestScope() + private val repository: AuthenticationRepository = AuthenticationRepositoryImpl() + private val underTest = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = repository, + ) + + @Test + fun authMethod() = + testScope.runTest { + val authMethod by collectLastValue(underTest.authenticationMethod) + assertThat(authMethod).isEqualTo(AuthenticationMethodModel.PIN(1234)) + + underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) + assertThat(authMethod).isEqualTo(AuthenticationMethodModel.Password("password")) + } + + @Test + fun isUnlocked_whenAuthMethodIsNone_isTrue() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + assertThat(isUnlocked).isFalse() + + underTest.setAuthenticationMethod(AuthenticationMethodModel.None) + + assertThat(isUnlocked).isTrue() + } + + @Test + fun unlockDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + assertThat(isUnlocked).isFalse() + + underTest.unlockDevice() + runCurrent() + + assertThat(isUnlocked).isTrue() + } + + @Test + fun biometricUnlock() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + assertThat(isUnlocked).isFalse() + + underTest.biometricUnlock() + runCurrent() + + assertThat(isUnlocked).isTrue() + } + + @Test + fun toggleBypassEnabled() = + testScope.runTest { + val isBypassEnabled by collectLastValue(underTest.isBypassEnabled) + assertThat(isBypassEnabled).isFalse() + + underTest.toggleBypassEnabled() + assertThat(isBypassEnabled).isTrue() + + underTest.toggleBypassEnabled() + assertThat(isBypassEnabled).isFalse() + } + + @Test + fun isAuthenticationRequired_lockedAndSecured_true() = + testScope.runTest { + underTest.lockDevice() + runCurrent() + underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) + + assertThat(underTest.isAuthenticationRequired()).isTrue() + } + + @Test + fun isAuthenticationRequired_lockedAndNotSecured_false() = + testScope.runTest { + underTest.lockDevice() + runCurrent() + underTest.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + + assertThat(underTest.isAuthenticationRequired()).isFalse() + } + + @Test + fun isAuthenticationRequired_unlockedAndSecured_false() = + testScope.runTest { + underTest.unlockDevice() + runCurrent() + underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) + + assertThat(underTest.isAuthenticationRequired()).isFalse() + } + + @Test + fun isAuthenticationRequired_unlockedAndNotSecured_false() = + testScope.runTest { + underTest.unlockDevice() + runCurrent() + underTest.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + + assertThat(underTest.isAuthenticationRequired()).isFalse() + } + + @Test + fun authenticate_withCorrectPin_returnsTrueAndUnlocksDevice() = + testScope.runTest { + 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() + } + + @Test + fun authenticate_withIncorrectPin_returnsFalseAndDoesNotUnlockDevice() = + testScope.runTest { + 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() + } + + @Test + fun authenticate_withCorrectPassword_returnsTrueAndUnlocksDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate("password".toList())).isTrue() + assertThat(isUnlocked).isTrue() + } + + @Test + fun authenticate_withIncorrectPassword_returnsFalseAndDoesNotUnlockDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate("alohomora".toList())).isFalse() + assertThat(isUnlocked).isFalse() + } + + @Test + fun authenticate_withCorrectPattern_returnsTrueAndUnlocksDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pattern( + listOf( + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 0, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 1, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 2, + ), + ) + ) + ) + assertThat(isUnlocked).isFalse() + + assertThat( + underTest.authenticate( + listOf( + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 0, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 1, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 2, + ), + ) + ) + ) + .isTrue() + assertThat(isUnlocked).isTrue() + } + + @Test + fun authenticate_withIncorrectPattern_returnsFalseAndDoesNotUnlockDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pattern( + listOf( + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 0, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 1, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 0, + y = 2, + ), + ) + ) + ) + assertThat(isUnlocked).isFalse() + + assertThat( + underTest.authenticate( + listOf( + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 2, + y = 0, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 2, + y = 1, + ), + AuthenticationMethodModel.Pattern.PatternCoordinate( + x = 2, + y = 2, + ), + ) + ) + ) + .isFalse() + assertThat(isUnlocked).isFalse() + } + + @Test + fun unlocksDevice_whenAuthMethodBecomesNone() = + testScope.runTest { + val isUnlocked by collectLastValue(underTest.isUnlocked) + assertThat(isUnlocked).isFalse() + + repository.setAuthenticationMethod(AuthenticationMethodModel.None) + + assertThat(isUnlocked).isTrue() + } +} From 8d655c395406465033832d5e6cbc0cd27c4d1b8a Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 5 May 2023 16:21:15 -0700 Subject: [PATCH 2/5] [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 --- .../bouncer/data/repo/BouncerRepository.kt | 35 +++ .../domain/interactor/BouncerInteractor.kt | 172 ++++++++++++++ .../bouncer/ui/viewmodel/BouncerViewModel.kt | 75 ++++++ .../interactor/BouncerInteractorTest.kt | 223 ++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt create mode 100644 packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt b/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt new file mode 100644 index 0000000000000..4c817b2e46a87 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt @@ -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(null) + /** The user-facing message to show in the bouncer. */ + val message: StateFlow = _message.asStateFlow() + + fun setMessage(message: String?) { + _message.value = message + } +} 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 new file mode 100644 index 0000000000000..57ce58049f134 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -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 = 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 = + 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, + ) { + 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 + } +} 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 new file mode 100644 index 0000000000000..8a183ae6ca7ce --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -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 = + 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. + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt new file mode 100644 index 0000000000000..7dd376ede3617 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -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" + } +} From 63561ed2df4493f852ebb16b927a77a96d5da477 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 5 May 2023 16:25:22 -0700 Subject: [PATCH 3/5] [flexiglass] Foundation for placeholder lock screen scene. Foundational parts of the lock screen 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: 280879610 Test: unit tests included. Manually tested with the entire relation chain in the Compose Gallery testdbed app. Change-Id: I90b025f57f993c02368e73a06fba6e4ddc02b9bc --- .../interactor/LockScreenSceneInteractor.kt | 186 ++++++++++++ .../ui/viewmodel/LockScreenSceneViewModel.kt | 108 +++++++ .../LockScreenSceneInteractorTest.kt | 270 ++++++++++++++++++ .../viewmodel/LockScreenSceneViewModelTest.kt | 190 ++++++++++++ 4 files changed, 754 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractor.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractor.kt new file mode 100644 index 0000000000000..61701802e231c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractor.kt @@ -0,0 +1,186 @@ +/* + * 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.keyguard.domain.interactor + +import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +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 com.android.systemui.util.kotlin.pairwise +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +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 business and application state accessing logic for the lock screen scene. */ +class LockScreenSceneInteractor +@AssistedInject +constructor( + @Application applicationScope: CoroutineScope, + private val authenticationInteractor: AuthenticationInteractor, + bouncerInteractorFactory: BouncerInteractor.Factory, + private val sceneInteractor: SceneInteractor, + @Assisted private val containerName: String, +) { + private val bouncerInteractor: BouncerInteractor = + bouncerInteractorFactory.create(containerName) + + /** Whether the device is currently locked. */ + val isDeviceLocked: StateFlow = + authenticationInteractor.isUnlocked + .map { !it } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = !authenticationInteractor.isUnlocked.value, + ) + + /** Whether it's currently possible to swipe up to dismiss the lock screen. */ + val isSwipeToDismissEnabled: StateFlow = + combine( + authenticationInteractor.isUnlocked, + authenticationInteractor.authenticationMethod, + ) { isUnlocked, authMethod -> + isSwipeToUnlockEnabled( + isUnlocked = isUnlocked, + authMethod = authMethod, + ) + } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = + isSwipeToUnlockEnabled( + isUnlocked = authenticationInteractor.isUnlocked.value, + authMethod = authenticationInteractor.authenticationMethod.value, + ), + ) + + init { + // LOCKING SHOWS LOCK SCREEN. + // + // Move to the lock screen scene if the device becomes locked while in any scene. + applicationScope.launch { + authenticationInteractor.isUnlocked + .map { !it } + .distinctUntilChanged() + .collect { isLocked -> + if (isLocked) { + sceneInteractor.setCurrentScene( + containerName = containerName, + scene = SceneModel(SceneKey.LockScreen), + ) + } + } + } + + // BYPASS UNLOCK. + // + // Moves to the gone scene if bypass is enabled and the device becomes unlocked while in the + // lock screen scene. + applicationScope.launch { + combine( + authenticationInteractor.isBypassEnabled, + authenticationInteractor.isUnlocked, + sceneInteractor.currentScene(containerName), + ::Triple, + ) + .collect { (isBypassEnabled, isUnlocked, currentScene) -> + if (isBypassEnabled && isUnlocked && currentScene.key == SceneKey.LockScreen) { + sceneInteractor.setCurrentScene( + containerName = containerName, + scene = SceneModel(SceneKey.Gone), + ) + } + } + } + + // SWIPE TO DISMISS LOCK SCREEN. + // + // If switched from the lock screen to the gone scene and the auth method was a swipe, + // unlocks the device. + applicationScope.launch { + combine( + authenticationInteractor.authenticationMethod, + sceneInteractor.currentScene(containerName).pairwise(), + ::Pair, + ) + .collect { (authMethod, scenes) -> + val (previousScene, currentScene) = scenes + if ( + authMethod is AuthenticationMethodModel.Swipe && + previousScene.key == SceneKey.LockScreen && + currentScene.key == SceneKey.Gone + ) { + authenticationInteractor.unlockDevice() + } + } + } + + // DISMISS LOCK SCREEN IF AUTH METHOD IS REMOVED. + // + // If the auth method becomes None while on the lock screen scene, dismisses the lock + // screen. + applicationScope.launch { + combine( + authenticationInteractor.authenticationMethod, + sceneInteractor.currentScene(containerName), + ::Pair, + ) + .collect { (authMethod, scene) -> + if ( + scene.key == SceneKey.LockScreen && + authMethod == AuthenticationMethodModel.None + ) { + sceneInteractor.setCurrentScene( + containerName = containerName, + scene = SceneModel(SceneKey.Gone), + ) + } + } + } + } + + /** Attempts to dismiss the lock screen. This will cause the bouncer to show, if needed. */ + fun dismissLockScreen() { + bouncerInteractor.showOrUnlockDevice(containerName = containerName) + } + + private fun isSwipeToUnlockEnabled( + isUnlocked: Boolean, + authMethod: AuthenticationMethodModel, + ): Boolean { + return !isUnlocked && authMethod is AuthenticationMethodModel.Swipe + } + + @AssistedFactory + interface Factory { + fun create( + containerName: String, + ): LockScreenSceneInteractor + } +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModel.kt new file mode 100644 index 0000000000000..08b9613068174 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModel.kt @@ -0,0 +1,108 @@ +/* + * 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.keyguard.ui.viewmodel + +import com.android.systemui.R +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +import com.android.systemui.scene.shared.model.SceneKey +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +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 + +/** Models UI state and handles user input for the lock screen scene. */ +class LockScreenSceneViewModel +@AssistedInject +constructor( + @Application applicationScope: CoroutineScope, + interactorFactory: LockScreenSceneInteractor.Factory, + @Assisted containerName: String, +) { + private val interactor: LockScreenSceneInteractor = interactorFactory.create(containerName) + + /** The icon for the "lock" button on the lock screen. */ + val lockButtonIcon: StateFlow = + interactor.isDeviceLocked + .map { isLocked -> lockIcon(isLocked = isLocked) } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = lockIcon(isLocked = interactor.isDeviceLocked.value), + ) + + /** The key of the scene we should switch to when swiping up. */ + val upDestinationSceneKey: StateFlow = + interactor.isSwipeToDismissEnabled + .map { isSwipeToUnlockEnabled -> upDestinationSceneKey(isSwipeToUnlockEnabled) } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = upDestinationSceneKey(interactor.isSwipeToDismissEnabled.value), + ) + + /** Notifies that the lock button on the lock screen was clicked. */ + fun onLockButtonClicked() { + interactor.dismissLockScreen() + } + + /** Notifies that some content on the lock screen was clicked. */ + fun onContentClicked() { + interactor.dismissLockScreen() + } + + private fun upDestinationSceneKey( + isSwipeToUnlockEnabled: Boolean, + ): SceneKey { + return if (isSwipeToUnlockEnabled) SceneKey.Gone else SceneKey.Bouncer + } + + private fun lockIcon( + isLocked: Boolean, + ): Icon { + return Icon.Resource( + res = + if (isLocked) { + R.drawable.ic_device_lock_on + } else { + R.drawable.ic_device_lock_off + }, + contentDescription = + ContentDescription.Resource( + res = + if (isLocked) { + R.string.accessibility_lock_icon + } else { + R.string.accessibility_unlock_button + } + ) + ) + } + + @AssistedFactory + interface Factory { + fun create( + containerName: String, + ): LockScreenSceneViewModel + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt new file mode 100644 index 0000000000000..749e7a0481eb5 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt @@ -0,0 +1,270 @@ +/* + * 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.keyguard.domain.interactor + +import androidx.test.filters.SmallTest +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.bouncer.domain.interactor.BouncerInteractor +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.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class LockScreenSceneInteractorTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + private val underTest = + LockScreenSceneInteractor( + applicationScope = testScope.backgroundScope, + authenticationInteractor = mAuthenticationInteractor, + bouncerInteractorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = containerName, + ) + } + }, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + + @Test + fun isDeviceLocked() = + testScope.runTest { + val isDeviceLocked by collectLastValue(underTest.isDeviceLocked) + + mAuthenticationInteractor.lockDevice() + assertThat(isDeviceLocked).isTrue() + + mAuthenticationInteractor.unlockDevice() + assertThat(isDeviceLocked).isFalse() + } + + @Test + fun isSwipeToDismissEnabled_deviceLockedAndAuthMethodSwipe_true() = + testScope.runTest { + val isSwipeToDismissEnabled by collectLastValue(underTest.isSwipeToDismissEnabled) + + mAuthenticationInteractor.lockDevice() + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + + assertThat(isSwipeToDismissEnabled).isTrue() + } + + @Test + fun isSwipeToDismissEnabled_deviceUnlockedAndAuthMethodSwipe_false() = + testScope.runTest { + val isSwipeToDismissEnabled by collectLastValue(underTest.isSwipeToDismissEnabled) + + mAuthenticationInteractor.unlockDevice() + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + + assertThat(isSwipeToDismissEnabled).isFalse() + } + + @Test + fun dismissLockScreen_deviceLockedWithSecureAuthMethod_switchesToBouncer() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.lockDevice() + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + + underTest.dismissLockScreen() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun dismissLockScreen_deviceUnlocked_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.unlockDevice() + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + + underTest.dismissLockScreen() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun dismissLockScreen_deviceLockedWithInsecureAuthMethod_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.lockDevice() + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + + underTest.dismissLockScreen() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun deviceLockedInNonLockScreenScene_switchesToLockScreenScene() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + runCurrent() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + runCurrent() + mAuthenticationInteractor.unlockDevice() + runCurrent() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + + mAuthenticationInteractor.lockDevice() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + } + + @Test + fun deviceBiometricUnlockedInLockScreen_bypassEnabled_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) + if (!mAuthenticationInteractor.isBypassEnabled.value) { + mAuthenticationInteractor.toggleBypassEnabled() + } + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + + mAuthenticationInteractor.biometricUnlock() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun deviceBiometricUnlockedInLockScreen_bypassNotEnabled_doesNotSwitch() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) + if (mAuthenticationInteractor.isBypassEnabled.value) { + mAuthenticationInteractor.toggleBypassEnabled() + } + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + + mAuthenticationInteractor.biometricUnlock() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + } + + @Test + fun switchFromLockScreenToGone_authMethodSwipe_unlocksDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + assertThat(isUnlocked).isFalse() + + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + + assertThat(isUnlocked).isTrue() + } + + @Test + fun switchFromLockScreenToGone_authMethodNotSwipe_doesNotUnlockDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + assertThat(isUnlocked).isFalse() + + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + + assertThat(isUnlocked).isFalse() + } + + @Test + fun switchFromNonLockScreenToGone_authMethodSwipe_doesNotUnlockDevice() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + runCurrent() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Shade)) + runCurrent() + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + runCurrent() + assertThat(isUnlocked).isFalse() + + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + + assertThat(isUnlocked).isFalse() + } + + @Test + fun authMethodChangedToNone_onLockScreenScene_dismissesLockScreen() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) + + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None) + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun authMethodChangedToNone_notOnLockScreenScene_doesNotDismissLockScreen() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + runCurrent() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.QuickSettings)) + runCurrent() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.QuickSettings)) + + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None) + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.QuickSettings)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt new file mode 100644 index 0000000000000..d335b09b196a1 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt @@ -0,0 +1,190 @@ +/* + * 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.keyguard.ui.viewmodel + +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.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +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.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class LockScreenSceneViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + + private val underTest = + LockScreenSceneViewModel( + applicationScope = testScope.backgroundScope, + interactorFactory = + object : LockScreenSceneInteractor.Factory { + override fun create(containerName: String): LockScreenSceneInteractor { + return LockScreenSceneInteractor( + applicationScope = testScope.backgroundScope, + authenticationInteractor = mAuthenticationInteractor, + bouncerInteractorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = containerName, + ) + } + }, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + } + }, + containerName = CONTAINER_NAME + ) + + @Test + fun lockButtonIcon_whenLocked() = + testScope.runTest { + val lockButtonIcon by collectLastValue(underTest.lockButtonIcon) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.lockDevice() + + assertThat((lockButtonIcon as? Icon.Resource)?.res) + .isEqualTo(R.drawable.ic_device_lock_on) + } + + @Test + fun lockButtonIcon_whenUnlocked() = + testScope.runTest { + val lockButtonIcon by collectLastValue(underTest.lockButtonIcon) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.unlockDevice() + + assertThat((lockButtonIcon as? Icon.Resource)?.res) + .isEqualTo(R.drawable.ic_device_lock_off) + } + + @Test + fun upTransitionSceneKey_swipeToUnlockedEnabled_gone() = + testScope.runTest { + val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + mAuthenticationInteractor.lockDevice() + + assertThat(upTransitionSceneKey).isEqualTo(SceneKey.Gone) + } + + @Test + fun upTransitionSceneKey_swipeToUnlockedNotEnabled_bouncer() = + testScope.runTest { + val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + + assertThat(upTransitionSceneKey).isEqualTo(SceneKey.Bouncer) + } + + @Test + fun onLockButtonClicked_deviceLockedSecurely_switchesToBouncer() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + runCurrent() + + underTest.onLockButtonClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onContentClicked_deviceUnlocked_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.unlockDevice() + runCurrent() + + underTest.onContentClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onContentClicked_deviceLockedSecurely_switchesToBouncer() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + runCurrent() + + underTest.onContentClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onLockButtonClicked_deviceUnlocked_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.unlockDevice() + runCurrent() + + underTest.onLockButtonClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + } +} From 1240a66fe07626fe9e5720a8220512d9f981bca3 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 5 May 2023 16:26:32 -0700 Subject: [PATCH 4/5] [flexiglass] Foundation for placeholder shade scene. Foundational parts of the shade 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: 280887022 Test: unit tests included. Manually tested with the entire relation chain in the Compose Gallery testdbed app. Change-Id: If1a64a9346343ad2242c39049d1a57ba7c8d76a8 --- .../shade/ui/viewmodel/ShadeSceneViewModel.kt | 72 ++++++++++ .../ui/viewmodel/ShadeSceneViewModelTest.kt | 136 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt new file mode 100644 index 0000000000000..dcae258ed76ff --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt @@ -0,0 +1,72 @@ +/* + * 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.shade.ui.viewmodel + +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +import com.android.systemui.scene.shared.model.SceneKey +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +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 + +/** Models UI state and handles user input for the shade scene. */ +class ShadeSceneViewModel +@AssistedInject +constructor( + @Application private val applicationScope: CoroutineScope, + lockScreenSceneInteractorFactory: LockScreenSceneInteractor.Factory, + @Assisted private val containerName: String, +) { + private val lockScreenInteractor: LockScreenSceneInteractor = + lockScreenSceneInteractorFactory.create(containerName) + + /** The key of the scene we should switch to when swiping up. */ + val upDestinationSceneKey: StateFlow = + lockScreenInteractor.isDeviceLocked + .map { isLocked -> upDestinationSceneKey(isLocked = isLocked) } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = + upDestinationSceneKey( + isLocked = lockScreenInteractor.isDeviceLocked.value, + ), + ) + + /** Notifies that some content in the shade was clicked. */ + fun onContentClicked() { + lockScreenInteractor.dismissLockScreen() + } + + private fun upDestinationSceneKey( + isLocked: Boolean, + ): SceneKey { + return if (isLocked) SceneKey.LockScreen else SceneKey.Gone + } + + @AssistedFactory + interface Factory { + fun create( + containerName: String, + ): ShadeSceneViewModel + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt new file mode 100644 index 0000000000000..688cce83a55d3 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt @@ -0,0 +1,136 @@ +/* + * 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.shade.ui.viewmodel + +import androidx.test.filters.SmallTest +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.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +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.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class ShadeSceneViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + + private val underTest = + ShadeSceneViewModel( + applicationScope = testScope.backgroundScope, + lockScreenSceneInteractorFactory = + object : LockScreenSceneInteractor.Factory { + override fun create(containerName: String): LockScreenSceneInteractor { + return LockScreenSceneInteractor( + applicationScope = testScope.backgroundScope, + authenticationInteractor = mAuthenticationInteractor, + bouncerInteractorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = containerName, + ) + } + }, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + } + }, + containerName = CONTAINER_NAME + ) + + @Test + fun upTransitionSceneKey_deviceLocked_lockScreen() = + testScope.runTest { + val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + + assertThat(upTransitionSceneKey).isEqualTo(SceneKey.LockScreen) + } + + @Test + fun upTransitionSceneKey_deviceUnlocked_gone() = + testScope.runTest { + val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.unlockDevice() + + assertThat(upTransitionSceneKey).isEqualTo(SceneKey.Gone) + } + + @Test + fun onContentClicked_deviceUnlocked_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.unlockDevice() + runCurrent() + + underTest.onContentClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onContentClicked_deviceLockedSecurely_switchesToBouncer() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + runCurrent() + + underTest.onContentClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + } +} From d7840346d1e2e08bcc3d1e596e9a6eb49ff80521 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 5 May 2023 16:26:59 -0700 Subject: [PATCH 5/5] [flexiglass] Foundation for placeholder QS scene. Foundational parts of the QS 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: 280887232 Test: unit tests included. Manually tested with the entire relation chain in the Compose Gallery testdbed app. Change-Id: Ia719fce3a3e089a7aaf677ed321b853cd0acd418 --- .../viewmodel/QuickSettingsSceneViewModel.kt | 45 +++++++ .../QuickSettingsSceneViewModelTest.kt | 115 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt new file mode 100644 index 0000000000000..6525a98b91028 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModel.kt @@ -0,0 +1,45 @@ +/* + * 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.qs.ui.viewmodel + +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** Models UI state and handles user input for the quick settings scene. */ +class QuickSettingsSceneViewModel +@AssistedInject +constructor( + lockScreenSceneInteractorFactory: LockScreenSceneInteractor.Factory, + @Assisted containerName: String, +) { + private val lockScreenSceneInteractor: LockScreenSceneInteractor = + lockScreenSceneInteractorFactory.create(containerName) + + /** Notifies that some content in quick settings was clicked. */ + fun onContentClicked() { + lockScreenSceneInteractor.dismissLockScreen() + } + + @AssistedFactory + interface Factory { + fun create( + containerName: String, + ): QuickSettingsSceneViewModel + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt new file mode 100644 index 0000000000000..e8875bee5276d --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt @@ -0,0 +1,115 @@ +/* + * 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.qs.ui.viewmodel + +import androidx.test.filters.SmallTest +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.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +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.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class QuickSettingsSceneViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + + private val underTest = + QuickSettingsSceneViewModel( + lockScreenSceneInteractorFactory = + object : LockScreenSceneInteractor.Factory { + override fun create(containerName: String): LockScreenSceneInteractor { + return LockScreenSceneInteractor( + applicationScope = testScope.backgroundScope, + authenticationInteractor = mAuthenticationInteractor, + bouncerInteractorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = containerName, + ) + } + }, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + } + }, + containerName = CONTAINER_NAME + ) + + @Test + fun onContentClicked_deviceUnlocked_switchesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.unlockDevice() + runCurrent() + + underTest.onContentClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onContentClicked_deviceLockedSecurely_switchesToBouncer() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + runCurrent() + + underTest.onContentClicked() + + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + } +}