diff options
3 files changed, 699 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/user/domain/interactor/GuestUserInteractor.kt b/packages/SystemUI/src/com/android/systemui/user/domain/interactor/GuestUserInteractor.kt new file mode 100644 index 000000000000..27748128a557 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/domain/interactor/GuestUserInteractor.kt @@ -0,0 +1,322 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.domain.interactor + +import android.annotation.UserIdInt +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.content.pm.UserInfo +import android.os.RemoteException +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import android.view.WindowManagerGlobal +import android.widget.Toast +import com.android.internal.logging.UiEventLogger +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.QSUserSwitcherEvent +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.user.data.repository.UserRepository +import com.android.systemui.user.domain.model.ShowDialogRequestModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +/** Encapsulates business logic to interact with guest user data and systems. */ +@SysUISingleton +class GuestUserInteractor +@Inject +constructor( + @Application private val applicationContext: Context, + @Application private val applicationScope: CoroutineScope, + @Main private val mainDispatcher: CoroutineDispatcher, + @Background private val backgroundDispatcher: CoroutineDispatcher, + private val manager: UserManager, + private val repository: UserRepository, + private val deviceProvisionedController: DeviceProvisionedController, + private val devicePolicyManager: DevicePolicyManager, + private val refreshUsersScheduler: RefreshUsersScheduler, + private val uiEventLogger: UiEventLogger, +) { + /** Whether the device is configured to always have a guest user available. */ + val isGuestUserAutoCreated: Boolean = repository.isGuestUserAutoCreated + + /** Whether the guest user is currently being reset. */ + val isGuestUserResetting: Boolean = repository.isGuestUserResetting + + /** Notifies that the device has finished booting. */ + fun onDeviceBootCompleted() { + applicationScope.launch { + if (isDeviceAllowedToAddGuest()) { + guaranteePresent() + return@launch + } + + suspendCancellableCoroutine<Unit> { continuation -> + val callback = + object : DeviceProvisionedController.DeviceProvisionedListener { + override fun onDeviceProvisionedChanged() { + continuation.resumeWith(Result.success(Unit)) + deviceProvisionedController.removeCallback(this) + } + } + + deviceProvisionedController.addCallback(callback) + } + + if (isDeviceAllowedToAddGuest()) { + guaranteePresent() + } + } + } + + /** Creates a guest user and switches to it. */ + fun createAndSwitchTo( + showDialog: (ShowDialogRequestModel) -> Unit, + dismissDialog: () -> Unit, + selectUser: (userId: Int) -> Unit, + ) { + applicationScope.launch { + val newGuestUserId = create(showDialog, dismissDialog) + if (newGuestUserId != UserHandle.USER_NULL) { + selectUser(newGuestUserId) + } + } + } + + /** Exits the guest user, switching back to the last non-guest user or to the default user. */ + fun exit( + @UserIdInt guestUserId: Int, + @UserIdInt targetUserId: Int, + forceRemoveGuestOnExit: Boolean, + showDialog: (ShowDialogRequestModel) -> Unit, + dismissDialog: () -> Unit, + switchUser: (userId: Int) -> Unit, + ) { + val currentUserInfo = repository.getSelectedUserInfo() + if (currentUserInfo.id != guestUserId) { + Log.w( + TAG, + "User requesting to start a new session ($guestUserId) is not current user" + + " (${currentUserInfo.id})" + ) + return + } + + if (!currentUserInfo.isGuest) { + Log.w(TAG, "User requesting to start a new session ($guestUserId) is not a guest") + return + } + + applicationScope.launch { + var newUserId = UserHandle.USER_SYSTEM + if (targetUserId == UserHandle.USER_NULL) { + // When a target user is not specified switch to last non guest user: + val lastSelectedNonGuestUserHandle = repository.lastSelectedNonGuestUserId + if (lastSelectedNonGuestUserHandle != UserHandle.USER_SYSTEM) { + val info = + withContext(backgroundDispatcher) { + manager.getUserInfo(lastSelectedNonGuestUserHandle) + } + if (info != null && info.isEnabled && info.supportsSwitchToByUser()) { + newUserId = info.id + } + } + } else { + newUserId = targetUserId + } + + if (currentUserInfo.isEphemeral || forceRemoveGuestOnExit) { + uiEventLogger.log(QSUserSwitcherEvent.QS_USER_GUEST_REMOVE) + remove(currentUserInfo.id, newUserId, showDialog, dismissDialog, switchUser) + } else { + uiEventLogger.log(QSUserSwitcherEvent.QS_USER_SWITCH) + switchUser(newUserId) + } + } + } + + /** + * Guarantees that the guest user is present on the device, creating it if needed and if allowed + * to. + */ + suspend fun guaranteePresent() { + if (!isDeviceAllowedToAddGuest()) { + return + } + + val guestUser = withContext(backgroundDispatcher) { manager.findCurrentGuestUser() } + if (guestUser == null) { + scheduleCreation() + } + } + + /** Removes the guest user from the device. */ + private suspend fun remove( + @UserIdInt guestUserId: Int, + @UserIdInt targetUserId: Int, + showDialog: (ShowDialogRequestModel) -> Unit, + dismissDialog: () -> Unit, + switchUser: (userId: Int) -> Unit, + ) { + val currentUser: UserInfo = repository.getSelectedUserInfo() + if (currentUser.id != guestUserId) { + Log.w( + TAG, + "User requesting to start a new session ($guestUserId) is not current user" + + " ($currentUser.id)" + ) + return + } + + if (!currentUser.isGuest) { + Log.w(TAG, "User requesting to start a new session ($guestUserId) is not a guest") + return + } + + val marked = + withContext(backgroundDispatcher) { manager.markGuestForDeletion(currentUser.id) } + if (!marked) { + Log.w(TAG, "Couldn't mark the guest for deletion for user $guestUserId") + return + } + + if (targetUserId == UserHandle.USER_NULL) { + // Create a new guest in the foreground, and then immediately switch to it + val newGuestId = create(showDialog, dismissDialog) + if (newGuestId == UserHandle.USER_NULL) { + Log.e(TAG, "Could not create new guest, switching back to system user") + switchUser(UserHandle.USER_SYSTEM) + withContext(backgroundDispatcher) { manager.removeUser(currentUser.id) } + try { + WindowManagerGlobal.getWindowManagerService().lockNow(/* options= */ null) + } catch (e: RemoteException) { + Log.e( + TAG, + "Couldn't remove guest because ActivityManager or WindowManager is dead" + ) + } + return + } + + switchUser(newGuestId) + + withContext(backgroundDispatcher) { manager.removeUser(currentUser.id) } + } else { + if (repository.isGuestUserAutoCreated) { + repository.isGuestUserResetting = true + } + switchUser(targetUserId) + manager.removeUser(currentUser.id) + } + } + + /** + * Creates the guest user and adds it to the device. + * + * @param showDialog A function to invoke to show a dialog. + * @param dismissDialog A function to invoke to dismiss a dialog. + * @return The user ID of the newly-created guest user. + */ + private suspend fun create( + showDialog: (ShowDialogRequestModel) -> Unit, + dismissDialog: () -> Unit, + ): Int { + return withContext(mainDispatcher) { + showDialog(ShowDialogRequestModel.ShowUserCreationDialog(isGuest = true)) + val guestUserId = createInBackground() + dismissDialog() + if (guestUserId != UserHandle.USER_NULL) { + uiEventLogger.log(QSUserSwitcherEvent.QS_USER_GUEST_ADD) + } else { + Toast.makeText( + applicationContext, + com.android.settingslib.R.string.add_guest_failed, + Toast.LENGTH_SHORT, + ) + .show() + } + + guestUserId + } + } + + /** Schedules the creation of the guest user. */ + private suspend fun scheduleCreation() { + if (!repository.isGuestUserCreationScheduled.compareAndSet(false, true)) { + return + } + + withContext(backgroundDispatcher) { + val newGuestUserId = createInBackground() + repository.isGuestUserCreationScheduled.set(false) + repository.isGuestUserResetting = false + if (newGuestUserId == UserHandle.USER_NULL) { + Log.w(TAG, "Could not create new guest while exiting existing guest") + // Refresh users so that we still display "Guest" if + // config_guestUserAutoCreated=true + refreshUsersScheduler.refreshIfNotPaused() + } + } + } + + /** + * Creates a guest user and return its multi-user user ID. + * + * This method does not check if a guest already exists before it makes a call to [UserManager] + * to create a new one. + * + * @return The multi-user user ID of the newly created guest user, or [UserHandle.USER_NULL] if + * the guest couldn't be created. + */ + @UserIdInt + private suspend fun createInBackground(): Int { + return withContext(backgroundDispatcher) { + try { + val guestUser = manager.createGuest(applicationContext) + if (guestUser != null) { + guestUser.id + } else { + Log.e( + TAG, + "Couldn't create guest, most likely because there already exists one!" + ) + UserHandle.USER_NULL + } + } catch (e: UserManager.UserOperationException) { + Log.e(TAG, "Couldn't create guest user!", e) + UserHandle.USER_NULL + } + } + } + + private fun isDeviceAllowedToAddGuest(): Boolean { + return deviceProvisionedController.isDeviceProvisioned && + !devicePolicyManager.isDeviceManaged + } + + companion object { + private const val TAG = "GuestUserInteractor" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/domain/model/ShowDialogRequestModel.kt b/packages/SystemUI/src/com/android/systemui/user/domain/model/ShowDialogRequestModel.kt new file mode 100644 index 000000000000..08d7c5a26a25 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/domain/model/ShowDialogRequestModel.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.domain.model + +import android.os.UserHandle + +/** Encapsulates a request to show a dialog. */ +sealed class ShowDialogRequestModel { + data class ShowAddUserDialog( + val userHandle: UserHandle, + val isKeyguardShowing: Boolean, + val showEphemeralMessage: Boolean, + ) : ShowDialogRequestModel() + + data class ShowUserCreationDialog( + val isGuest: Boolean, + ) : ShowDialogRequestModel() + + data class ShowExitGuestDialog( + val guestUserId: Int, + val targetUserId: Int, + val isGuestEphemeral: Boolean, + val isKeyguardShowing: Boolean, + val onExitGuestUser: (guestId: Int, targetId: Int, forceRemoveGuest: Boolean) -> Unit, + ) : ShowDialogRequestModel() +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/GuestUserInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/GuestUserInteractorTest.kt new file mode 100644 index 000000000000..6b4c9ed38b47 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/domain/interactor/GuestUserInteractorTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.user.domain.interactor + +import android.app.admin.DevicePolicyManager +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.internal.logging.UiEventLogger +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.user.data.repository.FakeUserRepository +import com.android.systemui.user.domain.model.ShowDialogRequestModel +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.kotlinArgumentCaptor +import com.android.systemui.util.mockito.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class GuestUserInteractorTest : SysuiTestCase() { + + @Mock private lateinit var manager: UserManager + @Mock private lateinit var deviceProvisionedController: DeviceProvisionedController + @Mock private lateinit var devicePolicyManager: DevicePolicyManager + @Mock private lateinit var uiEventLogger: UiEventLogger + @Mock private lateinit var showDialog: (ShowDialogRequestModel) -> Unit + @Mock private lateinit var dismissDialog: () -> Unit + @Mock private lateinit var selectUser: (Int) -> Unit + @Mock private lateinit var switchUser: (Int) -> Unit + + private lateinit var underTest: GuestUserInteractor + + private lateinit var scope: TestCoroutineScope + private lateinit var repository: FakeUserRepository + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + whenever(manager.createGuest(any())).thenReturn(GUEST_USER_INFO) + + scope = TestCoroutineScope() + repository = FakeUserRepository() + repository.setUserInfos(ALL_USERS) + + underTest = + GuestUserInteractor( + applicationContext = context, + applicationScope = scope, + mainDispatcher = IMMEDIATE, + backgroundDispatcher = IMMEDIATE, + manager = manager, + repository = repository, + deviceProvisionedController = deviceProvisionedController, + devicePolicyManager = devicePolicyManager, + refreshUsersScheduler = + RefreshUsersScheduler( + applicationScope = scope, + mainDispatcher = IMMEDIATE, + repository = repository, + ), + uiEventLogger = uiEventLogger, + ) + } + + @Test + fun `onDeviceBootCompleted - allowed to add - create guest`() = + runBlocking(IMMEDIATE) { + setAllowedToAdd() + + underTest.onDeviceBootCompleted() + + verify(manager).createGuest(any()) + verify(deviceProvisionedController, never()).addCallback(any()) + } + + @Test + fun `onDeviceBootCompleted - await provisioning - and create guest`() = + runBlocking(IMMEDIATE) { + setAllowedToAdd(isAllowed = false) + underTest.onDeviceBootCompleted() + val captor = + kotlinArgumentCaptor<DeviceProvisionedController.DeviceProvisionedListener>() + verify(deviceProvisionedController).addCallback(captor.capture()) + + setAllowedToAdd(isAllowed = true) + captor.value.onDeviceProvisionedChanged() + + verify(manager).createGuest(any()) + verify(deviceProvisionedController).removeCallback(captor.value) + } + + @Test + fun createAndSwitchTo() = + runBlocking(IMMEDIATE) { + underTest.createAndSwitchTo( + showDialog = showDialog, + dismissDialog = dismissDialog, + selectUser = selectUser, + ) + + verify(showDialog).invoke(ShowDialogRequestModel.ShowUserCreationDialog(isGuest = true)) + verify(manager).createGuest(any()) + verify(dismissDialog).invoke() + verify(selectUser).invoke(GUEST_USER_INFO.id) + } + + @Test + fun `createAndSwitchTo - fails to create - does not switch to`() = + runBlocking(IMMEDIATE) { + whenever(manager.createGuest(any())).thenReturn(null) + + underTest.createAndSwitchTo( + showDialog = showDialog, + dismissDialog = dismissDialog, + selectUser = selectUser, + ) + + verify(showDialog).invoke(ShowDialogRequestModel.ShowUserCreationDialog(isGuest = true)) + verify(manager).createGuest(any()) + verify(dismissDialog).invoke() + verify(selectUser, never()).invoke(anyInt()) + } + + @Test + fun `exit - returns to target user`() = + runBlocking(IMMEDIATE) { + repository.setSelectedUserInfo(GUEST_USER_INFO) + + val targetUserId = NON_GUEST_USER_INFO.id + underTest.exit( + guestUserId = GUEST_USER_INFO.id, + targetUserId = targetUserId, + forceRemoveGuestOnExit = false, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verify(manager, never()).markGuestForDeletion(anyInt()) + verify(manager, never()).removeUser(anyInt()) + verify(switchUser).invoke(targetUserId) + } + + @Test + fun `exit - returns to last non-guest`() = + runBlocking(IMMEDIATE) { + val expectedUserId = NON_GUEST_USER_INFO.id + whenever(manager.getUserInfo(expectedUserId)).thenReturn(NON_GUEST_USER_INFO) + repository.lastSelectedNonGuestUserId = expectedUserId + repository.setSelectedUserInfo(GUEST_USER_INFO) + + underTest.exit( + guestUserId = GUEST_USER_INFO.id, + targetUserId = UserHandle.USER_NULL, + forceRemoveGuestOnExit = false, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verify(manager, never()).markGuestForDeletion(anyInt()) + verify(manager, never()).removeUser(anyInt()) + verify(switchUser).invoke(expectedUserId) + } + + @Test + fun `exit - last non-guest was removed - returns to system`() = + runBlocking(IMMEDIATE) { + val removedUserId = 310 + repository.lastSelectedNonGuestUserId = removedUserId + repository.setSelectedUserInfo(GUEST_USER_INFO) + + underTest.exit( + guestUserId = GUEST_USER_INFO.id, + targetUserId = UserHandle.USER_NULL, + forceRemoveGuestOnExit = false, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verify(manager, never()).markGuestForDeletion(anyInt()) + verify(manager, never()).removeUser(anyInt()) + verify(switchUser).invoke(UserHandle.USER_SYSTEM) + } + + @Test + fun `exit - guest was ephemeral - it is removed`() = + runBlocking(IMMEDIATE) { + whenever(manager.markGuestForDeletion(anyInt())).thenReturn(true) + repository.setUserInfos(listOf(NON_GUEST_USER_INFO, EPHEMERAL_GUEST_USER_INFO)) + repository.setSelectedUserInfo(EPHEMERAL_GUEST_USER_INFO) + val targetUserId = NON_GUEST_USER_INFO.id + + underTest.exit( + guestUserId = GUEST_USER_INFO.id, + targetUserId = targetUserId, + forceRemoveGuestOnExit = false, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verify(manager).markGuestForDeletion(EPHEMERAL_GUEST_USER_INFO.id) + verify(manager).removeUser(EPHEMERAL_GUEST_USER_INFO.id) + verify(switchUser).invoke(targetUserId) + } + + @Test + fun `exit - force remove guest - it is removed`() = + runBlocking(IMMEDIATE) { + whenever(manager.markGuestForDeletion(anyInt())).thenReturn(true) + repository.setSelectedUserInfo(GUEST_USER_INFO) + val targetUserId = NON_GUEST_USER_INFO.id + + underTest.exit( + guestUserId = GUEST_USER_INFO.id, + targetUserId = targetUserId, + forceRemoveGuestOnExit = true, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verify(manager).markGuestForDeletion(GUEST_USER_INFO.id) + verify(manager).removeUser(GUEST_USER_INFO.id) + verify(switchUser).invoke(targetUserId) + } + + @Test + fun `exit - selected different from guest user - do nothing`() = + runBlocking(IMMEDIATE) { + repository.setSelectedUserInfo(NON_GUEST_USER_INFO) + + underTest.exit( + guestUserId = GUEST_USER_INFO.id, + targetUserId = 123, + forceRemoveGuestOnExit = false, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verifyDidNotExit() + } + + @Test + fun `exit - selected is actually not a guest user - do nothing`() = + runBlocking(IMMEDIATE) { + repository.setSelectedUserInfo(NON_GUEST_USER_INFO) + + underTest.exit( + guestUserId = NON_GUEST_USER_INFO.id, + targetUserId = 123, + forceRemoveGuestOnExit = false, + showDialog = showDialog, + dismissDialog = dismissDialog, + switchUser = switchUser, + ) + + verifyDidNotExit() + } + + private fun setAllowedToAdd(isAllowed: Boolean = true) { + whenever(deviceProvisionedController.isDeviceProvisioned).thenReturn(isAllowed) + whenever(devicePolicyManager.isDeviceManaged).thenReturn(!isAllowed) + } + + private fun verifyDidNotExit() { + verify(manager, never()).getUserInfo(anyInt()) + verify(manager, never()).markGuestForDeletion(anyInt()) + verify(uiEventLogger, never()).log(any()) + verify(showDialog, never()).invoke(any()) + verify(dismissDialog, never()).invoke() + verify(switchUser, never()).invoke(anyInt()) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + private val NON_GUEST_USER_INFO = + UserInfo( + /* id= */ 818, + /* name= */ "non_guest", + /* flags= */ 0, + ) + private val GUEST_USER_INFO = + UserInfo( + /* id= */ 669, + /* name= */ "guest", + /* iconPath= */ "", + /* flags= */ 0, + UserManager.USER_TYPE_FULL_GUEST, + ) + private val EPHEMERAL_GUEST_USER_INFO = + UserInfo( + /* id= */ 669, + /* name= */ "guest", + /* iconPath= */ "", + /* flags= */ UserInfo.FLAG_EPHEMERAL, + UserManager.USER_TYPE_FULL_GUEST, + ) + private val ALL_USERS = + listOf( + NON_GUEST_USER_INFO, + GUEST_USER_INFO, + ) + } +} |