summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Mark Renouf <mrenouf@google.com> 2023-10-24 09:36:48 -0400
committer Mark Renouf <mrenouf@google.com> 2023-11-10 14:53:12 -0500
commit75e928b3330c383363096d9113a804215863fba5 (patch)
treecb637e671b9fa2a8e49f678605cc44eb20e454ca
parent80118c7a461a6111829ddbc5c44931044d00e9fd (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
-rw-r--r--java/src/com/android/intentresolver/inject/Qualifiers.kt7
-rw-r--r--java/src/com/android/intentresolver/inject/SingletonModule.kt2
-rw-r--r--java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt46
-rw-r--r--java/src/com/android/intentresolver/v2/data/User.kt75
-rw-r--r--java/src/com/android/intentresolver/v2/data/UserDataSource.kt227
-rw-r--r--java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt34
-rw-r--r--java/tests/src/com/android/intentresolver/v2/coroutines/Flow.kt89
-rw-r--r--java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt194
-rw-r--r--java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt206
-rw-r--r--java/tests/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt128
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, "==")