diff options
8 files changed, 567 insertions, 23 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/user/UserModule.java b/packages/SystemUI/src/com/android/systemui/user/UserModule.java index 469d54ff8ffa..5b522dcc4885 100644 --- a/packages/SystemUI/src/com/android/systemui/user/UserModule.java +++ b/packages/SystemUI/src/com/android/systemui/user/UserModule.java @@ -19,6 +19,7 @@ package com.android.systemui.user; import android.app.Activity; import com.android.settingslib.users.EditUserInfoController; +import com.android.systemui.user.data.repository.UserRepositoryModule; import dagger.Binds; import dagger.Module; @@ -29,7 +30,11 @@ import dagger.multibindings.IntoMap; /** * Dagger module for User related classes. */ -@Module +@Module( + includes = { + UserRepositoryModule.class, + } +) public abstract class UserModule { private static final String FILE_PROVIDER_AUTHORITY = "com.android.systemui.fileprovider"; diff --git a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt new file mode 100644 index 000000000000..305b5ee920a1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2022 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.user.data.repository + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.UserManager +import androidx.appcompat.content.res.AppCompatResources +import com.android.internal.util.UserIcons +import com.android.systemui.R +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.common.shared.model.Text +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.source.UserRecord +import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Acts as source of truth for user related data. + * + * Abstracts-away data sources and their schemas so the rest of the app doesn't need to worry about + * upstream changes. + */ +interface UserRepository { + /** List of all users on the device. */ + val users: Flow<List<UserModel>> + + /** The currently-selected user. */ + val selectedUser: Flow<UserModel> + + /** List of available user-related actions. */ + val actions: Flow<List<UserActionModel>> + + /** Whether actions are available even when locked. */ + val isActionableWhenLocked: Flow<Boolean> + + /** Whether the device is configured to always have a guest user available. */ + val isGuestUserAutoCreated: Boolean + + /** Whether the guest user is currently being reset. */ + val isGuestUserResetting: Boolean +} + +@SysUISingleton +class UserRepositoryImpl +@Inject +constructor( + @Application private val appContext: Context, + private val manager: UserManager, + controller: UserSwitcherController, +) : UserRepository { + + private val userRecords: Flow<List<UserRecord>> = conflatedCallbackFlow { + fun send() { + trySendWithFailureLogging( + controller.users, + TAG, + ) + } + + val callback = UserSwitcherController.UserSwitchCallback { send() } + + controller.addUserSwitchCallback(callback) + send() + + awaitClose { controller.removeUserSwitchCallback(callback) } + } + + override val users: Flow<List<UserModel>> = + userRecords.map { records -> records.filter { it.isUser() }.map { it.toUserModel() } } + + override val selectedUser: Flow<UserModel> = + users.map { users -> users.first { user -> user.isSelected } } + + override val actions: Flow<List<UserActionModel>> = + userRecords.map { records -> records.filter { it.isNotUser() }.map { it.toActionModel() } } + + override val isActionableWhenLocked: Flow<Boolean> = controller.addUsersFromLockScreen + + override val isGuestUserAutoCreated: Boolean = controller.isGuestUserAutoCreated + + override val isGuestUserResetting: Boolean = controller.isGuestUserResetting + + private fun UserRecord.isUser(): Boolean { + return when { + isAddUser -> false + isAddSupervisedUser -> false + isGuest -> info != null + else -> true + } + } + + private fun UserRecord.isNotUser(): Boolean { + return !isUser() + } + + private fun UserRecord.toUserModel(): UserModel { + return UserModel( + id = resolveId(), + name = getUserName(this), + image = getUserImage(this), + isSelected = isCurrent, + isSelectable = isSwitchToEnabled || isGuest, + ) + } + + private fun UserRecord.toActionModel(): UserActionModel { + return when { + isAddUser -> UserActionModel.ADD_USER + isAddSupervisedUser -> UserActionModel.ADD_SUPERVISED_USER + isGuest -> UserActionModel.ENTER_GUEST_MODE + else -> error("Don't know how to convert to UserActionModel: $this") + } + } + + private fun getUserName(record: UserRecord): Text { + val resourceId: Int? = LegacyUserUiHelper.getGuestUserRecordNameResourceId(record) + return if (resourceId != null) { + Text.Resource(resourceId) + } else { + Text.Loaded(checkNotNull(record.info).name) + } + } + + private fun getUserImage(record: UserRecord): Drawable { + if (record.isGuest) { + return checkNotNull( + AppCompatResources.getDrawable(appContext, R.drawable.ic_account_circle) + ) + } + + val userId = checkNotNull(record.info?.id) + return manager.getUserIcon(userId)?.let { userSelectedIcon -> + BitmapDrawable(userSelectedIcon) + } + ?: UserIcons.getDefaultUserIcon(appContext.resources, userId, /* light= */ false) + } + + companion object { + private const val TAG = "UserRepository" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt new file mode 100644 index 000000000000..18ae1070e1bb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepositoryModule.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 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.user.data.repository + +import dagger.Binds +import dagger.Module + +@Module +interface UserRepositoryModule { + @Binds fun bindRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt b/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt index 67999f381a0e..cf6da9a60d78 100644 --- a/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt +++ b/packages/SystemUI/src/com/android/systemui/user/data/source/UserRecord.kt @@ -20,41 +20,29 @@ import android.content.pm.UserInfo import android.graphics.Bitmap import android.os.UserHandle -/** - * Encapsulates raw data for a user or an option item related to managing users on the device. - */ +/** Encapsulates raw data for a user or an option item related to managing users on the device. */ data class UserRecord( /** Relevant user information. If `null`, this record is not a user but an option item. */ - @JvmField - val info: UserInfo? = null, + @JvmField val info: UserInfo? = null, /** An image representing the user. */ - @JvmField - val picture: Bitmap? = null, + @JvmField val picture: Bitmap? = null, /** Whether this record represents an option to switch to a guest user. */ - @JvmField - val isGuest: Boolean = false, + @JvmField val isGuest: Boolean = false, /** Whether this record represents the currently-selected user. */ - @JvmField - val isCurrent: Boolean = false, + @JvmField val isCurrent: Boolean = false, /** Whether this record represents an option to add another user to the device. */ - @JvmField - val isAddUser: Boolean = false, + @JvmField val isAddUser: Boolean = false, /** * If true, the record is only available if unlocked or if the user has granted permission to * access this user action whilst on the device is locked. */ - @JvmField - val isRestricted: Boolean = false, + @JvmField val isRestricted: Boolean = false, /** Whether it is possible to switch to this user. */ - @JvmField - val isSwitchToEnabled: Boolean = false, + @JvmField val isSwitchToEnabled: Boolean = false, /** Whether this record represents an option to add another supervised user to the device. */ - @JvmField - val isAddSupervisedUser: Boolean = false, + @JvmField val isAddSupervisedUser: Boolean = false, ) { - /** - * Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. - */ + /** Returns a new instance of [UserRecord] with its [isCurrent] set to the given value. */ fun copyWithIsCurrent(isCurrent: Boolean): UserRecord { return copy(isCurrent = isCurrent) } diff --git a/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt new file mode 100644 index 000000000000..823bf74dc0f0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserActionModel.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 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.user.shared.model + +enum class UserActionModel { + ENTER_GUEST_MODE, + ADD_USER, + ADD_SUPERVISED_USER, + NAVIGATE_TO_USER_MANAGEMENT, +} diff --git a/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt new file mode 100644 index 000000000000..bf7977a600e9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/shared/model/UserModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2022 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.user.shared.model + +import android.graphics.drawable.Drawable +import com.android.systemui.common.shared.model.Text + +/** Represents a single user on the device. */ +data class UserModel( + /** ID of the user, unique across all users on this device. */ + val id: Int, + /** Human-facing name for this user. */ + val name: Text, + /** Human-facing image for this user. */ + val image: Drawable, + /** Whether this user is the currently-selected user. */ + val isSelected: Boolean, + /** Whether this use is selectable. A non-selectable user cannot be switched to. */ + val isSelectable: Boolean, +) diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt new file mode 100644 index 000000000000..20f1e367944f --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/FakeUserRepository.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2022 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.user.data.repository + +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +class FakeUserRepository : UserRepository { + + private val _users = MutableStateFlow<List<UserModel>>(emptyList()) + override val users: Flow<List<UserModel>> = _users.asStateFlow() + override val selectedUser: Flow<UserModel> = + users.map { models -> models.first { model -> model.isSelected } } + + private val _actions = MutableStateFlow<List<UserActionModel>>(emptyList()) + override val actions: Flow<List<UserActionModel>> = _actions.asStateFlow() + + private val _isActionableWhenLocked = MutableStateFlow(false) + override val isActionableWhenLocked: Flow<Boolean> = _isActionableWhenLocked.asStateFlow() + + private var _isGuestUserAutoCreated: Boolean = false + override val isGuestUserAutoCreated: Boolean + get() = _isGuestUserAutoCreated + private var _isGuestUserResetting: Boolean = false + override val isGuestUserResetting: Boolean + get() = _isGuestUserResetting + + fun setUsers(models: List<UserModel>) { + _users.value = models + } + + fun setSelectedUser(userId: Int) { + check(_users.value.find { it.id == userId } != null) { + "Cannot select a user with ID $userId - no user with that ID found!" + } + + setUsers( + _users.value.map { model -> + when { + model.isSelected && model.id != userId -> model.copy(isSelected = false) + !model.isSelected && model.id == userId -> model.copy(isSelected = true) + else -> model + } + } + ) + } + + fun setActions(models: List<UserActionModel>) { + _actions.value = models + } + + fun setActionableWhenLocked(value: Boolean) { + _isActionableWhenLocked.value = value + } + + fun setGuestUserAutoCreated(value: Boolean) { + _isGuestUserAutoCreated = value + } + + fun setGuestUserResetting(value: Boolean) { + _isGuestUserResetting = value + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 000000000000..6b466e1ac2d8 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2022 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.user.data.repository + +import android.content.pm.UserInfo +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.source.UserRecord +import com.android.systemui.user.shared.model.UserActionModel +import com.android.systemui.user.shared.model.UserModel +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.capture +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class UserRepositoryImplTest : SysuiTestCase() { + + @Mock private lateinit var manager: UserManager + @Mock private lateinit var controller: UserSwitcherController + @Captor + private lateinit var userSwitchCallbackCaptor: + ArgumentCaptor<UserSwitcherController.UserSwitchCallback> + + private lateinit var underTest: UserRepositoryImpl + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + whenever(controller.addUsersFromLockScreen).thenReturn(MutableStateFlow(false)) + whenever(controller.isGuestUserAutoCreated).thenReturn(false) + whenever(controller.isGuestUserResetting).thenReturn(false) + + underTest = + UserRepositoryImpl( + appContext = context, + manager = manager, + controller = controller, + ) + } + + @Test + fun `users - registers for updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.users.onEach {}.launchIn(this) + + verify(controller).addUserSwitchCallback(any()) + + job.cancel() + } + + @Test + fun `users - unregisters from updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.users.onEach {}.launchIn(this) + verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor)) + + job.cancel() + + verify(controller).removeUserSwitchCallback(userSwitchCallbackCaptor.value) + } + + @Test + fun `users - does not include actions`() = + runBlocking(IMMEDIATE) { + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0, isSelected = true), + createActionRecord(UserActionModel.ADD_USER), + createUserRecord(1), + createUserRecord(2), + createActionRecord(UserActionModel.ADD_SUPERVISED_USER), + createActionRecord(UserActionModel.ENTER_GUEST_MODE), + ) + ) + var models: List<UserModel>? = null + val job = underTest.users.onEach { models = it }.launchIn(this) + + assertThat(models).hasSize(3) + assertThat(models?.get(0)?.id).isEqualTo(0) + assertThat(models?.get(0)?.isSelected).isTrue() + assertThat(models?.get(1)?.id).isEqualTo(1) + assertThat(models?.get(1)?.isSelected).isFalse() + assertThat(models?.get(2)?.id).isEqualTo(2) + assertThat(models?.get(2)?.isSelected).isFalse() + job.cancel() + } + + @Test + fun selectedUser() = + runBlocking(IMMEDIATE) { + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0, isSelected = true), + createUserRecord(1), + createUserRecord(2), + ) + ) + var id: Int? = null + val job = underTest.selectedUser.map { it.id }.onEach { id = it }.launchIn(this) + + assertThat(id).isEqualTo(0) + + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0), + createUserRecord(1), + createUserRecord(2, isSelected = true), + ) + ) + verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor)) + userSwitchCallbackCaptor.value.onUserSwitched() + assertThat(id).isEqualTo(2) + + job.cancel() + } + + @Test + fun `actions - unregisters from updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.actions.onEach {}.launchIn(this) + verify(controller).addUserSwitchCallback(capture(userSwitchCallbackCaptor)) + + job.cancel() + + verify(controller).removeUserSwitchCallback(userSwitchCallbackCaptor.value) + } + + @Test + fun `actions - registers for updates`() = + runBlocking(IMMEDIATE) { + val job = underTest.actions.onEach {}.launchIn(this) + + verify(controller).addUserSwitchCallback(any()) + + job.cancel() + } + + @Test + fun `actopms - does not include users`() = + runBlocking(IMMEDIATE) { + whenever(controller.users) + .thenReturn( + arrayListOf( + createUserRecord(0, isSelected = true), + createActionRecord(UserActionModel.ADD_USER), + createUserRecord(1), + createUserRecord(2), + createActionRecord(UserActionModel.ADD_SUPERVISED_USER), + createActionRecord(UserActionModel.ENTER_GUEST_MODE), + ) + ) + var models: List<UserActionModel>? = null + val job = underTest.actions.onEach { models = it }.launchIn(this) + + assertThat(models).hasSize(3) + assertThat(models?.get(0)).isEqualTo(UserActionModel.ADD_USER) + assertThat(models?.get(1)).isEqualTo(UserActionModel.ADD_SUPERVISED_USER) + assertThat(models?.get(2)).isEqualTo(UserActionModel.ENTER_GUEST_MODE) + job.cancel() + } + + private fun createUserRecord(id: Int, isSelected: Boolean = false): UserRecord { + return UserRecord( + info = UserInfo(id, "name$id", 0), + isCurrent = isSelected, + ) + } + + private fun createActionRecord(action: UserActionModel): UserRecord { + return UserRecord( + isAddUser = action == UserActionModel.ADD_USER, + isAddSupervisedUser = action == UserActionModel.ADD_SUPERVISED_USER, + isGuest = action == UserActionModel.ENTER_GUEST_MODE, + ) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + } +} |