diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index 6c670c53d794d..28f4fd6b7f7ea 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -230,6 +230,7 @@ android_library { libs: [ "android.test.runner", "android.test.base", + "android.test.mock", ], kotlincflags: ["-Xjvm-default=enable"], aaptflags: [ diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/ActivityLaunchAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/ActivityLaunchAnimator.kt index 8ddd430dadbc0..7d4dcf88542b8 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/ActivityLaunchAnimator.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/ActivityLaunchAnimator.kt @@ -311,8 +311,7 @@ class ActivityLaunchAnimator( @JvmStatic fun fromView(view: View, cujType: Int? = null): Controller? { if (view.parent !is ViewGroup) { - // TODO(b/192194319): Throw instead of just logging. - Log.wtf( + Log.e( TAG, "Skipping animation as view $view is not attached to a ViewGroup", Exception() diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/Expandable.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/Expandable.kt new file mode 100644 index 0000000000000..8ce372dbb2780 --- /dev/null +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/Expandable.kt @@ -0,0 +1,52 @@ +/* + * 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.animation + +import android.view.View + +/** A piece of UI that can be expanded into a Dialog or an Activity. */ +interface Expandable { + /** + * Create an [ActivityLaunchAnimator.Controller] that can be used to expand this [Expandable] + * into an Activity, or return `null` if this [Expandable] should not be animated (e.g. if it is + * currently not attached or visible). + * + * @param cujType the CUJ type from the [com.android.internal.jank.InteractionJankMonitor] + * associated to the launch that will use this controller. + */ + fun activityLaunchController(cujType: Int? = null): ActivityLaunchAnimator.Controller? + + // TODO(b/230830644): Introduce DialogLaunchAnimator and a function to expose it here. + + companion object { + /** + * Create an [Expandable] that will animate [view] when expanded. + * + * Note: The background of [view] should be a (rounded) rectangle so that it can be properly + * animated. + */ + fun fromView(view: View): Expandable { + return object : Expandable { + override fun activityLaunchController( + cujType: Int?, + ): ActivityLaunchAnimator.Controller? { + return ActivityLaunchAnimator.Controller.fromView(view, cujType) + } + } + } + } +} diff --git a/packages/SystemUI/compose/gallery/Android.bp b/packages/SystemUI/compose/gallery/Android.bp index b0f5cc1121209..5a7a1e1807a33 100644 --- a/packages/SystemUI/compose/gallery/Android.bp +++ b/packages/SystemUI/compose/gallery/Android.bp @@ -54,6 +54,11 @@ android_library { "testables", "truth-prebuilt", "androidx.test.uiautomator", + "kotlinx_coroutines_test", + ], + + libs: [ + "android.test.mock", ], kotlincflags: ["-Xjvm-default=all"], diff --git a/packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt b/packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt new file mode 100644 index 0000000000000..bebade0cc4845 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt @@ -0,0 +1,33 @@ +/* + * 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.common.shared.model + +import android.annotation.StringRes + +/** + * Models a content description, that can either be already [loaded][ContentDescription.Loaded] or + * be a [reference][ContentDescription.Resource] to a resource. + */ +sealed class ContentDescription { + data class Loaded( + val description: String?, + ) : ContentDescription() + + data class Resource( + @StringRes val res: Int, + ) : ContentDescription() +} diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt new file mode 100644 index 0000000000000..0e0d19815d7bd --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt @@ -0,0 +1,32 @@ +/* + * 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.common.ui + +import android.widget.ImageView +import com.android.systemui.common.shared.model.Icon + +object IconViewBinder { + fun bind( + icon: Icon, + view: ImageView, + ) { + when (icon) { + is Icon.Loaded -> view.setImageDrawable(icon.drawable) + is Icon.Resource -> view.setImageResource(icon.res) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt new file mode 100644 index 0000000000000..000c23d2e592c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt @@ -0,0 +1,43 @@ +/* + * 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.qs.footer.dagger + +import com.android.systemui.qs.FgsManagerController +import com.android.systemui.qs.FgsManagerControllerImpl +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepositoryImpl +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepositoryImpl +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractorImpl +import dagger.Binds +import dagger.Module + +/** Dagger module to provide/bind footer actions singletons. */ +@Module +interface FooterActionsModule { + @Binds fun userSwitcherRepository(impl: UserSwitcherRepositoryImpl): UserSwitcherRepository + + @Binds + fun foregroundServicesRepository( + impl: ForegroundServicesRepositoryImpl + ): ForegroundServicesRepository + + @Binds fun footerActionsInteractor(impl: FooterActionsInteractorImpl): FooterActionsInteractor + + @Binds fun fgsManagerControllerImpl(impl: FgsManagerControllerImpl): FgsManagerController +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt new file mode 100644 index 0000000000000..4ca229ab4e615 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt @@ -0,0 +1,32 @@ +/* + * 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.qs.footer.data.model + +import android.graphics.drawable.Drawable + +/** The current status of the User Switcher. */ +sealed class UserSwitcherStatusModel { + /** The user switcher is disabled. */ + object Disabled : UserSwitcherStatusModel() + + /** The user switcher is enabled. */ + data class Enabled( + val currentUserName: String?, + val currentUserImage: Drawable?, + val isGuestUser: Boolean, + ) : UserSwitcherStatusModel() +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt new file mode 100644 index 0000000000000..37a9c40ffacfa --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt @@ -0,0 +1,121 @@ +/* + * 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.qs.footer.data.repository + +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.qs.FgsManagerController +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge + +interface ForegroundServicesRepository { + /** + * The number of packages with a service running in the foreground. + * + * Note that this will be equal to 0 if [FgsManagerController.isAvailable] is false. + */ + val foregroundServicesCount: Flow + + /** + * Whether there were new changes to the foreground packages since a dialog was last shown. + * + * Note that this will be equal to `false` if [FgsManagerController.showFooterDot] is false. + */ + val hasNewChanges: Flow +} + +@SysUISingleton +class ForegroundServicesRepositoryImpl +@Inject +constructor( + fgsManagerController: FgsManagerController, +) : ForegroundServicesRepository { + override val foregroundServicesCount: Flow = + fgsManagerController.isAvailable + .flatMapLatest { isAvailable -> + if (!isAvailable) { + return@flatMapLatest flowOf(0) + } + + conflatedCallbackFlow { + fun updateState(numberOfPackages: Int) { + trySendWithFailureLogging(numberOfPackages, TAG) + } + + val listener = + object : FgsManagerController.OnNumberOfPackagesChangedListener { + override fun onNumberOfPackagesChanged(numberOfPackages: Int) { + updateState(numberOfPackages) + } + } + + fgsManagerController.addOnNumberOfPackagesChangedListener(listener) + updateState(fgsManagerController.numRunningPackages) + awaitClose { + fgsManagerController.removeOnNumberOfPackagesChangedListener(listener) + } + } + } + .distinctUntilChanged() + + override val hasNewChanges: Flow = + fgsManagerController.showFooterDot.flatMapLatest { showFooterDot -> + if (!showFooterDot) { + return@flatMapLatest flowOf(false) + } + + // A flow that emits whenever the FGS dialog is dismissed. + val dialogDismissedEvents = conflatedCallbackFlow { + fun updateState() { + trySendWithFailureLogging( + Unit, + TAG, + ) + } + + val listener = + object : FgsManagerController.OnDialogDismissedListener { + override fun onDialogDismissed() { + updateState() + } + } + + fgsManagerController.addOnDialogDismissedListener(listener) + awaitClose { fgsManagerController.removeOnDialogDismissedListener(listener) } + } + + // Query [fgsManagerController.newChangesSinceDialogWasDismissed] everytime the dialog + // is dismissed or when [foregroundServices] is changing. + merge( + foregroundServicesCount, + dialogDismissedEvents, + ) + .map { fgsManagerController.newChangesSinceDialogWasDismissed } + .distinctUntilChanged() + } + + companion object { + private const val TAG = "ForegroundServicesRepositoryImpl" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt new file mode 100644 index 0000000000000..e969d4c6e08a7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt @@ -0,0 +1,155 @@ +/* + * 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.qs.footer.data.repository + +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.UserManager +import android.provider.Settings.Global.USER_SWITCHER_ENABLED +import com.android.keyguard.KeyguardUpdateMonitor +import com.android.systemui.R +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.qs.SettingObserver +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.settings.UserTracker +import com.android.systemui.statusbar.policy.UserInfoController +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.util.settings.GlobalSettings +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +interface UserSwitcherRepository { + /** The current [UserSwitcherStatusModel]. */ + val userSwitcherStatus: Flow +} + +@SysUISingleton +class UserSwitcherRepositoryImpl +@Inject +constructor( + @Application private val context: Context, + @Background private val bgHandler: Handler, + @Background private val bgDispatcher: CoroutineDispatcher, + private val userManager: UserManager, + private val userTracker: UserTracker, + private val userSwitcherController: UserSwitcherController, + private val userInfoController: UserInfoController, + private val globalSetting: GlobalSettings, +) : UserSwitcherRepository { + private val showUserSwitcherForSingleUser = + context.resources.getBoolean(R.bool.qs_show_user_switcher_for_single_user) + + /** Whether the user switcher is currently enabled. */ + private val isEnabled: Flow = conflatedCallbackFlow { + suspend fun updateState() { + trySendWithFailureLogging(isUserSwitcherEnabled(), TAG) + } + + val observer = + object : + SettingObserver( + globalSetting, + bgHandler, + USER_SWITCHER_ENABLED, + userTracker.userId, + ) { + override fun handleValueChanged(value: Int, observedChange: Boolean) { + if (observedChange) { + launch { updateState() } + } + } + } + + observer.isListening = true + updateState() + awaitClose { observer.isListening = false } + } + + /** The current user name. */ + private val currentUserName: Flow = conflatedCallbackFlow { + suspend fun updateState() { + trySendWithFailureLogging(getCurrentUser(), TAG) + } + + val callback = UserSwitcherController.UserSwitchCallback { launch { updateState() } } + + userSwitcherController.addUserSwitchCallback(callback) + updateState() + awaitClose { userSwitcherController.removeUserSwitchCallback(callback) } + } + + /** The current (icon, isGuestUser) values. */ + // TODO(b/242040009): Could we only use this callback to get the user name and remove + // currentUsername above? + private val currentUserInfo: Flow> = conflatedCallbackFlow { + val listener = + UserInfoController.OnUserInfoChangedListener { _, picture, _ -> + launch { trySendWithFailureLogging(picture to isGuestUser(), TAG) } + } + + // This will automatically call the listener when attached, so no need to update the state + // here. + userInfoController.addCallback(listener) + awaitClose { userInfoController.removeCallback(listener) } + } + + override val userSwitcherStatus: Flow = + isEnabled + .flatMapLatest { enabled -> + if (enabled) { + combine(currentUserName, currentUserInfo) { name, (icon, isGuest) -> + UserSwitcherStatusModel.Enabled(name, icon, isGuest) + } + } else { + flowOf(UserSwitcherStatusModel.Disabled) + } + } + .distinctUntilChanged() + + private suspend fun isUserSwitcherEnabled(): Boolean { + return withContext(bgDispatcher) { + userManager.isUserSwitcherEnabled(showUserSwitcherForSingleUser) + } + } + + private suspend fun getCurrentUser(): String? { + return withContext(bgDispatcher) { userSwitcherController.currentUserName } + } + + private suspend fun isGuestUser(): Boolean { + return withContext(bgDispatcher) { + userManager.isGuestUser(KeyguardUpdateMonitor.getCurrentUser()) + } + } + + companion object { + private const val TAG = "UserSwitcherRepositoryImpl" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt new file mode 100644 index 0000000000000..cf9b41c25388e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt @@ -0,0 +1,211 @@ +/* + * 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.qs.footer.domain.interactor + +import android.app.admin.DevicePolicyEventLogger +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.UserHandle +import android.provider.Settings +import android.view.View +import com.android.internal.jank.InteractionJankMonitor +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.nano.MetricsProto +import com.android.internal.util.FrameworkStatsLog +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.animation.Expandable +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.qs.FgsManagerController +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig +import com.android.systemui.qs.user.UserSwitchDialogController +import com.android.systemui.security.data.repository.SecurityRepository +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.user.UserSwitcherActivity +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +/** Interactor for the footer actions business logic. */ +interface FooterActionsInteractor { + /** The current [SecurityButtonConfig]. */ + val securityButtonConfig: Flow + + /** The number of packages with a service running in the foreground. */ + val foregroundServicesCount: Flow + + /** Whether there are new packages with a service running in the foreground. */ + val hasNewForegroundServices: Flow + + /** The current [UserSwitcherStatusModel]. */ + val userSwitcherStatus: Flow + + /** + * The flow emitting `Unit` whenever a request to show the device monitoring dialog is fired. + */ + val deviceMonitoringDialogRequests: Flow + + /** + * Show the device monitoring dialog, expanded from [view]. + * + * Important: [view] must be associated to the same [Context] as the [Quick Settings fragment] + * [com.android.systemui.qs.QSFragment]. + */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showDeviceMonitoringDialog(view: View) + + /** + * Show the device monitoring dialog. + * + * Important: [quickSettingsContext] *must* be the [Context] associated to the [Quick Settings + * fragment][com.android.systemui.qs.QSFragment]. + */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showDeviceMonitoringDialog(quickSettingsContext: Context) + + /** Show the foreground services dialog. */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showForegroundServicesDialog(view: View) + + /** Show the power menu dialog. */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showPowerMenuDialog(globalActionsDialogLite: GlobalActionsDialogLite, view: View) + + /** Show the settings. */ + fun showSettings(expandable: Expandable) + + /** Show the user switcher. */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showUserSwitcher(view: View) +} + +@SysUISingleton +class FooterActionsInteractorImpl +@Inject +constructor( + private val activityStarter: ActivityStarter, + private val featureFlags: FeatureFlags, + private val metricsLogger: MetricsLogger, + private val uiEventLogger: UiEventLogger, + private val deviceProvisionedController: DeviceProvisionedController, + private val qsSecurityFooterUtils: QSSecurityFooterUtils, + private val fgsManagerController: FgsManagerController, + private val userSwitchDialogController: UserSwitchDialogController, + securityRepository: SecurityRepository, + foregroundServicesRepository: ForegroundServicesRepository, + userSwitcherRepository: UserSwitcherRepository, + broadcastDispatcher: BroadcastDispatcher, + @Background bgDispatcher: CoroutineDispatcher, +) : FooterActionsInteractor { + override val securityButtonConfig: Flow = + securityRepository.security.map { security -> + withContext(bgDispatcher) { qsSecurityFooterUtils.getButtonConfig(security) } + } + + override val foregroundServicesCount: Flow = + foregroundServicesRepository.foregroundServicesCount + + override val hasNewForegroundServices: Flow = + foregroundServicesRepository.hasNewChanges + + override val userSwitcherStatus: Flow = + userSwitcherRepository.userSwitcherStatus + + override val deviceMonitoringDialogRequests: Flow = + broadcastDispatcher.broadcastFlow( + IntentFilter(DevicePolicyManager.ACTION_SHOW_DEVICE_MONITORING_DIALOG), + UserHandle.ALL, + Context.RECEIVER_EXPORTED, + null, + ) + + override fun showDeviceMonitoringDialog(view: View) { + qsSecurityFooterUtils.showDeviceMonitoringDialog(view.context, view) + DevicePolicyEventLogger.createEvent( + FrameworkStatsLog.DEVICE_POLICY_EVENT__EVENT_ID__DO_USER_INFO_CLICKED + ) + .write() + } + + override fun showDeviceMonitoringDialog(quickSettingsContext: Context) { + qsSecurityFooterUtils.showDeviceMonitoringDialog(quickSettingsContext, /* view= */ null) + } + + override fun showForegroundServicesDialog(view: View) { + fgsManagerController.showDialog(view) + } + + override fun showPowerMenuDialog(globalActionsDialogLite: GlobalActionsDialogLite, view: View) { + uiEventLogger.log(GlobalActionsDialogLite.GlobalActionsEvent.GA_OPEN_QS) + globalActionsDialogLite.showOrHideDialog( + /* keyguardShowing= */ false, + /* isDeviceProvisioned= */ true, + view, + ) + } + + override fun showSettings(expandable: Expandable) { + if (!deviceProvisionedController.isCurrentUserSetup) { + // If user isn't setup just unlock the device and dump them back at SUW. + activityStarter.postQSRunnableDismissingKeyguard {} + return + } + + metricsLogger.action(MetricsProto.MetricsEvent.ACTION_QS_EXPANDED_SETTINGS_LAUNCH) + activityStarter.startActivity( + Intent(Settings.ACTION_SETTINGS), + true /* dismissShade */, + expandable.activityLaunchController( + InteractionJankMonitor.CUJ_SHADE_APP_LAUNCH_FROM_SETTINGS_BUTTON + ), + ) + } + + override fun showUserSwitcher(view: View) { + if (!featureFlags.isEnabled(Flags.FULL_SCREEN_USER_SWITCHER)) { + userSwitchDialogController.showDialog(view) + return + } + + val intent = + Intent(view.context, UserSwitcherActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + } + + activityStarter.startActivity( + intent, + true /* dismissShade */, + ActivityLaunchAnimator.Controller.fromView(view, null), + true /* showOverlockscreenwhenlocked */, + UserHandle.SYSTEM, + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt new file mode 100644 index 0000000000000..4c0879e225c16 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt @@ -0,0 +1,36 @@ +/* + * 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.qs.footer.ui.viewmodel + +import android.annotation.DrawableRes +import android.view.View +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon + +/** + * A ViewModel for a simple footer actions button. This is used for the user switcher, settings and + * power buttons. + */ +data class FooterActionsButtonViewModel( + val icon: Icon, + val iconTint: Int?, + @DrawableRes val background: Int, + val contentDescription: ContentDescription, + // TODO(b/230830644): Replace View by an Expandable interface that can expand in either dialog + // or activity. + val onClick: (View) -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt new file mode 100644 index 0000000000000..98b53cb0ed5a5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt @@ -0,0 +1,28 @@ +/* + * 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.qs.footer.ui.viewmodel + +import android.view.View + +/** A ViewModel for the foreground services button. */ +data class FooterActionsForegroundServicesButtonViewModel( + val foregroundServicesCount: Int, + val text: String, + val displayText: Boolean, + val hasNewChanges: Boolean, + val onClick: (View) -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt new file mode 100644 index 0000000000000..98ab129fc9deb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt @@ -0,0 +1,27 @@ +/* + * 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.qs.footer.ui.viewmodel + +import android.view.View +import com.android.systemui.common.shared.model.Icon + +/** A ViewModel for the security button. */ +data class FooterActionsSecurityButtonViewModel( + val icon: Icon, + val text: String, + val onClick: ((View) -> Unit)?, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt new file mode 100644 index 0000000000000..8afe6f216cbb4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt @@ -0,0 +1,304 @@ +/* + * 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.qs.footer.ui.viewmodel + +import android.content.Context +import android.util.Log +import android.view.View +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.android.settingslib.Utils +import com.android.settingslib.drawable.UserIconDrawable +import com.android.systemui.R +import com.android.systemui.animation.Expandable +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.FalsingManager +import com.android.systemui.qs.dagger.QSFlagsModule.PM_LITE_ENABLED +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.util.icuMessageFormat +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider +import kotlin.math.max +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** A ViewModel for the footer actions. */ +class FooterActionsViewModel( + @Application private val context: Context, + private val footerActionsInteractor: FooterActionsInteractor, + private val falsingManager: FalsingManager, + private val globalActionsDialogLite: GlobalActionsDialogLite, + showPowerButton: Boolean, +) { + /** + * Whether the UI rendering this ViewModel should be visible. Note that even when this is false, + * the UI should still participate to the layout it is included in (i.e. in the View world it + * should be INVISIBLE, not GONE). + */ + private val _isVisible = MutableStateFlow(true) + val isVisible: StateFlow = _isVisible.asStateFlow() + + /** The alpha the UI rendering this ViewModel should have. */ + private val _alpha = MutableStateFlow(1f) + val alpha: StateFlow = _alpha.asStateFlow() + + /** The alpha the background of the UI rendering this ViewModel should have. */ + private val _backgroundAlpha = MutableStateFlow(1f) + val backgroundAlpha: StateFlow = _backgroundAlpha.asStateFlow() + + /** The model for the security button. */ + val security: Flow = + footerActionsInteractor.securityButtonConfig.map { config -> + val (icon, text, isClickable) = config ?: return@map null + FooterActionsSecurityButtonViewModel( + icon, + text, + if (isClickable) this::onSecurityButtonClicked else null, + ) + } + + /** The model for the foreground services button. */ + val foregroundServices: Flow = + combine( + footerActionsInteractor.foregroundServicesCount, + footerActionsInteractor.hasNewForegroundServices, + security, + ) { foregroundServicesCount, hasNewChanges, securityModel -> + if (foregroundServicesCount <= 0) { + return@combine null + } + + val text = + icuMessageFormat( + context.resources, + R.string.fgs_manager_footer_label, + foregroundServicesCount, + ) + FooterActionsForegroundServicesButtonViewModel( + foregroundServicesCount, + text = text, + displayText = securityModel == null, + hasNewChanges = hasNewChanges, + this::onForegroundServiceButtonClicked, + ) + } + + /** The model for the user switcher button. */ + val userSwitcher: Flow = + footerActionsInteractor.userSwitcherStatus.map { userSwitcherStatus -> + when (userSwitcherStatus) { + UserSwitcherStatusModel.Disabled -> null + is UserSwitcherStatusModel.Enabled -> { + if (userSwitcherStatus.currentUserImage == null) { + Log.e( + TAG, + "Skipped the addition of user switcher button because " + + "currentUserImage is missing", + ) + return@map null + } + + userSwitcherButton(userSwitcherStatus) + } + } + } + + /** The model for the settings button. */ + val settings: FooterActionsButtonViewModel = + FooterActionsButtonViewModel( + Icon.Resource(R.drawable.ic_settings), + iconTint = null, + R.drawable.qs_footer_action_circle, + ContentDescription.Resource(R.string.accessibility_quick_settings_settings), + this::onSettingsButtonClicked, + ) + + /** The model for the power button. */ + val power: FooterActionsButtonViewModel? = + if (showPowerButton) { + FooterActionsButtonViewModel( + Icon.Resource(android.R.drawable.ic_lock_power_off), + iconTint = + Utils.getColorAttrDefaultColor( + context, + com.android.internal.R.attr.textColorOnAccent, + ), + R.drawable.qs_footer_action_circle_color, + ContentDescription.Resource(R.string.accessibility_quick_settings_power_menu), + this::onPowerButtonClicked, + ) + } else { + null + } + + /** Called when the visibility of the UI rendering this model should be changed. */ + fun onVisibilityChangeRequested(visible: Boolean) { + _isVisible.value = visible + } + + /** Called when the expansion of the Quick Settings changed. */ + fun onQuickSettingsExpansionChanged(expansion: Float, isInSplitShade: Boolean) { + if (isInSplitShade) { + // In split shade, we want to fade in the background only at the very end (see + // b/240563302). + val delay = 0.99f + _alpha.value = expansion + _backgroundAlpha.value = max(0f, expansion - delay) / (1f - delay) + } else { + // Only start fading in the footer actions when we are at least 90% expanded. + val delay = 0.9f + _alpha.value = max(0f, expansion - delay) / (1 - delay) + _backgroundAlpha.value = 1f + } + } + + /** + * Observe the device monitoring dialog requests and show the dialog accordingly. This function + * will suspend indefinitely and will need to be cancelled to stop observing. + * + * Important: [quickSettingsContext] must be the [Context] associated to the [Quick Settings + * fragment][com.android.systemui.qs.QSFragment], and the call to this function must be + * cancelled when that fragment is destroyed. + */ + suspend fun observeDeviceMonitoringDialogRequests(quickSettingsContext: Context) { + footerActionsInteractor.deviceMonitoringDialogRequests.collect { + footerActionsInteractor.showDeviceMonitoringDialog(quickSettingsContext) + } + } + + private fun onSecurityButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showDeviceMonitoringDialog(view) + } + + private fun onForegroundServiceButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showForegroundServicesDialog(view) + } + + private fun onUserSwitcherClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showUserSwitcher(view) + } + + // TODO(b/230830644): Replace View by an Expandable interface that can expand in either dialog + // or activity. + private fun onSettingsButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showSettings(Expandable.fromView(view)) + } + + private fun onPowerButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showPowerMenuDialog(globalActionsDialogLite, view) + } + + private fun userSwitcherButton( + status: UserSwitcherStatusModel.Enabled + ): FooterActionsButtonViewModel { + val icon = status.currentUserImage!! + val iconTint = + if (status.isGuestUser && icon !is UserIconDrawable) { + Utils.getColorAttrDefaultColor(context, android.R.attr.colorForeground) + } else { + null + } + + return FooterActionsButtonViewModel( + Icon.Loaded(icon), + iconTint, + R.drawable.qs_footer_action_circle, + ContentDescription.Loaded(userSwitcherContentDescription(status.currentUserName)), + this::onUserSwitcherClicked, + ) + } + + private fun userSwitcherContentDescription(currentUser: String?): String? { + return currentUser?.let { user -> + context.getString(R.string.accessibility_quick_settings_user, user) + } + } + + @SysUISingleton + class Factory + @Inject + constructor( + @Application private val context: Context, + private val falsingManager: FalsingManager, + private val footerActionsInteractor: FooterActionsInteractor, + private val globalActionsDialogLiteProvider: Provider, + @Named(PM_LITE_ENABLED) private val showPowerButton: Boolean, + ) { + /** Create a [FooterActionsViewModel] bound to the lifecycle of [lifecycleOwner]. */ + fun create(lifecycleOwner: LifecycleOwner): FooterActionsViewModel { + val globalActionsDialogLite = globalActionsDialogLiteProvider.get() + if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) { + // This should usually not happen, but let's make sure we already destroy + // globalActionsDialogLite. + globalActionsDialogLite.destroy() + } else { + // Destroy globalActionsDialogLite when the lifecycle is destroyed. + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + globalActionsDialogLite.destroy() + } + } + ) + } + + return FooterActionsViewModel( + context, + footerActionsInteractor, + falsingManager, + globalActionsDialogLite, + showPowerButton, + ) + } + } + + companion object { + private const val TAG = "FooterActionsViewModel" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt new file mode 100644 index 0000000000000..8f4402eaa406d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt @@ -0,0 +1,58 @@ +/* + * 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.security.data.repository + +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.security.data.model.SecurityModel +import com.android.systemui.statusbar.policy.SecurityController +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +interface SecurityRepository { + /** The current [SecurityModel]. */ + val security: Flow +} + +@SysUISingleton +class SecurityRepositoryImpl +@Inject +constructor( + private val securityController: SecurityController, + @Background private val bgDispatcher: CoroutineDispatcher, +) : SecurityRepository { + override val security: Flow = conflatedCallbackFlow { + suspend fun updateState() { + trySendWithFailureLogging(SecurityModel.create(securityController, bgDispatcher), TAG) + } + + val callback = SecurityController.SecurityControllerCallback { launch { updateState() } } + + securityController.addCallback(callback) + updateState() + awaitClose { securityController.removeCallback(callback) } + } + + companion object { + private const val TAG = "SecurityRepositoryImpl" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt new file mode 100644 index 0000000000000..39a57cafaeccc --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.security.data.repository + +import dagger.Binds +import dagger.Module + +/** Dagger module to provide/bind security repositories. */ +@Module +interface SecurityRepositoryModule { + @Binds fun securityRepository(impl: SecurityRepositoryImpl): SecurityRepository +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt index 0f112415df0d4..e2790e47fe06e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt @@ -10,12 +10,10 @@ import android.graphics.Rect import android.os.Looper import android.testing.AndroidTestingRunner import android.testing.TestableLooper.RunWithLooper -import android.util.Log import android.view.IRemoteAnimationFinishedCallback import android.view.RemoteAnimationAdapter import android.view.RemoteAnimationTarget import android.view.SurfaceControl -import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.test.filters.SmallTest @@ -51,7 +49,6 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() { @Mock lateinit var listener: ActivityLaunchAnimator.Listener @Spy private val controller = TestLaunchAnimatorController(launchContainer) @Mock lateinit var iCallback: IRemoteAnimationFinishedCallback - @Mock lateinit var failHandler: Log.TerribleFailureHandler private lateinit var activityLaunchAnimator: ActivityLaunchAnimator @get:Rule val rule = MockitoJUnit.rule() @@ -187,13 +184,6 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() { verify(controller).onLaunchAnimationStart(anyBoolean()) } - @Test - fun controllerFromOrphanViewReturnsNullAndIsATerribleFailure() { - Log.setWtfHandler(failHandler) - assertNull(ActivityLaunchAnimator.Controller.fromView(View(mContext))) - verify(failHandler).onTerribleFailure(any(), any(), anyBoolean()) - } - private fun fakeWindow(): RemoteAnimationTarget { val bounds = Rect(10 /* left */, 20 /* top */, 30 /* right */, 40 /* bottom */) val taskInfo = ActivityManager.RunningTaskInfo() diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt new file mode 100644 index 0000000000000..3c258077c29d6 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt @@ -0,0 +1,212 @@ +/* + * 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.qs.footer.domain.interactor + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.UserHandle +import android.provider.Settings +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.view.View +import androidx.test.filters.SmallTest +import com.android.internal.logging.nano.MetricsProto +import com.android.internal.logging.testing.FakeMetricsLogger +import com.android.internal.logging.testing.UiEventLoggerFake +import com.android.systemui.SysuiTestCase +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.FooterActionsTestUtils +import com.android.systemui.qs.user.UserSwitchDialogController +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.truth.correspondence.FakeUiEvent +import com.android.systemui.truth.correspondence.LogMaker +import com.android.systemui.user.UserSwitcherActivity +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.argumentCaptor +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.nullable +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +class FooterActionsInteractorTest : SysuiTestCase() { + private lateinit var utils: FooterActionsTestUtils + + @Before + fun setUp() { + utils = FooterActionsTestUtils(context, TestableLooper.get(this)) + } + + @Test + fun showDeviceMonitoringDialog() { + val qsSecurityFooterUtils = mock() + val underTest = utils.footerActionsInteractor(qsSecurityFooterUtils = qsSecurityFooterUtils) + + val quickSettingsContext = mock() + underTest.showDeviceMonitoringDialog(quickSettingsContext) + verify(qsSecurityFooterUtils).showDeviceMonitoringDialog(quickSettingsContext, null) + + val view = mock() + whenever(view.context).thenReturn(quickSettingsContext) + underTest.showDeviceMonitoringDialog(view) + verify(qsSecurityFooterUtils).showDeviceMonitoringDialog(quickSettingsContext, null) + } + + @Test + fun showPowerMenuDialog() { + val uiEventLogger = UiEventLoggerFake() + val underTest = utils.footerActionsInteractor(uiEventLogger = uiEventLogger) + + val globalActionsDialogLite = mock() + val view = mock() + underTest.showPowerMenuDialog(globalActionsDialogLite, view) + + // Event is logged. + val logs = uiEventLogger.logs + assertThat(logs) + .comparingElementsUsing(FakeUiEvent.EVENT_ID) + .containsExactly(GlobalActionsDialogLite.GlobalActionsEvent.GA_OPEN_QS.id) + + // Dialog is shown. + verify(globalActionsDialogLite) + .showOrHideDialog( + /* keyguardShowing= */ false, + /* isDeviceProvisioned= */ true, + view, + ) + } + + @Test + fun showSettings_userSetUp() { + val activityStarter = mock() + val deviceProvisionedController = mock() + val metricsLogger = FakeMetricsLogger() + + // User is set up. + whenever(deviceProvisionedController.isCurrentUserSetup).thenReturn(true) + + val underTest = + utils.footerActionsInteractor( + activityStarter = activityStarter, + deviceProvisionedController = deviceProvisionedController, + metricsLogger = metricsLogger, + ) + + underTest.showSettings(mock()) + + // Event is logged. + assertThat(metricsLogger.logs.toList()) + .comparingElementsUsing(LogMaker.CATEGORY) + .containsExactly(MetricsProto.MetricsEvent.ACTION_QS_EXPANDED_SETTINGS_LAUNCH) + + // Activity is started. + val intentCaptor = argumentCaptor() + verify(activityStarter) + .startActivity( + intentCaptor.capture(), + /* dismissShade= */ eq(true), + nullable() as? ActivityLaunchAnimator.Controller, + ) + assertThat(intentCaptor.value.action).isEqualTo(Settings.ACTION_SETTINGS) + } + + @Test + fun showSettings_userNotSetUp() { + val activityStarter = mock() + val deviceProvisionedController = mock() + + // User is not set up. + whenever(deviceProvisionedController.isCurrentUserSetup).thenReturn(false) + + val underTest = + utils.footerActionsInteractor( + activityStarter = activityStarter, + deviceProvisionedController = deviceProvisionedController, + ) + + underTest.showSettings(mock()) + + // We only unlock the device. + verify(activityStarter).postQSRunnableDismissingKeyguard(any()) + } + + @Test + fun showUserSwitcher_fullScreenDisabled() { + val featureFlags = FakeFeatureFlags().apply { set(Flags.FULL_SCREEN_USER_SWITCHER, false) } + val userSwitchDialogController = mock() + val underTest = + utils.footerActionsInteractor( + featureFlags = featureFlags, + userSwitchDialogController = userSwitchDialogController, + ) + + val view = mock() + underTest.showUserSwitcher(view) + + // Dialog is shown. + verify(userSwitchDialogController).showDialog(view) + } + + @Test + fun showUserSwitcher_fullScreenEnabled() { + val featureFlags = FakeFeatureFlags().apply { set(Flags.FULL_SCREEN_USER_SWITCHER, true) } + val activityStarter = mock() + val underTest = + utils.footerActionsInteractor( + featureFlags = featureFlags, + activityStarter = activityStarter, + ) + + // The clicked view. The context is necessary because it's used to build the intent, that + // we check below. + val view = mock() + whenever(view.context).thenReturn(context) + + underTest.showUserSwitcher(view) + + // Dialog is shown. + val intentCaptor = argumentCaptor() + verify(activityStarter) + .startActivity( + intentCaptor.capture(), + /* dismissShade= */ eq(true), + /* ActivityLaunchAnimator.Controller= */ nullable(), + /* showOverLockscreenWhenLocked= */ eq(true), + eq(UserHandle.SYSTEM), + ) + assertThat(intentCaptor.value.component) + .isEqualTo( + ComponentName( + context, + UserSwitcherActivity::class.java, + ) + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt new file mode 100644 index 0000000000000..e4751d1350351 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt @@ -0,0 +1,408 @@ +/* + * 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.qs.footer.ui.viewmodel + +import android.graphics.drawable.Drawable +import android.os.UserManager +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.testing.TestableLooper.RunWithLooper +import androidx.test.filters.SmallTest +import com.android.settingslib.Utils +import com.android.settingslib.drawable.UserIconDrawable +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.qs.FakeFgsManagerController +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.FooterActionsTestUtils +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig +import com.android.systemui.security.data.model.SecurityModel +import com.android.systemui.settings.FakeUserTracker +import com.android.systemui.statusbar.policy.FakeSecurityController +import com.android.systemui.statusbar.policy.FakeUserInfoController +import com.android.systemui.statusbar.policy.FakeUserInfoController.FakeInfo +import com.android.systemui.statusbar.policy.MockUserSwitcherControllerWrapper +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.nullable +import com.android.systemui.util.settings.FakeSettings +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.`when` as whenever + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@RunWithLooper +class FooterActionsViewModelTest : SysuiTestCase() { + private lateinit var utils: FooterActionsTestUtils + + @Before + fun setUp() { + utils = FooterActionsTestUtils(context, TestableLooper.get(this)) + } + + @Test + fun settingsButton() = runBlockingTest { + val underTest = utils.footerActionsViewModel(showPowerButton = false) + val settings = underTest.settings + + assertThat(settings.contentDescription) + .isEqualTo(ContentDescription.Resource(R.string.accessibility_quick_settings_settings)) + assertThat(settings.icon).isEqualTo(Icon.Resource(R.drawable.ic_settings)) + assertThat(settings.background).isEqualTo(R.drawable.qs_footer_action_circle) + assertThat(settings.iconTint).isNull() + } + + @Test + fun powerButton() = runBlockingTest { + // Without power button. + val underTestWithoutPower = utils.footerActionsViewModel(showPowerButton = false) + assertThat(underTestWithoutPower.power).isNull() + + // With power button. + val underTestWithPower = utils.footerActionsViewModel(showPowerButton = true) + val power = underTestWithPower.power + assertThat(power).isNotNull() + assertThat(power!!.contentDescription) + .isEqualTo( + ContentDescription.Resource(R.string.accessibility_quick_settings_power_menu) + ) + assertThat(power.icon).isEqualTo(Icon.Resource(android.R.drawable.ic_lock_power_off)) + assertThat(power.background).isEqualTo(R.drawable.qs_footer_action_circle_color) + assertThat(power.iconTint) + .isEqualTo( + Utils.getColorAttrDefaultColor( + context, + com.android.internal.R.attr.textColorOnAccent, + ), + ) + } + + @Test + fun userSwitcher() = runBlockingTest { + val picture: Drawable = mock() + val userInfoController = FakeUserInfoController(FakeInfo(picture = picture)) + val settings = FakeSettings() + val userId = 42 + val userTracker = FakeUserTracker(userId) + val userSwitcherControllerWrapper = + MockUserSwitcherControllerWrapper(currentUserName = "foo") + + // Mock UserManager. + val userManager = mock() + var isUserSwitcherEnabled = false + var isGuestUser = false + whenever(userManager.isUserSwitcherEnabled(any())).thenAnswer { isUserSwitcherEnabled } + whenever(userManager.isGuestUser(any())).thenAnswer { isGuestUser } + + val underTest = + utils.footerActionsViewModel( + showPowerButton = false, + footerActionsInteractor = + utils.footerActionsInteractor( + userSwitcherRepository = + utils.userSwitcherRepository( + userTracker = userTracker, + settings = settings, + userManager = userManager, + userInfoController = userInfoController, + userSwitcherController = userSwitcherControllerWrapper.controller, + ), + ) + ) + + // Collect the user switcher into currentUserSwitcher. + var currentUserSwitcher: FooterActionsButtonViewModel? = null + val job = launch { underTest.userSwitcher.collect { currentUserSwitcher = it } } + fun currentUserSwitcher(): FooterActionsButtonViewModel? { + // Make sure we finish collecting the current user switcher. This is necessary because + // combined flows launch multiple coroutines in the current scope so we need to make + // sure we process all coroutines triggered by our flow collection before we make + // assertions on the current buttons. + advanceUntilIdle() + return currentUserSwitcher + } + + // The user switcher is disabled. + assertThat(currentUserSwitcher()).isNull() + + // Make the user manager return that the User Switcher is enabled. A change of the setting + // for the current user will be fired to notify us of that change. + isUserSwitcherEnabled = true + + // Update the setting for a random user: nothing should change, given that at this point we + // weren't notified of the change yet. + utils.setUserSwitcherEnabled(settings, true, 3) + assertThat(currentUserSwitcher()).isNull() + + // Update the setting for the observed user: now we will be notified and the button should + // be there. + utils.setUserSwitcherEnabled(settings, true, userId) + val userSwitcher = currentUserSwitcher() + assertThat(userSwitcher).isNotNull() + assertThat(userSwitcher!!.contentDescription) + .isEqualTo(ContentDescription.Loaded("Signed in as foo")) + assertThat(userSwitcher.icon).isEqualTo(Icon.Loaded(picture)) + assertThat(userSwitcher.background).isEqualTo(R.drawable.qs_footer_action_circle) + + // Change the current user name. + userSwitcherControllerWrapper.currentUserName = "bar" + assertThat(currentUserSwitcher()?.contentDescription) + .isEqualTo(ContentDescription.Loaded("Signed in as bar")) + + fun iconTint(): Int? = currentUserSwitcher()!!.iconTint + + // We tint the icon if the current user is not the guest. + assertThat(iconTint()).isNull() + + // Make the UserManager return that the current user is the guest. A change of the user + // info will be fired to notify us of that change. + isGuestUser = true + + // At this point, there was no change of the user info yet so we still didn't pick the + // UserManager change. + assertThat(iconTint()).isNull() + + // Trigger a user info change: there should now be a tint. + userInfoController.updateInfo { userAccount = "doe" } + assertThat(iconTint()) + .isEqualTo( + Utils.getColorAttrDefaultColor( + context, + android.R.attr.colorForeground, + ) + ) + + // Make sure we don't tint the icon if it is a user image (and not the default image), even + // in guest mode. + userInfoController.updateInfo { this.picture = mock() } + assertThat(iconTint()).isNull() + + job.cancel() + } + + @Test + fun security() = runBlockingTest { + val securityController = FakeSecurityController() + val qsSecurityFooterUtils = mock() + + // Mock QSSecurityFooter to map a SecurityModel into a SecurityButtonConfig using the + // logic in securityToConfig. + var securityToConfig: (SecurityModel) -> SecurityButtonConfig? = { null } + whenever(qsSecurityFooterUtils.getButtonConfig(any())).thenAnswer { + securityToConfig(it.arguments.first() as SecurityModel) + } + + val underTest = + utils.footerActionsViewModel( + footerActionsInteractor = + utils.footerActionsInteractor( + qsSecurityFooterUtils = qsSecurityFooterUtils, + securityRepository = + utils.securityRepository( + securityController = securityController, + ), + ), + ) + + // Collect the security model into currentSecurity. + var currentSecurity: FooterActionsSecurityButtonViewModel? = null + val job = launch { underTest.security.collect { currentSecurity = it } } + fun currentSecurity(): FooterActionsSecurityButtonViewModel? { + advanceUntilIdle() + return currentSecurity + } + + // By default, we always return a null SecurityButtonConfig. + assertThat(currentSecurity()).isNull() + + // Map any SecurityModel into a non-null SecurityButtonConfig. + val buttonConfig = + SecurityButtonConfig( + icon = Icon.Resource(0), + text = "foo", + isClickable = true, + ) + securityToConfig = { buttonConfig } + + // There was no change of the security info yet, so the mapper was not called yet. + assertThat(currentSecurity()).isNull() + + // Trigger a SecurityModel change, which will call the mapper and add a button. + securityController.updateState {} + var security = currentSecurity() + assertThat(security).isNotNull() + assertThat(security!!.icon).isEqualTo(buttonConfig.icon) + assertThat(security.text).isEqualTo(buttonConfig.text) + assertThat(security.onClick).isNotNull() + + // If the config.clickable = false, then onClick should be null. + securityToConfig = { buttonConfig.copy(isClickable = false) } + securityController.updateState {} + security = currentSecurity() + assertThat(security).isNotNull() + assertThat(security!!.onClick).isNull() + + job.cancel() + } + + @Test + fun foregroundServices() = runBlockingTest { + val securityController = FakeSecurityController() + val fgsManagerController = + FakeFgsManagerController( + isAvailable = true, + showFooterDot = false, + numRunningPackages = 0, + ) + val qsSecurityFooterUtils = mock() + + // Mock QSSecurityFooter to map a SecurityModel into a SecurityButtonConfig using the + // logic in securityToConfig. + var securityToConfig: (SecurityModel) -> SecurityButtonConfig? = { null } + whenever(qsSecurityFooterUtils.getButtonConfig(any())).thenAnswer { + securityToConfig(it.arguments.first() as SecurityModel) + } + + val underTest = + utils.footerActionsViewModel( + footerActionsInteractor = + utils.footerActionsInteractor( + qsSecurityFooterUtils = qsSecurityFooterUtils, + securityRepository = utils.securityRepository(securityController), + foregroundServicesRepository = + utils.foregroundServicesRepository(fgsManagerController), + ), + ) + + // Collect the security model into currentSecurity. + var currentForegroundServices: FooterActionsForegroundServicesButtonViewModel? = null + val job = launch { underTest.foregroundServices.collect { currentForegroundServices = it } } + fun currentForegroundServices(): FooterActionsForegroundServicesButtonViewModel? { + advanceUntilIdle() + return currentForegroundServices + } + + // We don't show the foreground services button if the number of running packages is not + // > 1. + assertThat(currentForegroundServices()).isNull() + + // We show it at soon as the number of services is at least 1. Given that there is no + // security, it should be displayed with text. + fgsManagerController.numRunningPackages = 1 + val foregroundServices = currentForegroundServices() + assertThat(foregroundServices).isNotNull() + assertThat(foregroundServices!!.foregroundServicesCount).isEqualTo(1) + assertThat(foregroundServices.text).isEqualTo("1 app is active") + assertThat(foregroundServices.displayText).isTrue() + assertThat(foregroundServices.onClick).isNotNull() + + // We handle plurals correctly. + fgsManagerController.numRunningPackages = 3 + assertThat(currentForegroundServices()?.text).isEqualTo("3 apps are active") + + // Showing new changes (the footer dot) is currently disabled. + assertThat(foregroundServices.hasNewChanges).isFalse() + + // Enabling it will show the new changes. + fgsManagerController.showFooterDot.value = true + assertThat(currentForegroundServices()?.hasNewChanges).isTrue() + + // Dismissing the dialog should remove the new changes dot. + fgsManagerController.simulateDialogDismiss() + assertThat(currentForegroundServices()?.hasNewChanges).isFalse() + + // Showing the security button will make this show as a simple button without text. + assertThat(foregroundServices.displayText).isTrue() + securityToConfig = { + SecurityButtonConfig( + icon = Icon.Resource(0), + text = "foo", + isClickable = true, + ) + } + securityController.updateState {} + assertThat(currentForegroundServices()?.displayText).isFalse() + + job.cancel() + } + + @Test + fun observeDeviceMonitoringDialogRequests() = runBlockingTest { + val qsSecurityFooterUtils = mock() + val broadcastDispatcher = mock() + + // Return a fake broadcastFlow that emits 3 fake events when collected. + val broadcastFlow = flowOf(Unit, Unit, Unit) + whenever( + broadcastDispatcher.broadcastFlow( + any(), + nullable(), + anyInt(), + nullable(), + ) + ) + .thenAnswer { broadcastFlow } + + // Increment nDialogRequests whenever a request to show the dialog is made by the + // FooterActionsInteractor. + var nDialogRequests = 0 + whenever(qsSecurityFooterUtils.showDeviceMonitoringDialog(any(), nullable())).then { + nDialogRequests++ + } + + val underTest = + utils.footerActionsViewModel( + footerActionsInteractor = + utils.footerActionsInteractor( + qsSecurityFooterUtils = qsSecurityFooterUtils, + broadcastDispatcher = broadcastDispatcher, + ), + ) + + val job = launch { + underTest.observeDeviceMonitoringDialogRequests(quickSettingsContext = mock()) + } + + advanceUntilIdle() + assertThat(nDialogRequests).isEqualTo(3) + + job.cancel() + } + + @Test + fun isVisible() { + val underTest = utils.footerActionsViewModel() + assertThat(underTest.isVisible.value).isTrue() + + underTest.onVisibilityChangeRequested(visible = false) + assertThat(underTest.isVisible.value).isFalse() + + underTest.onVisibilityChangeRequested(visible = true) + assertThat(underTest.isVisible.value).isTrue() + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt new file mode 100644 index 0000000000000..2a9aeddc9aa8b --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt @@ -0,0 +1,172 @@ +/* + * 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.qs.footer + +import android.content.Context +import android.os.Handler +import android.os.UserManager +import android.provider.Settings +import android.testing.TestableLooper +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.testing.FakeMetricsLogger +import com.android.internal.logging.testing.UiEventLoggerFake +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.classifier.FalsingManagerFake +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.plugins.FalsingManager +import com.android.systemui.qs.FakeFgsManagerController +import com.android.systemui.qs.FgsManagerController +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepositoryImpl +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepositoryImpl +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractorImpl +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel +import com.android.systemui.qs.user.UserSwitchDialogController +import com.android.systemui.security.data.repository.SecurityRepository +import com.android.systemui.security.data.repository.SecurityRepositoryImpl +import com.android.systemui.settings.FakeUserTracker +import com.android.systemui.settings.UserTracker +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.statusbar.policy.FakeSecurityController +import com.android.systemui.statusbar.policy.FakeUserInfoController +import com.android.systemui.statusbar.policy.SecurityController +import com.android.systemui.statusbar.policy.UserInfoController +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.settings.FakeSettings +import com.android.systemui.util.settings.GlobalSettings +import com.android.systemui.util.time.FakeSystemClock +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineDispatcher + +/** + * Util class to create real implementations of the FooterActions repositories, viewModel and + * interactor to be used in tests. + */ +class FooterActionsTestUtils( + private val context: Context, + private val testableLooper: TestableLooper, + private val fakeClock: FakeSystemClock = FakeSystemClock(), +) { + /** Enable or disable the user switcher in the settings. */ + fun setUserSwitcherEnabled(settings: GlobalSettings, enabled: Boolean, userId: Int) { + settings.putBoolForUser(Settings.Global.USER_SWITCHER_ENABLED, enabled, userId) + + // The settings listener is processing messages on the bgHandler (usually backed by a + // testableLooper in tests), so let's make sure we process the callback before continuing. + testableLooper.processAllMessages() + } + + /** Create a [FooterActionsViewModel] to be used in tests. */ + fun footerActionsViewModel( + @Application context: Context = this.context.applicationContext, + footerActionsInteractor: FooterActionsInteractor = footerActionsInteractor(), + falsingManager: FalsingManager = FalsingManagerFake(), + globalActionsDialogLite: GlobalActionsDialogLite = mock(), + showPowerButton: Boolean = true, + ): FooterActionsViewModel { + return FooterActionsViewModel( + context, + footerActionsInteractor, + falsingManager, + globalActionsDialogLite, + showPowerButton, + ) + } + + /** Create a [FooterActionsInteractor] to be used in tests. */ + fun footerActionsInteractor( + activityStarter: ActivityStarter = mock(), + featureFlags: FeatureFlags = FakeFeatureFlags(), + metricsLogger: MetricsLogger = FakeMetricsLogger(), + uiEventLogger: UiEventLogger = UiEventLoggerFake(), + deviceProvisionedController: DeviceProvisionedController = mock(), + qsSecurityFooterUtils: QSSecurityFooterUtils = mock(), + fgsManagerController: FgsManagerController = mock(), + userSwitchDialogController: UserSwitchDialogController = mock(), + securityRepository: SecurityRepository = securityRepository(), + foregroundServicesRepository: ForegroundServicesRepository = foregroundServicesRepository(), + userSwitcherRepository: UserSwitcherRepository = userSwitcherRepository(), + broadcastDispatcher: BroadcastDispatcher = mock(), + bgDispatcher: CoroutineDispatcher = TestCoroutineDispatcher(), + ): FooterActionsInteractor { + return FooterActionsInteractorImpl( + activityStarter, + featureFlags, + metricsLogger, + uiEventLogger, + deviceProvisionedController, + qsSecurityFooterUtils, + fgsManagerController, + userSwitchDialogController, + securityRepository, + foregroundServicesRepository, + userSwitcherRepository, + broadcastDispatcher, + bgDispatcher, + ) + } + + /** Create a [SecurityRepository] to be used in tests. */ + fun securityRepository( + securityController: SecurityController = FakeSecurityController(), + bgDispatcher: CoroutineDispatcher = TestCoroutineDispatcher(), + ): SecurityRepository { + return SecurityRepositoryImpl( + securityController, + bgDispatcher, + ) + } + + /** Create a [SecurityRepository] to be used in tests. */ + fun foregroundServicesRepository( + fgsManagerController: FakeFgsManagerController = FakeFgsManagerController(), + ): ForegroundServicesRepository { + return ForegroundServicesRepositoryImpl(fgsManagerController) + } + + /** Create a [UserSwitcherRepository] to be used in tests. */ + fun userSwitcherRepository( + @Application context: Context = this.context.applicationContext, + bgHandler: Handler = Handler(testableLooper.looper), + bgDispatcher: CoroutineDispatcher = TestCoroutineDispatcher(), + userManager: UserManager = mock(), + userTracker: UserTracker = FakeUserTracker(), + userSwitcherController: UserSwitcherController = mock(), + userInfoController: UserInfoController = FakeUserInfoController(), + settings: GlobalSettings = FakeSettings(), + ): UserSwitcherRepository { + return UserSwitcherRepositoryImpl( + context, + bgHandler, + bgDispatcher, + userManager, + userTracker, + userSwitcherController, + userInfoController, + settings, + ) + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt new file mode 100644 index 0000000000000..48cd345b1f68f --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt @@ -0,0 +1,30 @@ +/* + * 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.truth.correspondence + +import com.android.internal.logging.testing.UiEventLoggerFake +import com.android.internal.logging.testing.UiEventLoggerFake.FakeUiEvent +import com.google.common.truth.Correspondence + +/** Instances of [Correspondence] to match a [UiEventLoggerFake.FakeUiEvent] with Truth. */ +object FakeUiEvent { + val EVENT_ID = + Correspondence.transforming( + { it?.eventId }, + "has a eventId of", + ) +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt new file mode 100644 index 0000000000000..3f0a95248d9ce --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt @@ -0,0 +1,29 @@ +/* + * 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.truth.correspondence + +import android.metrics.LogMaker +import com.google.common.truth.Correspondence + +/** Instances of [Correspondence] to match a [LogMaker] with Truth. */ +object LogMaker { + val CATEGORY = + Correspondence.transforming( + { it?.category }, + "has a category of", + ) +}