User data layer.

UserSwitcherActivity refactor: CL 4/7

This is the data layer for the UserSwitcherActivity refactor. It
includes a repository and a couple of shared models that the repository
exposes.

Bug: 243844359
Test: Unit tests included. Also tested as part of the big manual test in
CL 7/7 in this chain

Change-Id: I4cb5b1c4f018def88cda5864de51c644c6d8ce0f
This commit is contained in:
Alejandro Nijamkin
2022-08-31 15:51:35 -07:00
parent 6a449c6205
commit 2aca8b4dad
8 changed files with 567 additions and 23 deletions

View File

@@ -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";

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -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,
)

View File

@@ -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
}
}

View File

@@ -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
}
}