diff options
6 files changed, 666 insertions, 0 deletions
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 000000000000..7c394a62a5f4 --- /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 000000000000..cd195f6f9f97 --- /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<Boolean> + + /** + * 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> + + /** + * 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<Boolean> + + /** 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<Boolean> = _isUnlocked.asStateFlow() + + private val _authenticationMethod = + MutableStateFlow<AuthenticationMethodModel>(AuthenticationMethodModel.PIN(1234)) + override val authenticationMethod: StateFlow<AuthenticationMethodModel> = + _authenticationMethod.asStateFlow() + + private val _isBypassEnabled = MutableStateFlow(false) + override val isBypassEnabled: StateFlow<Boolean> = _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 000000000000..5aea930401d9 --- /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<AuthenticationMethodModel> = 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<Boolean> = + 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<Boolean> = 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<Any>): 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<Any>.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<Any>.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<Any>.asPattern(): + List<AuthenticationMethodModel.Pattern.PatternCoordinate> { + 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 000000000000..83250b638424 --- /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<PatternCoordinate>) : + 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 dff2c0e91f1f..b98b23e2e220 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 000000000000..2e62bebfe3f1 --- /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() + } +} |