diff options
author | 2023-10-24 09:36:48 -0400 | |
---|---|---|
committer | 2023-11-10 14:53:12 -0500 | |
commit | 75e928b3330c383363096d9113a804215863fba5 (patch) | |
tree | cb637e671b9fa2a8e49f678605cc44eb20e454ca | |
parent | 80118c7a461a6111829ddbc5c44931044d00e9fd (diff) |
Adds UserDataSource
An abstraction of users and profiles, packaged up into an injectable interface.
UserDataSource
val users: Flow<Map<UserHandle, User>>
fun isAvailable(@UserIdInt userId: Int): Flow<Boolean>
Along with an interface and implementation this change introduces a data model,
[User] to abstract from platform internal types.
Bug: 309960444
Test: atest FakeUserManagerTest UserDataSourceImplTest
Change-Id: I46681e5f5b40c0720f4b99c1bb13d05ab5da4211
10 files changed, 1007 insertions, 1 deletions
diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index fca1e896..157e8f76 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -25,6 +25,13 @@ import javax.inject.Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class ApplicationOwned +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationUser + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt index 36adf06b..e517800d 100644 --- a/java/src/com/android/intentresolver/inject/SingletonModule.kt +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -12,7 +12,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -class SingletonModule { +object SingletonModule { @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() @Provides diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt new file mode 100644 index 00000000..1a58afcb --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt @@ -0,0 +1,46 @@ +package com.android.intentresolver.v2.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.UserHandle +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastFlow" + +/** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed using + * [transform] and emitted if non-null. + */ +internal fun <T> broadcastFlow( + context: Context, + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T? +): Flow<T> = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + null, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/java/src/com/android/intentresolver/v2/data/User.kt b/java/src/com/android/intentresolver/v2/data/User.kt new file mode 100644 index 00000000..d8a4af74 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/User.kt @@ -0,0 +1,75 @@ +package com.android.intentresolver.v2.data + +import android.annotation.UserIdInt +import android.content.pm.UserInfo +import android.os.UserHandle +import com.android.intentresolver.v2.data.User.Role +import com.android.intentresolver.v2.data.User.Type +import com.android.intentresolver.v2.data.User.Type.FULL +import com.android.intentresolver.v2.data.User.Type.PROFILE + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * fun example() { + * User(id = 0, role = PERSONAL) + * User(id = 10, role = WORK) + * User(id = 11, role = CLONE) + * User(id = 12, role = PRIVATE) + * } + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + val type: Type + get() = role.type + + enum class Type { + FULL, + PROFILE + } + + enum class Role( + /** The type of the role user. */ + val type: Type + ) { + PERSONAL(FULL), + PRIVATE(PROFILE), + WORK(PROFILE), + CLONE(PROFILE) + } +} + +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List<User> = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/v2/data/UserDataSource.kt b/java/src/com/android/intentresolver/v2/data/UserDataSource.kt new file mode 100644 index 00000000..9eecc3be --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/UserDataSource.kt @@ -0,0 +1,227 @@ +package com.android.intentresolver.v2.data + +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserDataSource { + /** + * A [Flow] user profile groups. Each map contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow<Map<UserHandle, User>> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + fun isAvailable(handle: UserHandle): Flow<Boolean> +} + +private const val TAG = "UserDataSource" + +private data class UserWithState(val user: User, val available: Boolean) + +private typealias UserStateMap = Map<UserHandle, UserWithState> + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserDataSourceImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow<UserEvent>, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher +) : UserDataSource { + @Inject + constructor( + @ApplicationContext context: Context, + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher + ) : this( + profileParent, + userManager, + userEvents = userBroadcastFlow(context, profileParent), + scope, + background + ) + + data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + internal class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null + ) : RuntimeException("$message: event=$event", cause) + + private val usersWithState: Flow<UserStateMap> = + userEvents + .onStart { emit(UserEvent(INITIALIZE, profileParent)) } + .onEach { Log.i("UserDataSource", "userEvent: $it") } + .runningFold<UserEvent, UserStateMap>(emptyMap()) { users, event -> + try { + // Handle an action by performing some operation, then returning a new map + when (event.action) { + INITIALIZE -> createNewUserStateMap(profileParent) + ACTION_PROFILE_ADDED -> handleProfileAdded(event, users) + ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users) + ACTION_MANAGED_PROFILE_UNAVAILABLE, + ACTION_MANAGED_PROFILE_AVAILABLE, + ACTION_PROFILE_AVAILABLE, + ACTION_PROFILE_UNAVAILABLE -> handleAvailability(event, users) + else -> { + Log.w(TAG, "Unhandled event: $event)") + users + } + } + } catch (e: UserStateException) { + Log.e(TAG, "An error occurred handling an event: ${e.event}", e) + Log.e(TAG, "Attempting to recover...") + createNewUserStateMap(profileParent) + } + } + .onEach { Log.i("UserDataSource", "userStateMap: $it") } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + .filterNot { it.isEmpty() } + + override val users: Flow<Map<UserHandle, User>> = + usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + + private val availability: Flow<Map<UserHandle, Boolean>> = + usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() + + override fun isAvailable(handle: UserHandle): Flow<Boolean> { + return availability.map { it[handle] ?: false } + } + + private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { + val userEntry = + current[event.user] + ?: throw UserStateException("User was not present in the map", event) + return current + (event.user to userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { + if (!current.containsKey(event.user)) { + throw UserStateException("User was not present in the map", event) + } + return current.filterKeys { it != event.user } + } + + private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + (event.user to UserWithState(user, !event.quietMode)) + } + + private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { + val profiles = readProfileGroup(user) + return profiles + .mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + .associateBy { it.user.handle } + } + + private suspend fun readProfileGroup(handle: UserHandle): List<UserInfo> { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ +private fun Intent.toUserEvent(): UserEvent? { + val action = action + val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) + val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) ?: false + return if (user == null || action == null) { + null + } else { + UserEvent(action, user, quietMode) + } +} + +const val INITIALIZE = "INITIALIZE" + +private fun createFilter(actions: Iterable<String>): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +private fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} + +private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> { + val userActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) +} diff --git a/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt b/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt new file mode 100644 index 00000000..94f39eb7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.data + +import android.content.Context +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.inject.ProfileParent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UserDataSourceModule { + companion object { + @Provides + @Singleton + @ApplicationUser + fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user + + @Provides + @Singleton + @ProfileParent + fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { + return userManager.getProfileParent(user) ?: user + } + } + + @Binds @Singleton fun userDataSource(impl: UserDataSourceImpl): UserDataSource +} diff --git a/java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt b/java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt new file mode 100644 index 00000000..a5677d94 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt @@ -0,0 +1,89 @@ +@file:Suppress("OPT_IN_USAGE") + +package com.android.intentresolver.v2.coroutines + +/* + * 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. + */ + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent + +/** + * Collect [flow] in a new [Job] and return a getter for the last collected value. + * + * ``` + * fun myTest() = runTest { + * // ... + * val actual by collectLastValue(underTest.flow) + * assertThat(actual).isEqualTo(expected) + * } + * ``` + */ +fun <T> TestScope.collectLastValue( + flow: Flow<T>, + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, +): FlowValue<T?> { + val values by + collectValues( + flow = flow, + context = context, + start = start, + ) + return FlowValueImpl { values.lastOrNull() } +} + +/** + * Collect [flow] in a new [Job] and return a getter for the collection of values collected. + * + * ``` + * fun myTest() = runTest { + * // ... + * val values by collectValues(underTest.flow) + * assertThat(values).isEqualTo(listOf(expected1, expected2, ...)) + * } + * ``` + */ +fun <T> TestScope.collectValues( + flow: Flow<T>, + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, +): FlowValue<List<T>> { + val values = mutableListOf<T>() + backgroundScope.launch(context, start) { flow.collect(values::add) } + return FlowValueImpl { + runCurrent() + values.toList() + } +} + +/** @see collectLastValue */ +interface FlowValue<T> : ReadOnlyProperty<Any?, T> { + operator fun invoke(): T +} + +private class FlowValueImpl<T>(private val block: () -> T) : FlowValue<T> { + override operator fun invoke(): T = block() + override fun getValue(thisRef: Any?, property: KProperty<*>): T = invoke() +} diff --git a/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt b/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt new file mode 100644 index 00000000..56d5de35 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt @@ -0,0 +1,194 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.v2.data + +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserHandle.USER_NULL +import android.os.UserManager +import com.android.intentresolver.mock +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.data.User.Role +import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import com.android.intentresolver.v2.platform.FakeUserManager +import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.eq + +internal class UserDataSourceImplTest { + private val userManager = FakeUserManager() + private val userState = userManager.state + + @Test + fun initialization() = runTest { + val dataSource = createUserDataSource(userManager) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly( + userState.primaryUserHandle, + User(userState.primaryUserHandle.identifier, Role.PERSONAL) + ) + } + + @Test + fun createProfile() = runTest { + val dataSource = createUserDataSource(userManager) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() + + val profile = userState.createProfile(ProfileType.WORK) + assertThat(users).containsEntry(profile, User(profile.identifier, Role.WORK)) + } + + @Test + fun removeProfile() = runTest { + val dataSource = createUserDataSource(userManager) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + val work = userState.createProfile(ProfileType.WORK) + assertThat(users).containsEntry(work, User(work.identifier, Role.WORK)) + + userState.removeProfile(work) + assertThat(users).doesNotContainEntry(work, User(work.identifier, Role.WORK)) + } + + @Test + fun isAvailable() = runTest { + val dataSource = createUserDataSource(userManager) + val work = userState.createProfile(ProfileType.WORK) + + val available by collectLastValue(dataSource.isAvailable(work)) + assertThat(available).isTrue() + + userState.setQuietMode(work, true) + assertThat(available).isFalse() + + userState.setQuietMode(work, false) + assertThat(available).isTrue() + } + + /** + * This and all the 'recovers_from_*' tests below all configure a static event flow instead of + * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to + * reinitialize with the user profile group. + */ + @Test + fun recovers_from_invalid_profile_added_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent(ACTION_PROFILE_ADDED, UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + profileParent = UserHandle.SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_removed_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent(ACTION_PROFILE_REMOVED, UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + profileParent = UserHandle.SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_available_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent(ACTION_PROFILE_AVAILABLE, UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + UserHandle.SYSTEM, + userManager, + events, + backgroundScope, + Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_unknown_event() = runTest { + val userManager = + mockUserManager(validUser = UserHandle.USER_SYSTEM, invalidUser = USER_NULL) + val events = flowOf(UserEvent("UNKNOWN_EVENT", UserHandle.of(USER_NULL))) + val dataSource = + UserDataSourceImpl( + profileParent = UserHandle.SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(dataSource.users) + + assertWithMessage("collectLast(dataSource.users)").that(users).isNotNull() + assertThat(users) + .containsExactly(UserHandle.SYSTEM, User(UserHandle.USER_SYSTEM, Role.PERSONAL)) + } +} + +@Suppress("SameParameterValue", "DEPRECATION") +private fun mockUserManager(validUser: Int, invalidUser: Int) = + mock<UserManager> { + val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) + doReturn(listOf(info)).whenever(this).getEnabledProfiles(anyInt()) + + doReturn(info).whenever(this).getUserInfo(eq(validUser)) + + doReturn(listOf<UserInfo>()).whenever(this).getEnabledProfiles(eq(invalidUser)) + + doReturn(null).whenever(this).getUserInfo(eq(invalidUser)) + } + +private fun TestScope.createUserDataSource(userManager: FakeUserManager) = + UserDataSourceImpl( + profileParent = userManager.state.primaryUserHandle, + userManager = userManager, + userEvents = userManager.state.userEvents, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt new file mode 100644 index 00000000..ef1e5917 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt @@ -0,0 +1,206 @@ +package com.android.intentresolver.v2.platform + +import android.content.Context +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.pm.UserInfo +import android.content.pm.UserInfo.FLAG_FULL +import android.content.pm.UserInfo.FLAG_INITIALIZED +import android.content.pm.UserInfo.FLAG_PROFILE +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.IUserManager +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.THROWS_EXCEPTION +import com.android.intentresolver.mock +import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import com.android.intentresolver.v2.platform.FakeUserManager.State +import com.android.intentresolver.whenever +import kotlin.random.Random +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import org.mockito.Mockito.RETURNS_SELF +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.withSettings + +/** + * A stand-in for [UserManager] to support testing of data layer components which depend on it. + * + * This fake targets system applications which need to interact with any or all of the current + * user's associated profiles (as reported by [getEnabledProfiles]). Support for manipulating + * non-profile (full) secondary users (switching active foreground user, adding or removing users) + * is not included. + * + * Upon creation [FakeUserManager] contains a single primary (full) user with a randomized ID. This + * is available from [FakeUserManager.state] using [primaryUserHandle][State.primaryUserHandle] or + * [getPrimaryUser][State.getPrimaryUser]. + * + * To make state changes, use functions available from [FakeUserManager.state]: + * * [createProfile][State.createProfile] + * * [removeProfile][State.removeProfile] + * * [setQuietMode][State.setQuietMode] + * + * Any functionality not explicitly overridden here is guaranteed to throw an exception when + * accessed (access to the real system service is prevented). + */ +class FakeUserManager(val state: State = State()) : + UserManager(/* context = */ mockContext(), /* service = */ mockService()) { + + enum class ProfileType { + WORK, + CLONE, + PRIVATE + } + + override fun getProfileParent(userHandle: UserHandle): UserHandle? { + return state.getUserOrNull(userHandle)?.let { user -> + if (user.isProfile) { + state.getUserOrNull(UserHandle.of(user.profileGroupId))?.userHandle + } else { + null + } + } + } + + override fun getUserInfo(userId: Int): UserInfo? { + return state.getUserOrNull(UserHandle.of(userId)) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun getEnabledProfiles(userId: Int): List<UserInfo> { + val user = state.users.single { it.id == userId } + return state.users.filter { other -> + user.id == other.id || user.profileGroupId == other.profileGroupId + } + } + + override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { + return state.getUser(userHandle).isQuietModeEnabled + } + + override fun toString(): String { + return "FakeUserManager(state=$state)" + } + + class State { + private val eventChannel = Channel<UserEvent>() + private val userInfoMap: MutableMap<UserHandle, UserInfo> = mutableMapOf() + + /** The id of the primary/full/system user, which is automatically created. */ + val primaryUserHandle: UserHandle + + /** + * Retrieves the primary user. The value returned changes, but the values are immutable. + * + * Do not cache this value in tests, between operations. + */ + fun getPrimaryUser(): UserInfo = getUser(primaryUserHandle) + + private var nextUserId: Int = 100 + Random.nextInt(0, 900) + + /** + * A flow of [UserEvent] which emulates those normally generated from system broadcasts. + * + * Events are produced by calls to [createPrimaryUser], [createProfile], [removeProfile]. + */ + val userEvents: Flow<UserEvent> + + val users: List<UserInfo> + get() = userInfoMap.values.toList() + + val userHandles: List<UserHandle> + get() = userInfoMap.keys.toList() + + init { + primaryUserHandle = createPrimaryUser(allocateNextId()) + userEvents = eventChannel.consumeAsFlow() + } + + private fun allocateNextId() = nextUserId++ + + private fun createPrimaryUser(id: Int): UserHandle { + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_FULL, USER_TYPE_FULL_SYSTEM) + userInfoMap[userInfo.userHandle] = userInfo + return userInfo.userHandle + } + + fun getUserOrNull(handle: UserHandle): UserInfo? = userInfoMap[handle] + + fun getUser(handle: UserHandle): UserInfo = + requireNotNull(getUserOrNull(handle)) { + "Expected userInfoMap to contain an entry for $handle" + } + + fun setQuietMode(user: UserHandle, quietMode: Boolean) { + userInfoMap[user]?.also { it.flags = it.flags or UserInfo.FLAG_QUIET_MODE } + eventChannel.trySend(UserEvent(ACTION_PROFILE_UNAVAILABLE, user, quietMode)) + } + + fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { + val parentUser = getUser(parent) + require(!parentUser.isProfile) { "Parent user cannot be a profile" } + + // Ensure the parent user has a valid profileGroupId + if (parentUser.profileGroupId == NO_PROFILE_GROUP_ID) { + parentUser.profileGroupId = parentUser.id + } + val id = allocateNextId() + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_PROFILE, type.toUserType()).apply { + profileGroupId = parentUser.profileGroupId + } + userInfoMap[userInfo.userHandle] = userInfo + eventChannel.trySend(UserEvent(ACTION_PROFILE_ADDED, userInfo.userHandle)) + return userInfo.userHandle + } + + fun removeProfile(handle: UserHandle): Boolean { + return userInfoMap[handle]?.let { user -> + require(user.isProfile) { "Only profiles can be removed" } + userInfoMap.remove(user.userHandle) + eventChannel.trySend(UserEvent(ACTION_PROFILE_REMOVED, user.userHandle)) + return true + } + ?: false + } + + override fun toString() = buildString { + append("State(nextUserId=$nextUserId, userInfoMap=[") + userInfoMap.entries.forEach { + append("UserHandle[${it.key.identifier}] = ${it.value.debugString},") + } + append("])") + } + } +} + +/** A safe mock of [Context] which throws on any unstubbed method call. */ +private fun mockContext(user: UserHandle = UserHandle.SYSTEM): Context { + return mock<Context>(withSettings().defaultAnswer(THROWS_EXCEPTION)) { + doAnswer(RETURNS_SELF).whenever(this).applicationContext + doReturn(user).whenever(this).user + doReturn(user.identifier).whenever(this).userId + } +} + +private fun FakeUserManager.ProfileType.toUserType(): String { + return when (this) { + FakeUserManager.ProfileType.WORK -> UserManager.USER_TYPE_PROFILE_MANAGED + FakeUserManager.ProfileType.CLONE -> UserManager.USER_TYPE_PROFILE_CLONE + FakeUserManager.ProfileType.PRIVATE -> UserManager.USER_TYPE_PROFILE_PRIVATE + } +} + +/** A safe mock of [IUserManager] which throws on any unstubbed method call. */ +fun mockService(): IUserManager { + return mock<IUserManager>(withSettings().defaultAnswer(THROWS_EXCEPTION)) +} + +val UserInfo.debugString: String + get() = + "UserInfo(id=$id, profileGroupId=$profileGroupId, name=$name, " + + "type=$userType, flags=${UserInfo.flagsToString(flags)})" diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt new file mode 100644 index 00000000..a2239192 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt @@ -0,0 +1,128 @@ +package com.android.intentresolver.v2.platform + +import android.content.pm.UserInfo +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeUserManagerTest { + private val userManager = FakeUserManager() + private val state = userManager.state + + @Test + fun initialState() { + val personal = userManager.getEnabledProfiles(state.primaryUserHandle.identifier).single() + + assertThat(personal.id).isEqualTo(state.primaryUserHandle.identifier) + assertThat(personal.userType).isEqualTo(UserManager.USER_TYPE_FULL_SYSTEM) + assertThat(personal.flags and UserInfo.FLAG_FULL).isEqualTo(UserInfo.FLAG_FULL) + } + + @Test + fun getProfileParent() { + val workHandle = state.createProfile(ProfileType.WORK) + + assertThat(userManager.getProfileParent(state.primaryUserHandle)).isNull() + assertThat(userManager.getProfileParent(workHandle)).isEqualTo(state.primaryUserHandle) + assertThat(userManager.getProfileParent(UserHandle.of(-1))).isNull() + } + + @Test + fun getUserInfo() { + val personalUser = + requireNotNull(userManager.getUserInfo(state.primaryUserHandle.identifier)) { + "Expected getUserInfo to return non-null" + } + assertTrue(userInfoAreEqual.apply(personalUser, state.getPrimaryUser())) + + val workHandle = state.createProfile(ProfileType.WORK) + + val workUser = + requireNotNull(userManager.getUserInfo(workHandle.identifier)) { + "Expected getUserInfo to return non-null" + } + assertTrue( + userInfoAreEqual.apply(workUser, userManager.getUserInfo(workHandle.identifier)!!) + ) + } + + @Test + fun getEnabledProfiles_usingParentId() { + val personal = state.primaryUserHandle + val work = state.createProfile(ProfileType.WORK) + val private = state.createProfile(ProfileType.PRIVATE) + + val enabledProfiles = userManager.getEnabledProfiles(personal.identifier) + + assertWithMessage("enabledProfiles: List<UserInfo>") + .that(enabledProfiles) + .comparingElementsUsing(userInfoEquality) + .displayingDiffsPairedBy { it.id } + .containsExactly(state.getPrimaryUser(), state.getUser(work), state.getUser(private)) + } + + @Test + fun getEnabledProfiles_usingProfileId() { + val clone = state.createProfile(ProfileType.CLONE) + + val enabledProfiles = userManager.getEnabledProfiles(clone.identifier) + + assertWithMessage("getEnabledProfiles(clone.identifier)") + .that(enabledProfiles) + .comparingElementsUsing(userInfoEquality) + .displayingDiffsPairedBy { it.id } + .containsExactly(state.getPrimaryUser(), state.getUser(clone)) + } + + @Test + fun getUserOrNull() { + val personal = state.getPrimaryUser() + + assertThat(state.getUserOrNull(personal.userHandle)).isEqualTo(personal) + assertThat(state.getUserOrNull(UserHandle.of(personal.id - 1))).isNull() + } + + @Test + fun createProfile() { + // Order dependent: profile creation modifies the primary user + val workHandle = state.createProfile(ProfileType.WORK) + + val primaryUser = state.getPrimaryUser() + val workUser = state.getUser(workHandle) + + assertThat(primaryUser.profileGroupId).isNotEqualTo(NO_PROFILE_GROUP_ID) + assertThat(workUser.profileGroupId).isEqualTo(primaryUser.profileGroupId) + } + + @Test + fun removeProfile() { + val personal = state.getPrimaryUser() + val work = state.createProfile(ProfileType.WORK) + val private = state.createProfile(ProfileType.PRIVATE) + + state.removeProfile(private) + assertThat(state.userHandles).containsExactly(personal.userHandle, work) + } + + @Test(expected = IllegalArgumentException::class) + fun removeProfile_primaryNotAllowed() { + state.removeProfile(state.primaryUserHandle) + } +} + +private val userInfoAreEqual = + Correspondence.BinaryPredicate<UserInfo, UserInfo> { actual, expected -> + actual.id == expected.id && + actual.profileGroupId == expected.profileGroupId && + actual.userType == expected.userType && + actual.flags == expected.flags + } + +val userInfoEquality: Correspondence<UserInfo, UserInfo> = + Correspondence.from(userInfoAreEqual, "==") |