From 43fe454f23a95ec11f25620975340bf392ddb161 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Tue, 14 Nov 2023 14:03:27 -0500 Subject: Rename UserDataSource to UserRepository Adds requestState to allow modification of user profile state, including availability (quiet mode). Test: UserRepositoryImplTest Change-Id: Ic38f24475c73390841ee599c48d965117981faa0 --- .../src/com/android/intentresolver/v2/data/User.kt | 75 ------ .../intentresolver/v2/data/UserDataSource.kt | 227 ------------------ .../intentresolver/v2/data/UserDataSourceModule.kt | 34 --- .../android/intentresolver/v2/data/model/User.kt | 50 ++++ .../v2/data/repository/UserInfoExt.kt | 29 +++ .../v2/data/repository/UserRepository.kt | 261 +++++++++++++++++++++ .../v2/data/repository/UserRepositoryModule.kt | 34 +++ .../v2/data/UserDataSourceImplTest.kt | 194 --------------- .../v2/data/repository/UserRepositoryImplTest.kt | 222 ++++++++++++++++++ .../intentresolver/v2/platform/FakeUserManager.kt | 39 ++- 10 files changed, 632 insertions(+), 533 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/data/User.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/UserDataSource.kt delete mode 100644 java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt create mode 100644 java/src/com/android/intentresolver/v2/data/model/User.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt create mode 100644 java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt delete mode 100644 java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt create mode 100644 java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt (limited to 'java') diff --git a/java/src/com/android/intentresolver/v2/data/User.kt b/java/src/com/android/intentresolver/v2/data/User.kt deleted file mode 100644 index d8a4af74..00000000 --- a/java/src/com/android/intentresolver/v2/data/User.kt +++ /dev/null @@ -1,75 +0,0 @@ -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 = - * 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 deleted file mode 100644 index 9eecc3be..00000000 --- a/java/src/com/android/intentresolver/v2/data/UserDataSource.kt +++ /dev/null @@ -1,227 +0,0 @@ -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> - - /** - * 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 -} - -private const val TAG = "UserDataSource" - -private data class UserWithState(val user: User, val available: Boolean) - -private typealias UserStateMap = Map - -/** 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, - 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 = - userEvents - .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i("UserDataSource", "userEvent: $it") } - .runningFold(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> = - usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() - - private val availability: Flow> = - usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() - - override fun isAvailable(handle: UserHandle): Flow { - 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 { - 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): IntentFilter { - return IntentFilter().apply { actions.forEach(::addAction) } -} - -private fun UserInfo?.isAvailable(): Boolean { - return this?.isQuietModeEnabled != true -} - -private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { - 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 deleted file mode 100644 index 94f39eb7..00000000 --- a/java/src/com/android/intentresolver/v2/data/UserDataSourceModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -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/src/com/android/intentresolver/v2/data/model/User.kt b/java/src/com/android/intentresolver/v2/data/model/User.kt new file mode 100644 index 00000000..504b04c8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/model/User.kt @@ -0,0 +1,50 @@ +package com.android.intentresolver.v2.data.model + +import android.annotation.UserIdInt +import android.os.UserHandle +import com.android.intentresolver.v2.data.model.User.Type +import com.android.intentresolver.v2.data.model.User.Type.FULL +import com.android.intentresolver.v2.data.model.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) + * + * ``` + * val users = listOf( + * 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) + } +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt new file mode 100644 index 00000000..fc82efee --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt @@ -0,0 +1,29 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.pm.UserInfo +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.model.User.Role + +/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ +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 = + * 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/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt new file mode 100644 index 00000000..dc809b46 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -0,0 +1,261 @@ +package com.android.intentresolver.v2.data.repository + +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.broadcastFlow +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.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 UserRepository { + /** + * 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> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + fun isAvailable(user: User): Flow + + /** + * Request that availability be updated to the requested state. This currently includes toggling + * quiet mode as needed. This may involve additional background actions, such as starting or + * stopping a profile user (along with their many associated processes). + * + * If successful, the change will be applied after the call returns and can be observed using + * [UserRepository.isAvailable] for the given user. + * + * No actions are taken if the user is already in requested state. + * + * @throws IllegalArgumentException if called for an unsupported user type + */ + suspend fun requestState(user: User, available: Boolean) +} + +private const val TAG = "UserRepository" + +private data class UserWithState(val user: User, val available: Boolean) + +private typealias UserStateMap = Map + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserRepositoryImpl +@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, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher +) : UserRepository { + @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 = + userEvents + .onStart { emit(UserEvent(INITIALIZE, profileParent)) } + .onEach { Log.i("UserDataSource", "userEvent: $it") } + .runningFold(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> = + usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + + private val availability: Flow> = + usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() + + override fun isAvailable(user: User): Flow { + return isAvailable(user.handle) + } + + @VisibleForTesting + fun isAvailable(handle: UserHandle): Flow { + return availability.map { it[handle] ?: false } + } + + override suspend fun requestState(user: User, available: Boolean) { + require(user.type == User.Type.PROFILE) { "Only profile users are supported" } + return requestState(user.handle, available) + } + + @VisibleForTesting + suspend fun requestState(user: UserHandle, available: Boolean) { + return withContext(backgroundDispatcher) { + Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user) + } + } + + 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 { + 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): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +private fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} + +private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow { + 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/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt new file mode 100644 index 00000000..94f985e7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.data.repository + +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 UserRepositoryModule { + 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 userRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt b/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt deleted file mode 100644 index 56d5de35..00000000 --- a/java/tests/src/com/android/intentresolver/v2/data/UserDataSourceImplTest.kt +++ /dev/null @@ -1,194 +0,0 @@ -@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 { - val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) - doReturn(listOf(info)).whenever(this).getEnabledProfiles(anyInt()) - - doReturn(info).whenever(this).getUserInfo(eq(validUser)) - - doReturn(listOf()).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/data/repository/UserRepositoryImplTest.kt b/java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 00000000..4f514db5 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,222 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Intent +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserHandle.SYSTEM +import android.os.UserHandle.USER_SYSTEM +import android.os.UserManager +import com.android.intentresolver.mock +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.model.User.Role +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.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.doReturn + +internal class UserRepositoryImplTest { + private val userManager = FakeUserManager() + private val userState = userManager.state + + @Test + fun initialization() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users) + .containsExactly( + userState.primaryUserHandle, + User(userState.primaryUserHandle.identifier, Role.PERSONAL) + ) + } + + @Test + fun createProfile() = runTest { + val repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.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 repo = createUserRepository(userManager) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.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 repo = createUserRepository(userManager) + val work = userState.createProfile(ProfileType.WORK) + + val available by collectLastValue(repo.isAvailable(work)) + assertThat(available).isTrue() + + userState.setQuietMode(work, true) + assertThat(available).isFalse() + + userState.setQuietMode(work, false) + assertThat(available).isTrue() + } + + @Test + fun requestState() = runTest { + val repo = createUserRepository(userManager) + val work = userState.createProfile(ProfileType.WORK) + + val available by collectLastValue(repo.isAvailable(work)) + assertThat(available).isTrue() + + repo.requestState(work, false) + assertThat(available).isFalse() + + repo.requestState(work, true) + assertThat(available).isTrue() + } + + @Test(expected = IllegalArgumentException::class) + fun requestState_invalidForFullUser() = runTest { + val repo = createUserRepository(userManager) + val primaryUser = User(userState.primaryUserHandle.identifier, Role.PERSONAL) + repo.requestState(primaryUser, available = false) + } + + /** + * 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 = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent( + Intent.ACTION_PROFILE_ADDED, + UserHandle.of(UserHandle.USER_NULL) + ) + ) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_removed_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent( + Intent.ACTION_PROFILE_REMOVED, + UserHandle.of(UserHandle.USER_NULL) + ) + ) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_invalid_profile_available_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent( + Intent.ACTION_PROFILE_AVAILABLE, + UserHandle.of(UserHandle.USER_NULL) + ) + ) + val repo = + UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } + + @Test + fun recovers_from_unknown_event() = runTest { + val userManager = + mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) + val events = + flowOf( + UserRepositoryImpl.UserEvent("UNKNOWN_EVENT", UserHandle.of(UserHandle.USER_NULL)) + ) + val repo = + UserRepositoryImpl( + profileParent = SYSTEM, + userManager = userManager, + userEvents = events, + scope = backgroundScope, + backgroundDispatcher = Dispatchers.Unconfined + ) + val users by collectLastValue(repo.users) + + assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() + assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + } +} + +@Suppress("SameParameterValue", "DEPRECATION") +private fun mockUserManager(validUser: Int, invalidUser: Int) = + mock { + val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) + doReturn(listOf(info)).whenever(this).getEnabledProfiles(Mockito.anyInt()) + + doReturn(info).whenever(this).getUserInfo(Mockito.eq(validUser)) + + doReturn(listOf()).whenever(this).getEnabledProfiles(Mockito.eq(invalidUser)) + + doReturn(null).whenever(this).getUserInfo(Mockito.eq(invalidUser)) + } + +private fun TestScope.createUserRepository(userManager: FakeUserManager) = + UserRepositoryImpl( + 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 index ef1e5917..370e5a00 100644 --- a/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeUserManager.kt @@ -1,7 +1,10 @@ package com.android.intentresolver.v2.platform import android.content.Context +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.pm.UserInfo @@ -12,9 +15,10 @@ import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID import android.os.IUserManager import android.os.UserHandle import android.os.UserManager +import androidx.annotation.NonNull import com.android.intentresolver.THROWS_EXCEPTION import com.android.intentresolver.mock -import com.android.intentresolver.v2.data.UserDataSourceImpl.UserEvent +import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent import com.android.intentresolver.v2.platform.FakeUserManager.State import com.android.intentresolver.whenever import kotlin.random.Random @@ -77,6 +81,14 @@ class FakeUserManager(val state: State = State()) : } } + override fun requestQuietModeEnabled( + enableQuietMode: Boolean, + @NonNull userHandle: UserHandle + ): Boolean { + state.setQuietMode(userHandle, enableQuietMode) + return true + } + override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { return state.getUser(userHandle).isQuietModeEnabled } @@ -136,8 +148,29 @@ class FakeUserManager(val state: State = State()) : } 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)) + userInfoMap[user]?.also { + it.flags = + if (quietMode) { + it.flags or UserInfo.FLAG_QUIET_MODE + } else { + it.flags and UserInfo.FLAG_QUIET_MODE.inv() + } + val actions = mutableListOf() + if (quietMode) { + actions += ACTION_PROFILE_UNAVAILABLE + if (it.isManagedProfile) { + actions += ACTION_MANAGED_PROFILE_UNAVAILABLE + } + } else { + actions += ACTION_PROFILE_AVAILABLE + if (it.isManagedProfile) { + actions += ACTION_MANAGED_PROFILE_AVAILABLE + } + } + actions.forEach { action -> + eventChannel.trySend(UserEvent(action, user, quietMode)) + } + } } fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { -- cgit v1.2.3-59-g8ed1b