diff --git a/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java b/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java index 7c08a7bbc8262..6c689ff2b7252 100644 --- a/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java +++ b/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java @@ -548,6 +548,12 @@ public final class SystemUiDeviceConfigFlags { */ public static final String TASK_MANAGER_SHOW_FOOTER_DOT = "task_manager_show_footer_dot"; + /** + * (boolean) Whether the task manager should show a stop button if the app is allowlisted + * by the user. + */ + public static final String TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS = + "show_stop_button_for_user_allowlisted_apps"; /** * (boolean) Whether the clipboard overlay is enabled. diff --git a/ktfmt_includes.txt b/ktfmt_includes.txt index 96da8c9c803b6..c7062e0931359 100644 --- a/ktfmt_includes.txt +++ b/ktfmt_includes.txt @@ -6,4 +6,12 @@ packages/SystemUI/src/com/android/systemui/keyguard/data packages/SystemUI/src/com/android/systemui/keyguard/dagger packages/SystemUI/src/com/android/systemui/keyguard/domain packages/SystemUI/src/com/android/systemui/keyguard/shared -packages/SystemUI/src/com/android/systemui/keyguard/ui \ No newline at end of file +packages/SystemUI/src/com/android/systemui/keyguard/ui +packages/SystemUI/src/com/android/systemui/qs/footer +packages/SystemUI/src/com/android/systemui/security +packages/SystemUI/src/com/android/systemui/common/ +packages/SystemUI/tests/utils/src/com/android/systemui/qs/ +packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeSecurityController.kt +packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeUserInfoController.kt +packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/MockUserSwitcherControllerWrapper.kt +packages/SystemUI/tests/src/com/android/systemui/qs/footer/ \ No newline at end of file diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index cd3a72271603e..ff4c748f4946a 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/DialogLaunchAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt index 8f9ced6956ca7..eac5d275092a5 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt @@ -113,6 +113,19 @@ constructor( } val animateFrom = animatedParent?.dialogContentWithBackground ?: view + if (animatedParent == null && animateFrom !is LaunchableView) { + // Make sure the View we launch from implements LaunchableView to avoid visibility + // issues. Given that we don't own dialog decorViews so we can't enforce it for launches + // from a dialog. + // TODO(b/243636422): Throw instead of logging to enforce this. + Log.w( + TAG, + "A dialog was launched from a View that does not implement LaunchableView. This " + + "can lead to subtle bugs where the visibility of the View we are " + + "launching from is not what we expected." + ) + } + // Make sure we don't run the launch animation from the same view twice at the same time. if (animateFrom.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) { Log.e(TAG, "Not running dialog launch animation as there is already one running") @@ -156,9 +169,14 @@ constructor( openedDialogs.firstOrNull { it.dialog == animateFrom }?.dialogContentWithBackground ?: throw IllegalStateException( "The animateFrom dialog was not animated using " + - "DialogLaunchAnimator.showFrom(View|Dialog)") + "DialogLaunchAnimator.showFrom(View|Dialog)" + ) showFromView( - dialog, view, animateBackgroundBoundsChange = animateBackgroundBoundsChange, cuj = cuj) + dialog, + view, + animateBackgroundBoundsChange = animateBackgroundBoundsChange, + cuj = cuj + ) } /** @@ -197,7 +215,7 @@ constructor( // bouncer. if ( !dialog.isShowing || - (!callback.isUnlocked() && !callback.isShowingAlternateAuthOnUnlock()) + (!callback.isUnlocked() && !callback.isShowingAlternateAuthOnUnlock()) ) { return null } @@ -556,11 +574,12 @@ private class AnimatedDialog( window.setDecorFitsSystemWindows(false) val viewWithInsets = (dialogContentWithBackground.parent as ViewGroup) viewWithInsets.setOnApplyWindowInsetsListener { view, windowInsets -> - val type = if (wasFittingNavigationBars) { - WindowInsets.Type.displayCutout() or WindowInsets.Type.navigationBars() - } else { - WindowInsets.Type.displayCutout() - } + val type = + if (wasFittingNavigationBars) { + WindowInsets.Type.displayCutout() or WindowInsets.Type.navigationBars() + } else { + WindowInsets.Type.displayCutout() + } val insets = windowInsets.getInsets(type) view.setPadding(insets.left, insets.top, insets.right, insets.bottom) 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/animation/src/com/android/systemui/animation/LaunchableView.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt index 7499302c06b22..67b59e0e9928e 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt @@ -16,15 +16,79 @@ package com.android.systemui.animation +import android.view.View + /** A view that can expand/launch into an app or a dialog. */ interface LaunchableView { /** - * Set whether this view should block/prevent all visibility changes. This ensures that this - * view remains invisible during the launch animation given that it is ghosted and already drawn + * Set whether this view should block/postpone all visibility changes. This ensures that this + * view: + * - remains invisible during the launch animation given that it is ghosted and already drawn * somewhere else. + * - remains invisible as long as a dialog expanded from it is shown. + * - restores its expected visibility once the dialog expanded from it is dismissed. * * Note that when this is set to true, both the [normal][android.view.View.setVisibility] and * [transition][android.view.View.setTransitionVisibility] visibility changes must be blocked. + * + * @param block whether we should block/postpone all calls to `setVisibility` and + * `setTransitionVisibility`. */ fun setShouldBlockVisibilityChanges(block: Boolean) } + +/** A delegate that can be used by views to make the implementation of [LaunchableView] easier. */ +class LaunchableViewDelegate( + private val view: View, + + /** + * The lambda that should set the actual visibility of [view], usually by calling + * super.setVisibility(visibility). + */ + private val superSetVisibility: (Int) -> Unit, + + /** + * The lambda that should set the actual transition visibility of [view], usually by calling + * super.setTransitionVisibility(visibility). + */ + private val superSetTransitionVisibility: (Int) -> Unit, +) { + private var blockVisibilityChanges = false + private var lastVisibility = view.visibility + + /** Call this when [LaunchableView.setShouldBlockVisibilityChanges] is called. */ + fun setShouldBlockVisibilityChanges(block: Boolean) { + if (block == blockVisibilityChanges) { + return + } + + blockVisibilityChanges = block + if (block) { + lastVisibility = view.visibility + } else { + superSetVisibility(lastVisibility) + } + } + + /** Call this when [View.setVisibility] is called. */ + fun setVisibility(visibility: Int) { + if (blockVisibilityChanges) { + lastVisibility = visibility + return + } + + superSetVisibility(visibility) + } + + /** Call this when [View.setTransitionVisibility] is called. */ + fun setTransitionVisibility(visibility: Int) { + if (blockVisibilityChanges) { + // View.setTransitionVisibility just sets the visibility flag, so we don't have to save + // the transition visibility separately from the normal visibility. + lastVisibility = visibility + return + } + + superSetTransitionVisibility(visibility) + } +} 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/compose/gallery/src/com/android/systemui/qs/footer/Fakes.kt b/packages/SystemUI/compose/gallery/src/com/android/systemui/qs/footer/Fakes.kt new file mode 100644 index 0000000000000..11477f9d833bb --- /dev/null +++ b/packages/SystemUI/compose/gallery/src/com/android/systemui/qs/footer/Fakes.kt @@ -0,0 +1,160 @@ +/* + * 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.UserHandle +import android.view.View +import com.android.internal.util.UserIcons +import com.android.systemui.R +import com.android.systemui.animation.Expandable +import com.android.systemui.classifier.FalsingManagerFake +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel +import com.android.systemui.util.mockito.mock +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** A list of fake [FooterActionsViewModel] to be used in screenshot tests and the gallery. */ +fun fakeFooterActionsViewModels( + @Application context: Context, +): List { + return listOf( + fakeFooterActionsViewModel(context), + fakeFooterActionsViewModel(context, showPowerButton = false, isGuestUser = true), + fakeFooterActionsViewModel(context, showUserSwitcher = false), + fakeFooterActionsViewModel(context, showUserSwitcher = false, foregroundServices = 4), + fakeFooterActionsViewModel( + context, + foregroundServices = 4, + hasNewForegroundServices = true, + userId = 1, + ), + fakeFooterActionsViewModel( + context, + securityText = "Security", + foregroundServices = 4, + showUserSwitcher = false, + ), + fakeFooterActionsViewModel( + context, + securityText = "Security (not clickable)", + securityClickable = false, + foregroundServices = 4, + hasNewForegroundServices = true, + userId = 2, + ), + ) +} + +private fun fakeFooterActionsViewModel( + @Application context: Context, + securityText: String? = null, + securityClickable: Boolean = true, + foregroundServices: Int = 0, + hasNewForegroundServices: Boolean = false, + showUserSwitcher: Boolean = true, + showPowerButton: Boolean = true, + userId: Int = UserHandle.USER_OWNER, + isGuestUser: Boolean = false, +): FooterActionsViewModel { + val interactor = + FakeFooterActionsInteractor( + securityButtonConfig = + flowOf( + securityText?.let { text -> + SecurityButtonConfig( + icon = Icon.Resource(R.drawable.ic_info_outline), + text = text, + isClickable = securityClickable, + ) + } + ), + foregroundServicesCount = flowOf(foregroundServices), + hasNewForegroundServices = flowOf(hasNewForegroundServices), + userSwitcherStatus = + flowOf( + if (showUserSwitcher) { + UserSwitcherStatusModel.Enabled( + currentUserName = "foo", + currentUserImage = + UserIcons.getDefaultUserIcon( + context.resources, + userId, + /* light= */ false, + ), + isGuestUser = isGuestUser, + ) + } else { + UserSwitcherStatusModel.Disabled + } + ), + deviceMonitoringDialogRequests = flowOf(), + ) + + return FooterActionsViewModel( + context, + interactor, + FalsingManagerFake(), + globalActionsDialogLite = mock(), + showPowerButton = showPowerButton, + ) +} + +private class FakeFooterActionsInteractor( + override val securityButtonConfig: Flow = flowOf(null), + override val foregroundServicesCount: Flow = flowOf(0), + override val hasNewForegroundServices: Flow = flowOf(false), + override val userSwitcherStatus: Flow = + flowOf(UserSwitcherStatusModel.Disabled), + override val deviceMonitoringDialogRequests: Flow = flowOf(), + private val onShowDeviceMonitoringDialogFromView: (View) -> Unit = {}, + private val onShowDeviceMonitoringDialog: (Context) -> Unit = {}, + private val onShowForegroundServicesDialog: (View) -> Unit = {}, + private val onShowPowerMenuDialog: (GlobalActionsDialogLite, View) -> Unit = { _, _ -> }, + private val onShowSettings: (Expandable) -> Unit = {}, + private val onShowUserSwitcher: (View) -> Unit = {}, +) : FooterActionsInteractor { + override fun showDeviceMonitoringDialog(view: View) { + onShowDeviceMonitoringDialogFromView(view) + } + + override fun showDeviceMonitoringDialog(quickSettingsContext: Context) { + onShowDeviceMonitoringDialog(quickSettingsContext) + } + + override fun showForegroundServicesDialog(view: View) { + onShowForegroundServicesDialog(view) + } + + override fun showPowerMenuDialog(globalActionsDialogLite: GlobalActionsDialogLite, view: View) { + onShowPowerMenuDialog(globalActionsDialogLite, view) + } + + override fun showSettings(expandable: Expandable) { + onShowSettings(expandable) + } + + override fun showUserSwitcher(view: View) { + onShowUserSwitcher(view) + } +} diff --git a/packages/SystemUI/res-keyguard/layout/fgs_footer.xml b/packages/SystemUI/res-keyguard/layout/fgs_footer.xml index 6757acf7014ad..ee588f997ab84 100644 --- a/packages/SystemUI/res-keyguard/layout/fgs_footer.xml +++ b/packages/SystemUI/res-keyguard/layout/fgs_footer.xml @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> + + diff --git a/packages/SystemUI/res-keyguard/layout/footer_actions_icon_button.xml b/packages/SystemUI/res-keyguard/layout/footer_actions_icon_button.xml new file mode 100644 index 0000000000000..fad41c822ec07 --- /dev/null +++ b/packages/SystemUI/res-keyguard/layout/footer_actions_icon_button.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml b/packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml new file mode 100644 index 0000000000000..a7ffe9ca256f4 --- /dev/null +++ b/packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml @@ -0,0 +1,39 @@ + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res-keyguard/layout/footer_actions_text_button.xml b/packages/SystemUI/res-keyguard/layout/footer_actions_text_button.xml new file mode 100644 index 0000000000000..fc18132d4dc38 --- /dev/null +++ b/packages/SystemUI/res-keyguard/layout/footer_actions_text_button.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/quick_settings_security_footer.xml b/packages/SystemUI/res/layout/quick_settings_security_footer.xml index 1b11816465ac0..194f3dd5dc269 100644 --- a/packages/SystemUI/res/layout/quick_settings_security_footer.xml +++ b/packages/SystemUI/res/layout/quick_settings_security_footer.xml @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> + 4dp + 8dp 4dp -4dp diff --git a/packages/SystemUI/src/com/android/systemui/broadcast/BroadcastDispatcher.kt b/packages/SystemUI/src/com/android/systemui/broadcast/BroadcastDispatcher.kt index d757b629c8290..eb8cb47c26710 100644 --- a/packages/SystemUI/src/com/android/systemui/broadcast/BroadcastDispatcher.kt +++ b/packages/SystemUI/src/com/android/systemui/broadcast/BroadcastDispatcher.kt @@ -31,6 +31,8 @@ import android.util.SparseArray import com.android.internal.annotations.VisibleForTesting import com.android.systemui.Dumpable import com.android.systemui.broadcast.logging.BroadcastDispatcherLogger +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.dump.DumpManager @@ -38,6 +40,8 @@ import com.android.systemui.settings.UserTracker import java.io.PrintWriter import java.util.concurrent.Executor import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow data class ReceiverData( val receiver: BroadcastReceiver, @@ -153,6 +157,55 @@ open class BroadcastDispatcher @Inject constructor( .sendToTarget() } + /** + * Returns a [Flow] that, when collected, emits a new value whenever a broadcast matching + * [filter] is received. The value will be computed from the intent and the registered receiver + * using [map]. + * + * @see registerReceiver + */ + @JvmOverloads + fun broadcastFlow( + filter: IntentFilter, + user: UserHandle? = null, + @Context.RegisterReceiverFlags flags: Int = Context.RECEIVER_EXPORTED, + permission: String? = null, + map: (Intent, BroadcastReceiver) -> T, + ): Flow = conflatedCallbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySendWithFailureLogging(map(intent, this), TAG) + } + } + + registerReceiver( + receiver, + filter, + bgExecutor, + user, + flags, + permission, + ) + + awaitClose { + unregisterReceiver(receiver) + } + } + + /** + * Returns a [Flow] that, when collected, emits `Unit` whenever a broadcast matching [filter] is + * received. + * + * @see registerReceiver + */ + @JvmOverloads + fun broadcastFlow( + filter: IntentFilter, + user: UserHandle? = null, + @Context.RegisterReceiverFlags flags: Int = Context.RECEIVER_EXPORTED, + permission: String? = null, + ): Flow = broadcastFlow(filter, user, flags, permission) { _, _ -> Unit } + private fun checkFilter(filter: IntentFilter) { val sb = StringBuilder() if (filter.countActions() == 0) sb.append("Filter must contain at least one action. ") 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/shared/model/Icon.kt b/packages/SystemUI/src/com/android/systemui/common/shared/model/Icon.kt new file mode 100644 index 0000000000000..0b65966ca1096 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/shared/model/Icon.kt @@ -0,0 +1,34 @@ +/* + * 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.DrawableRes +import android.graphics.drawable.Drawable + +/** + * Models an icon, that can either be already [loaded][Icon.Loaded] or be a [reference] + * [Icon.Resource] to a resource. + */ +sealed class Icon { + data class Loaded( + val drawable: Drawable, + ) : Icon() + + data class Resource( + @DrawableRes val res: Int, + ) : Icon() +} diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/binder/ContentDescriptionViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/binder/ContentDescriptionViewBinder.kt new file mode 100644 index 0000000000000..d6433aae9845f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/binder/ContentDescriptionViewBinder.kt @@ -0,0 +1,34 @@ +/* + * 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.binder + +import android.view.View +import com.android.systemui.common.shared.model.ContentDescription + +object ContentDescriptionViewBinder { + fun bind( + contentDescription: ContentDescription, + view: View, + ) { + when (contentDescription) { + is ContentDescription.Loaded -> view.contentDescription = contentDescription.description + is ContentDescription.Resource -> { + view.contentDescription = view.context.resources.getString(contentDescription.res) + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/binder/IconViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/binder/IconViewBinder.kt new file mode 100644 index 0000000000000..aecee2afc9d23 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/binder/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.binder + +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/common/ui/view/LaunchableLinearLayout.kt b/packages/SystemUI/src/com/android/systemui/common/ui/view/LaunchableLinearLayout.kt new file mode 100644 index 0000000000000..c27b82aeeb47c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/view/LaunchableLinearLayout.kt @@ -0,0 +1,60 @@ +/* + * 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.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.android.systemui.animation.LaunchableView +import com.android.systemui.animation.LaunchableViewDelegate + +/** A [LinearLayout] that also implements [LaunchableView]. */ +class LaunchableLinearLayout : LinearLayout, LaunchableView { + private val delegate = + LaunchableViewDelegate( + this, + superSetVisibility = { super.setVisibility(it) }, + superSetTransitionVisibility = { super.setTransitionVisibility(it) }, + ) + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + ) : super(context, attrs, defStyleAttr) + + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int, + ) : super(context, attrs, defStyleAttr, defStyleRes) + + override fun setShouldBlockVisibilityChanges(block: Boolean) { + delegate.setShouldBlockVisibilityChanges(block) + } + + override fun setVisibility(visibility: Int) { + delegate.setVisibility(visibility) + } + + override fun setTransitionVisibility(visibility: Int) { + delegate.setTransitionVisibility(visibility) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index e549a96079bd1..1b060e209579e 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -49,8 +49,12 @@ import com.android.systemui.navigationbar.NavigationBarComponent; import com.android.systemui.people.PeopleModule; import com.android.systemui.plugins.BcSmartspaceDataPlugin; import com.android.systemui.privacy.PrivacyModule; +import com.android.systemui.qs.FgsManagerController; +import com.android.systemui.qs.FgsManagerControllerImpl; +import com.android.systemui.qs.footer.dagger.FooterActionsModule; import com.android.systemui.recents.Recents; import com.android.systemui.screenshot.dagger.ScreenshotModule; +import com.android.systemui.security.data.repository.SecurityRepositoryModule; import com.android.systemui.settings.dagger.MultiUserUtilsModule; import com.android.systemui.shade.ShadeController; import com.android.systemui.smartspace.dagger.SmartspaceModule; @@ -122,6 +126,7 @@ import dagger.Provides; DemoModeModule.class, FalsingModule.class, FlagsModule.class, + FooterActionsModule.class, LogModule.class, MediaProjectionModule.class, PeopleHubModule.class, @@ -132,6 +137,7 @@ import dagger.Provides; ScreenshotModule.class, SensorModule.class, MultiUserUtilsModule.class, + SecurityRepositoryModule.class, SettingsUtilModule.class, SmartRepliesInflationModule.class, SmartspaceModule.class, @@ -258,4 +264,7 @@ public abstract class SystemUIModule { return Optional.empty(); } } + + @Binds + abstract FgsManagerController bindFgsManagerController(FgsManagerControllerImpl impl); } diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java index c162c20d5abf1..d8423c3c530c1 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java @@ -151,6 +151,8 @@ public class Flags { public static final ResourceBooleanFlag FULL_SCREEN_USER_SWITCHER = new ResourceBooleanFlag(506, R.bool.config_enableFullscreenUserSwitcher); + public static final UnreleasedFlag NEW_FOOTER_ACTIONS = new UnreleasedFlag(507, true); + /***************************************/ // 600- status bar public static final ResourceBooleanFlag STATUS_BAR_USER_SWITCHER = diff --git a/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt b/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt index 0288c9fce64ae..482a1397642b3 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt @@ -40,11 +40,13 @@ import android.widget.Button import android.widget.ImageView import android.widget.TextView import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.internal.config.sysui.SystemUiDeviceConfigFlags.TASK_MANAGER_ENABLED import com.android.internal.config.sysui.SystemUiDeviceConfigFlags.TASK_MANAGER_SHOW_FOOTER_DOT +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags.TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS import com.android.internal.jank.InteractionJankMonitor import com.android.systemui.Dumpable import com.android.systemui.R @@ -66,9 +68,73 @@ import java.util.Objects import java.util.concurrent.Executor import javax.inject.Inject import kotlin.math.max +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** A controller for the dealing with services running in the foreground. */ +interface FgsManagerController { + /** Whether the TaskManager (and therefore this controller) is actually available. */ + val isAvailable: StateFlow + + /** The number of packages with a service running in the foreground. */ + val numRunningPackages: Int + + /** + * Whether there were new changes to the foreground services since the last [shown][showDialog] + * dialog was dismissed. + */ + val newChangesSinceDialogWasDismissed: Boolean + + /** + * Whether we should show a dot to indicate when [newChangesSinceDialogWasDismissed] is true. + */ + val showFooterDot: StateFlow + + /** + * Initialize this controller. This should be called once, before this controller is used for + * the first time. + */ + fun init() + + /** + * Show the foreground services dialog. The dialog will be expanded from [viewLaunchedFrom] if + * it's not `null`. + */ + fun showDialog(viewLaunchedFrom: View?) + + /** Add a [OnNumberOfPackagesChangedListener]. */ + fun addOnNumberOfPackagesChangedListener(listener: OnNumberOfPackagesChangedListener) + + /** Remove a [OnNumberOfPackagesChangedListener]. */ + fun removeOnNumberOfPackagesChangedListener(listener: OnNumberOfPackagesChangedListener) + + /** Add a [OnDialogDismissedListener]. */ + fun addOnDialogDismissedListener(listener: OnDialogDismissedListener) + + /** Remove a [OnDialogDismissedListener]. */ + fun removeOnDialogDismissedListener(listener: OnDialogDismissedListener) + + /** Whether we should update the footer visibility. */ + // TODO(b/242040009): Remove this. + fun shouldUpdateFooterVisibility(): Boolean + + @VisibleForTesting + fun visibleButtonsCount(): Int + + interface OnNumberOfPackagesChangedListener { + /** Called when [numRunningPackages] changed. */ + fun onNumberOfPackagesChanged(numPackages: Int) + } + + interface OnDialogDismissedListener { + /** Called when a dialog shown using [showDialog] was dismissed. */ + fun onDialogDismissed() + } +} @SysUISingleton -class FgsManagerController @Inject constructor( +class FgsManagerControllerImpl @Inject constructor( private val context: Context, @Main private val mainExecutor: Executor, @Background private val backgroundExecutor: Executor, @@ -80,22 +146,32 @@ class FgsManagerController @Inject constructor( private val dialogLaunchAnimator: DialogLaunchAnimator, private val broadcastDispatcher: BroadcastDispatcher, private val dumpManager: DumpManager -) : IForegroundServiceObserver.Stub(), Dumpable { +) : IForegroundServiceObserver.Stub(), Dumpable, FgsManagerController { companion object { private const val INTERACTION_JANK_TAG = "active_background_apps" - private val LOG_TAG = FgsManagerController::class.java.simpleName private const val DEFAULT_TASK_MANAGER_ENABLED = true private const val DEFAULT_TASK_MANAGER_SHOW_FOOTER_DOT = false + private const val DEFAULT_TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS = true } - var changesSinceDialog = false + override var newChangesSinceDialogWasDismissed = false private set - var isAvailable = false - private set - var showFooterDot = false - private set + val _isAvailable = MutableStateFlow(false) + override val isAvailable: StateFlow = _isAvailable.asStateFlow() + + val _showFooterDot = MutableStateFlow(false) + override val showFooterDot: StateFlow = _showFooterDot.asStateFlow() + + private var showStopBtnForUserAllowlistedApps = false + + override val numRunningPackages: Int + get() { + synchronized(lock) { + return getNumVisiblePackagesLocked() + } + } private val lock = Any() @@ -133,15 +209,7 @@ class FgsManagerController @Inject constructor( } } - interface OnNumberOfPackagesChangedListener { - fun onNumberOfPackagesChanged(numPackages: Int) - } - - interface OnDialogDismissedListener { - fun onDialogDismissed() - } - - fun init() { + override fun init() { synchronized(lock) { if (initialized) { return @@ -160,19 +228,26 @@ class FgsManagerController @Inject constructor( NAMESPACE_SYSTEMUI, backgroundExecutor ) { - isAvailable = it.getBoolean(TASK_MANAGER_ENABLED, isAvailable) - showFooterDot = - it.getBoolean(TASK_MANAGER_SHOW_FOOTER_DOT, showFooterDot) + _isAvailable.value = it.getBoolean(TASK_MANAGER_ENABLED, _isAvailable.value) + _showFooterDot.value = + it.getBoolean(TASK_MANAGER_SHOW_FOOTER_DOT, _showFooterDot.value) + showStopBtnForUserAllowlistedApps = it.getBoolean( + TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS, + showStopBtnForUserAllowlistedApps) } - isAvailable = deviceConfigProxy.getBoolean( + _isAvailable.value = deviceConfigProxy.getBoolean( NAMESPACE_SYSTEMUI, TASK_MANAGER_ENABLED, DEFAULT_TASK_MANAGER_ENABLED ) - showFooterDot = deviceConfigProxy.getBoolean( + _showFooterDot.value = deviceConfigProxy.getBoolean( NAMESPACE_SYSTEMUI, TASK_MANAGER_SHOW_FOOTER_DOT, DEFAULT_TASK_MANAGER_SHOW_FOOTER_DOT ) + showStopBtnForUserAllowlistedApps = deviceConfigProxy.getBoolean( + NAMESPACE_SYSTEMUI, + TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS, + DEFAULT_TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS) dumpManager.registerDumpable(this) @@ -220,42 +295,45 @@ class FgsManagerController @Inject constructor( } @GuardedBy("lock") - val onNumberOfPackagesChangedListeners: MutableSet = - mutableSetOf() + private val onNumberOfPackagesChangedListeners = + mutableSetOf() @GuardedBy("lock") - val onDialogDismissedListeners: MutableSet = mutableSetOf() + private val onDialogDismissedListeners = + mutableSetOf() - fun addOnNumberOfPackagesChangedListener(listener: OnNumberOfPackagesChangedListener) { + override fun addOnNumberOfPackagesChangedListener( + listener: FgsManagerController.OnNumberOfPackagesChangedListener + ) { synchronized(lock) { onNumberOfPackagesChangedListeners.add(listener) } } - fun removeOnNumberOfPackagesChangedListener(listener: OnNumberOfPackagesChangedListener) { + override fun removeOnNumberOfPackagesChangedListener( + listener: FgsManagerController.OnNumberOfPackagesChangedListener + ) { synchronized(lock) { onNumberOfPackagesChangedListeners.remove(listener) } } - fun addOnDialogDismissedListener(listener: OnDialogDismissedListener) { + override fun addOnDialogDismissedListener( + listener: FgsManagerController.OnDialogDismissedListener + ) { synchronized(lock) { onDialogDismissedListeners.add(listener) } } - fun removeOnDialogDismissedListener(listener: OnDialogDismissedListener) { + override fun removeOnDialogDismissedListener( + listener: FgsManagerController.OnDialogDismissedListener + ) { synchronized(lock) { onDialogDismissedListeners.remove(listener) } } - fun getNumRunningPackages(): Int { - synchronized(lock) { - return getNumVisiblePackagesLocked() - } - } - private fun getNumVisiblePackagesLocked(): Int { return runningServiceTokens.keys.count { it.uiControl != UIControl.HIDE_ENTRY && currentProfileIds.contains(it.userId) @@ -266,7 +344,7 @@ class FgsManagerController @Inject constructor( val num = getNumVisiblePackagesLocked() if (num != lastNumberOfVisiblePackages) { lastNumberOfVisiblePackages = num - changesSinceDialog = true + newChangesSinceDialogWasDismissed = true onNumberOfPackagesChangedListeners.forEach { backgroundExecutor.execute { it.onNumberOfPackagesChanged(num) @@ -275,9 +353,21 @@ class FgsManagerController @Inject constructor( } } - fun shouldUpdateFooterVisibility() = dialog == null + override fun visibleButtonsCount(): Int { + synchronized(lock) { + return getNumVisibleButtonsLocked() + } + } - fun showDialog(viewLaunchedFrom: View?) { + private fun getNumVisibleButtonsLocked(): Int { + return runningServiceTokens.keys.count { + it.uiControl != UIControl.HIDE_BUTTON && currentProfileIds.contains(it.userId) + } + } + + override fun shouldUpdateFooterVisibility() = dialog == null + + override fun showDialog(viewLaunchedFrom: View?) { synchronized(lock) { if (dialog == null) { @@ -302,7 +392,7 @@ class FgsManagerController @Inject constructor( this.dialog = dialog dialog.setOnDismissListener { - changesSinceDialog = false + newChangesSinceDialogWasDismissed = false synchronized(lock) { this.dialog = null updateAppItemsLocked() @@ -505,6 +595,13 @@ class FgsManagerController @Inject constructor( PowerExemptionManager.REASON_PROC_STATE_PERSISTENT_UI, PowerExemptionManager.REASON_ROLE_DIALER, PowerExemptionManager.REASON_SYSTEM_MODULE -> UIControl.HIDE_BUTTON + + PowerExemptionManager.REASON_ALLOWLISTED_PACKAGE -> + if (showStopBtnForUserAllowlistedApps) { + UIControl.NORMAL + } else { + UIControl.HIDE_BUTTON + } else -> UIControl.NORMAL } uiControlInitialized = true @@ -623,7 +720,7 @@ class FgsManagerController @Inject constructor( val pw = IndentingPrintWriter(printwriter) synchronized(lock) { pw.println("current user profiles = $currentProfileIds") - pw.println("changesSinceDialog=$changesSinceDialog") + pw.println("newChangesSinceDialogWasShown=$newChangesSinceDialogWasDismissed") pw.println("Running service tokens: [") pw.indentIfPossible { runningServiceTokens.forEach { (userPackage, startTimeAndTokens) -> diff --git a/packages/SystemUI/src/com/android/systemui/qs/FooterActionsController.kt b/packages/SystemUI/src/com/android/systemui/qs/FooterActionsController.kt index c790cfe7b7b7c..9d64781ef2e9a 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/FooterActionsController.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/FooterActionsController.kt @@ -56,6 +56,7 @@ import javax.inject.Provider * determined by [buttonsVisibleState] */ @QSScope +// TODO(b/242040009): Remove this file. internal class FooterActionsController @Inject constructor( view: FooterActionsView, multiUserSwitchControllerFactory: MultiUserSwitchController.Factory, diff --git a/packages/SystemUI/src/com/android/systemui/qs/FooterActionsView.kt b/packages/SystemUI/src/com/android/systemui/qs/FooterActionsView.kt index 309ac2a66e6b9..d602b0b279774 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/FooterActionsView.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/FooterActionsView.kt @@ -38,6 +38,7 @@ import com.android.systemui.statusbar.phone.MultiUserSwitch * in split shade mode visible also in collapsed state. May contain up to 5 buttons: settings, * edit tiles, power off and conditionally: user switch and tuner */ +// TODO(b/242040009): Remove this file. class FooterActionsView(context: Context?, attrs: AttributeSet?) : LinearLayout(context, attrs) { private lateinit var settingsContainer: View private lateinit var multiUserSwitch: MultiUserSwitch diff --git a/packages/SystemUI/src/com/android/systemui/qs/NewFooterActionsController.kt b/packages/SystemUI/src/com/android/systemui/qs/NewFooterActionsController.kt new file mode 100644 index 0000000000000..7c67d9f42b55a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/NewFooterActionsController.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.qs + +import com.android.systemui.dagger.SysUISingleton +import javax.inject.Inject + +/** Controller for the footer actions. This manages the initialization of its dependencies. */ +@SysUISingleton +class NewFooterActionsController +@Inject +// TODO(b/242040009): Rename this to FooterActionsController. +constructor( + private val fgsManagerController: FgsManagerController, +) { + fun init() { + fgsManagerController.init() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java b/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java index 875493d73f9eb..7511278e0919b 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java @@ -41,6 +41,7 @@ import javax.inject.Named; /** * Footer entry point for the foreground service manager */ +// TODO(b/242040009): Remove this file. @QSScope public class QSFgsManagerFooter implements View.OnClickListener, FgsManagerController.OnDialogDismissedListener, @@ -149,9 +150,11 @@ public class QSFgsManagerFooter implements View.OnClickListener, mNumberView.setContentDescription(text); if (mFgsManagerController.shouldUpdateFooterVisibility()) { mRootView.setVisibility(mNumPackages > 0 - && mFgsManagerController.isAvailable() ? View.VISIBLE : View.GONE); - int dotVis = mFgsManagerController.getShowFooterDot() - && mFgsManagerController.getChangesSinceDialog() ? View.VISIBLE : View.GONE; + && mFgsManagerController.isAvailable().getValue() ? View.VISIBLE + : View.GONE); + int dotVis = mFgsManagerController.getShowFooterDot().getValue() + && mFgsManagerController.getNewChangesSinceDialogWasDismissed() + ? View.VISIBLE : View.GONE; mDotView.setVisibility(dotVis); mCollapsedDotView.setVisibility(dotVis); if (mVisibilityChangedListener != null) { diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java b/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java index 139fb8b0bc14e..05b3eae1d2f2e 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java @@ -36,6 +36,9 @@ import android.view.ViewTreeObserver; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LifecycleRegistry; import com.android.keyguard.BouncerPanelExpansionCalculator; import com.android.systemui.Dumpable; @@ -43,6 +46,8 @@ import com.android.systemui.R; import com.android.systemui.animation.Interpolators; import com.android.systemui.animation.ShadeInterpolation; import com.android.systemui.dump.DumpManager; +import com.android.systemui.flags.FeatureFlags; +import com.android.systemui.flags.Flags; import com.android.systemui.media.MediaHost; import com.android.systemui.plugins.FalsingManager; import com.android.systemui.plugins.qs.QS; @@ -50,6 +55,8 @@ import com.android.systemui.plugins.qs.QSContainerController; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.qs.customize.QSCustomizerController; import com.android.systemui.qs.dagger.QSFragmentComponent; +import com.android.systemui.qs.footer.ui.binder.FooterActionsViewBinder; +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel; import com.android.systemui.statusbar.CommandQueue; import com.android.systemui.statusbar.StatusBarState; import com.android.systemui.statusbar.notification.stack.StackStateAnimator; @@ -104,6 +111,10 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca private final QSFragmentComponent.Factory mQsComponentFactory; private final QSFragmentDisableFlagsLogger mQsFragmentDisableFlagsLogger; private final QSTileHost mHost; + private final FeatureFlags mFeatureFlags; + private final NewFooterActionsController mNewFooterActionsController; + private final FooterActionsViewModel.Factory mFooterActionsViewModelFactory; + private final ListeningAndVisibilityLifecycleOwner mListeningAndVisibilityLifecycleOwner; private boolean mShowCollapsedOnKeyguard; private boolean mLastKeyguardAndExpanded; /** @@ -119,8 +130,11 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca private QSPanelController mQSPanelController; private QuickQSPanelController mQuickQSPanelController; private QSCustomizerController mQSCustomizerController; + @Nullable private FooterActionsController mQSFooterActionController; @Nullable + private FooterActionsViewModel mQSFooterActionsViewModel; + @Nullable private ScrollListener mScrollListener; /** * When true, QS will translate from outside the screen. It will be clipped with parallax @@ -161,7 +175,9 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca KeyguardBypassController keyguardBypassController, QSFragmentComponent.Factory qsComponentFactory, QSFragmentDisableFlagsLogger qsFragmentDisableFlagsLogger, - FalsingManager falsingManager, DumpManager dumpManager) { + FalsingManager falsingManager, DumpManager dumpManager, FeatureFlags featureFlags, + NewFooterActionsController newFooterActionsController, + FooterActionsViewModel.Factory footerActionsViewModelFactory) { mRemoteInputQuickSettingsDisabler = remoteInputQsDisabler; mQsMediaHost = qsMediaHost; mQqsMediaHost = qqsMediaHost; @@ -173,6 +189,10 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca mBypassController = keyguardBypassController; mStatusBarStateController = statusBarStateController; mDumpManager = dumpManager; + mFeatureFlags = featureFlags; + mNewFooterActionsController = newFooterActionsController; + mFooterActionsViewModelFactory = footerActionsViewModelFactory; + mListeningAndVisibilityLifecycleOwner = new ListeningAndVisibilityLifecycleOwner(); } @Override @@ -193,11 +213,22 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca QSFragmentComponent qsFragmentComponent = mQsComponentFactory.create(this); mQSPanelController = qsFragmentComponent.getQSPanelController(); mQuickQSPanelController = qsFragmentComponent.getQuickQSPanelController(); - mQSFooterActionController = qsFragmentComponent.getQSFooterActionController(); mQSPanelController.init(); mQuickQSPanelController.init(); - mQSFooterActionController.init(); + + if (mFeatureFlags.isEnabled(Flags.NEW_FOOTER_ACTIONS)) { + mQSFooterActionsViewModel = mFooterActionsViewModelFactory.create(/* lifecycleOwner */ + this); + FooterActionsView footerActionsView = view.findViewById(R.id.qs_footer_actions); + FooterActionsViewBinder.bind(footerActionsView, mQSFooterActionsViewModel, + mListeningAndVisibilityLifecycleOwner); + + mNewFooterActionsController.init(); + } else { + mQSFooterActionController = qsFragmentComponent.getQSFooterActionController(); + mQSFooterActionController.init(); + } mQSPanelScrollView = view.findViewById(R.id.expanded_qs_scroll_view); mQSPanelScrollView.addOnLayoutChangeListener( @@ -283,6 +314,7 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca mDumpManager.unregisterDumpable(mContainer.getClass().getName()); } mDumpManager.unregisterDumpable(getClass().getName()); + mListeningAndVisibilityLifecycleOwner.destroy(); } @Override @@ -395,7 +427,9 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca mContainer.disable(state1, state2, animate); mHeader.disable(state1, state2, animate); mFooter.disable(state1, state2, animate); - mQSFooterActionController.disable(state2); + if (mQSFooterActionController != null) { + mQSFooterActionController.disable(state2); + } updateQsState(); } @@ -415,7 +449,11 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca boolean footerVisible = qsPanelVisible && (expanded || !keyguardShowing || mHeaderAnimating || mShowCollapsedOnKeyguard); mFooter.setVisibility(footerVisible ? View.VISIBLE : View.INVISIBLE); - mQSFooterActionController.setVisible(footerVisible); + if (mQSFooterActionController != null) { + mQSFooterActionController.setVisible(footerVisible); + } else { + mQSFooterActionsViewModel.onVisibilityChangeRequested(footerVisible); + } mFooter.setExpanded((keyguardShowing && !mHeaderAnimating && !mShowCollapsedOnKeyguard) || (expanded && !mStackScrollerOverscrolling)); mQSPanelController.setVisibility(qsPanelVisible ? View.VISIBLE : View.INVISIBLE); @@ -482,7 +520,9 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca } mFooter.setKeyguardShowing(keyguardShowing); - mQSFooterActionController.setKeyguardShowing(keyguardShowing); + if (mQSFooterActionController != null) { + mQSFooterActionController.setKeyguardShowing(keyguardShowing); + } updateQsState(); } @@ -498,7 +538,10 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca if (DEBUG) Log.d(TAG, "setListening " + listening); mListening = listening; mQSContainerImplController.setListening(listening && mQsVisible); - mQSFooterActionController.setListening(listening && mQsVisible); + if (mQSFooterActionController != null) { + mQSFooterActionController.setListening(listening && mQsVisible); + } + mListeningAndVisibilityLifecycleOwner.updateState(); updateQsPanelControllerListening(); } @@ -511,6 +554,7 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca if (DEBUG) Log.d(TAG, "setQsVisible " + visible); mQsVisible = visible; setListening(mListening); + mListeningAndVisibilityLifecycleOwner.updateState(); } @Override @@ -602,7 +646,12 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca mFooter.setExpansion(onKeyguardAndExpanded ? 1 : expansion); float footerActionsExpansion = onKeyguardAndExpanded ? 1 : mInSplitShade ? alphaProgress : expansion; - mQSFooterActionController.setExpansion(footerActionsExpansion); + if (mQSFooterActionController != null) { + mQSFooterActionController.setExpansion(footerActionsExpansion); + } else { + mQSFooterActionsViewModel.onQuickSettingsExpansionChanged(footerActionsExpansion, + mInSplitShade); + } mQSPanelController.setRevealExpansion(expansion); mQSPanelController.getTileLayout().setExpansion(expansion, proposedTranslation); mQuickQSPanelController.getTileLayout().setExpansion(expansion, proposedTranslation); @@ -714,7 +763,11 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca boolean customizing = isCustomizing(); mQSPanelScrollView.setVisibility(!customizing ? View.VISIBLE : View.INVISIBLE); mFooter.setVisibility(!customizing ? View.VISIBLE : View.INVISIBLE); - mQSFooterActionController.setVisible(!customizing); + if (mQSFooterActionController != null) { + mQSFooterActionController.setVisible(!customizing); + } else { + mQSFooterActionsViewModel.onVisibilityChangeRequested(!customizing); + } mHeader.setVisibility(!customizing ? View.VISIBLE : View.INVISIBLE); // Let the panel know the position changed and it needs to update where notifications // and whatnot are. @@ -860,4 +913,56 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca } return "GONE"; } + + /** + * A {@link LifecycleOwner} whose state is driven by the current state of this fragment: + * + * - DESTROYED when the fragment is destroyed. + * - CREATED when mListening == mQsVisible == false. + * - STARTED when mListening == true && mQsVisible == false. + * - RESUMED when mListening == true && mQsVisible == true. + */ + private class ListeningAndVisibilityLifecycleOwner implements LifecycleOwner { + private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); + private boolean mDestroyed = false; + + { + updateState(); + } + + @Override + public Lifecycle getLifecycle() { + return mLifecycleRegistry; + } + + /** + * Update the state of the associated lifecycle. This should be called whenever + * {@code mListening} or {@code mQsVisible} is changed. + */ + public void updateState() { + if (mDestroyed) { + mLifecycleRegistry.setCurrentState(Lifecycle.State.DESTROYED); + return; + } + + if (!mListening) { + mLifecycleRegistry.setCurrentState(Lifecycle.State.CREATED); + return; + } + + // mListening && !mQsVisible. + if (!mQsVisible) { + mLifecycleRegistry.setCurrentState(Lifecycle.State.STARTED); + return; + } + + // mListening && mQsVisible. + mLifecycleRegistry.setCurrentState(Lifecycle.State.RESUMED); + } + + public void destroy() { + mDestroyed = true; + updateState(); + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java index 87fcce455ea6d..b20d7ba333972 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java @@ -15,126 +15,66 @@ */ package com.android.systemui.qs; -import static android.app.admin.DevicePolicyManager.DEVICE_OWNER_TYPE_FINANCED; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_CA_CERT; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_NETWORK; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_TWO_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MONITORING_CA_CERT_SUBTITLE; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MONITORING_NETWORK_SUBTITLE; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MONITORING_VPN_SUBTITLE; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_NAMED_MANAGEMENT; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_PERSONAL_PROFILE_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_VIEW_POLICIES; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_WORK_PROFILE_CA_CERT; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_WORK_PROFILE_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_WORK_PROFILE_NETWORK; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT_MONITORING; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT_MULTIPLE_VPNS; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT_MONITORING; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT_MULTIPLE_VPNS; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_WORK_PROFILE_MONITORING; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_PERSONAL_PROFILE_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_WORK_PROFILE_MONITORING; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_WORK_PROFILE_NAMED_VPN; -import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_WORK_PROFILE_NETWORK; - import static com.android.systemui.qs.dagger.QSFragmentModule.QS_SECURITY_FOOTER_VIEW; -import android.app.AlertDialog; -import android.app.Dialog; -import android.app.admin.DeviceAdminInfo; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.BroadcastReceiver; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.UserInfo; import android.content.res.Resources; -import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.os.UserHandle; -import android.os.UserManager; -import android.provider.Settings; -import android.text.SpannableStringBuilder; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; import android.util.Log; -import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; -import android.view.Window; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import com.android.internal.jank.InteractionJankMonitor; import com.android.internal.util.FrameworkStatsLog; import com.android.systemui.FontSizeUtils; import com.android.systemui.R; -import com.android.systemui.animation.DialogCuj; -import com.android.systemui.animation.DialogLaunchAnimator; import com.android.systemui.broadcast.BroadcastDispatcher; +import com.android.systemui.common.shared.model.Icon; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.dagger.qualifiers.Main; -import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.qs.dagger.QSScope; -import com.android.systemui.settings.UserTracker; -import com.android.systemui.statusbar.phone.SystemUIDialog; +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig; +import com.android.systemui.security.data.model.SecurityModel; import com.android.systemui.statusbar.policy.SecurityController; import com.android.systemui.util.ViewController; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; - import javax.inject.Inject; import javax.inject.Named; +/** ViewController for the footer actions. */ +// TODO(b/242040009): Remove this class. @QSScope -class QSSecurityFooter extends ViewController - implements OnClickListener, DialogInterface.OnClickListener, - VisibilityChangedDispatcher { +public class QSSecurityFooter extends ViewController + implements OnClickListener, VisibilityChangedDispatcher { protected static final String TAG = "QSSecurityFooter"; - protected static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); - private static final boolean DEBUG_FORCE_VISIBLE = false; - - private static final String INTERACTION_JANK_TAG = "managed_device_info"; private final TextView mFooterText; private final ImageView mPrimaryFooterIcon; private Context mContext; - private final DevicePolicyManager mDpm; private final Callback mCallback = new Callback(); private final SecurityController mSecurityController; - private final ActivityStarter mActivityStarter; private final Handler mMainHandler; - private final UserTracker mUserTracker; - private final DialogLaunchAnimator mDialogLaunchAnimator; private final BroadcastDispatcher mBroadcastDispatcher; + private final QSSecurityFooterUtils mQSSecurityFooterUtils; - private final AtomicBoolean mShouldUseSettingsButton = new AtomicBoolean(false); - - private AlertDialog mDialog; protected H mHandler; private boolean mIsVisible; + private boolean mIsClickable; @Nullable private CharSequence mFooterTextContent = null; - private int mFooterIconId; - @Nullable - private Drawable mPrimaryFooterIconDrawable; + private Icon mFooterIcon; @Nullable private VisibilityChangedDispatcher.OnVisibilityChangedListener mVisibilityChangedListener; @@ -149,82 +89,21 @@ class QSSecurityFooter extends ViewController } }; - private Supplier mManagementTitleSupplier = () -> - mContext == null ? null : mContext.getString(R.string.monitoring_title_device_owned); - - private Supplier mManagementMessageSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.quick_settings_disclosure_management); - - private Supplier mManagementMonitoringStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.quick_settings_disclosure_management_monitoring); - - private Supplier mManagementMultipleVpnStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.quick_settings_disclosure_management_vpns); - - private Supplier mWorkProfileMonitoringStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.quick_settings_disclosure_managed_profile_monitoring); - - private Supplier mWorkProfileNetworkStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.quick_settings_disclosure_managed_profile_network_activity); - - private Supplier mMonitoringSubtitleCaCertStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_subtitle_ca_certificate); - - private Supplier mMonitoringSubtitleNetworkStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_subtitle_network_logging); - - private Supplier mMonitoringSubtitleVpnStringSupplier = () -> - mContext == null ? null : mContext.getString(R.string.monitoring_subtitle_vpn); - - private Supplier mViewPoliciesButtonStringSupplier = () -> - mContext == null ? null : mContext.getString(R.string.monitoring_button_view_policies); - - private Supplier mManagementDialogStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_description_management); - - private Supplier mManagementDialogCaCertStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_description_management_ca_certificate); - - private Supplier mWorkProfileDialogCaCertStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_description_managed_profile_ca_certificate); - - private Supplier mManagementDialogNetworkStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_description_management_network_logging); - - private Supplier mWorkProfileDialogNetworkStringSupplier = () -> - mContext == null ? null : mContext.getString( - R.string.monitoring_description_managed_profile_network_logging); - @Inject QSSecurityFooter(@Named(QS_SECURITY_FOOTER_VIEW) View rootView, - UserTracker userTracker, @Main Handler mainHandler, - ActivityStarter activityStarter, SecurityController securityController, - DialogLaunchAnimator dialogLaunchAnimator, @Background Looper bgLooper, - BroadcastDispatcher broadcastDispatcher) { + @Main Handler mainHandler, SecurityController securityController, + @Background Looper bgLooper, BroadcastDispatcher broadcastDispatcher, + QSSecurityFooterUtils qSSecurityFooterUtils) { super(rootView); mFooterText = mView.findViewById(R.id.footer_text); mPrimaryFooterIcon = mView.findViewById(R.id.primary_footer_icon); - mFooterIconId = R.drawable.ic_info_outline; + mFooterIcon = new Icon.Resource(R.drawable.ic_info_outline); mContext = rootView.getContext(); - mDpm = rootView.getContext().getSystemService(DevicePolicyManager.class); - mMainHandler = mainHandler; - mActivityStarter = activityStarter; mSecurityController = securityController; + mMainHandler = mainHandler; mHandler = new H(bgLooper); - mUserTracker = userTracker; - mDialogLaunchAnimator = dialogLaunchAnimator; mBroadcastDispatcher = broadcastDispatcher; + mQSSecurityFooterUtils = qSSecurityFooterUtils; } @Override @@ -287,8 +166,9 @@ class QSSecurityFooter extends ViewController .write(); } + // TODO(b/242040009): Remove this. public void showDeviceMonitoringDialog() { - createDialog(); + mQSSecurityFooterUtils.showDeviceMonitoringDialog(mContext, mView); } public void refreshState() { @@ -296,590 +176,30 @@ class QSSecurityFooter extends ViewController } private void handleRefreshState() { - final boolean isDeviceManaged = mSecurityController.isDeviceManaged(); - final UserInfo currentUser = mUserTracker.getUserInfo(); - final boolean isDemoDevice = UserManager.isDeviceInDemoMode(mContext) && currentUser != null - && currentUser.isDemo(); - final boolean hasWorkProfile = mSecurityController.hasWorkProfile(); - final boolean hasCACerts = mSecurityController.hasCACertInCurrentUser(); - final boolean hasCACertsInWorkProfile = mSecurityController.hasCACertInWorkProfile(); - final boolean isNetworkLoggingEnabled = mSecurityController.isNetworkLoggingEnabled(); - final String vpnName = mSecurityController.getPrimaryVpnName(); - final String vpnNameWorkProfile = mSecurityController.getWorkProfileVpnName(); - final CharSequence organizationName = mSecurityController.getDeviceOwnerOrganizationName(); - final CharSequence workProfileOrganizationName = - mSecurityController.getWorkProfileOrganizationName(); - final boolean isProfileOwnerOfOrganizationOwnedDevice = - mSecurityController.isProfileOwnerOfOrganizationOwnedDevice(); - final boolean isParentalControlsEnabled = mSecurityController.isParentalControlsEnabled(); - final boolean isWorkProfileOn = mSecurityController.isWorkProfileOn(); - final boolean hasDisclosableWorkProfilePolicy = hasCACertsInWorkProfile - || vpnNameWorkProfile != null || (hasWorkProfile && isNetworkLoggingEnabled); - // Update visibility of footer - mIsVisible = (isDeviceManaged && !isDemoDevice) - || hasCACerts - || vpnName != null - || isProfileOwnerOfOrganizationOwnedDevice - || isParentalControlsEnabled - || (hasDisclosableWorkProfilePolicy && isWorkProfileOn); - // Update the view to be untappable if the device is an organization-owned device with a - // managed profile and there is either: - // a) no policy set which requires a privacy disclosure. - // b) a specific work policy set but the work profile is turned off. - if (mIsVisible && isProfileOwnerOfOrganizationOwnedDevice - && (!hasDisclosableWorkProfilePolicy || !isWorkProfileOn)) { - mView.setClickable(false); - mView.findViewById(R.id.footer_icon).setVisibility(View.GONE); + SecurityModel securityModel = SecurityModel.create(mSecurityController); + SecurityButtonConfig buttonConfig = mQSSecurityFooterUtils.getButtonConfig(securityModel); + + if (buttonConfig == null) { + mIsVisible = false; } else { - mView.setClickable(true); - mView.findViewById(R.id.footer_icon).setVisibility(View.VISIBLE); - } - // Update the string - mFooterTextContent = getFooterText(isDeviceManaged, hasWorkProfile, - hasCACerts, hasCACertsInWorkProfile, isNetworkLoggingEnabled, vpnName, - vpnNameWorkProfile, organizationName, workProfileOrganizationName, - isProfileOwnerOfOrganizationOwnedDevice, isParentalControlsEnabled, - isWorkProfileOn); - // Update the icon - int footerIconId = R.drawable.ic_info_outline; - if (vpnName != null || vpnNameWorkProfile != null) { - if (mSecurityController.isVpnBranded()) { - footerIconId = R.drawable.stat_sys_branded_vpn; - } else { - footerIconId = R.drawable.stat_sys_vpn_ic; - } - } - if (mFooterIconId != footerIconId) { - mFooterIconId = footerIconId; + mIsVisible = true; + mIsClickable = buttonConfig.isClickable(); + mFooterTextContent = buttonConfig.getText(); + mFooterIcon = buttonConfig.getIcon(); } - // Update the primary icon - if (isParentalControlsEnabled) { - if (mPrimaryFooterIconDrawable == null) { - DeviceAdminInfo info = mSecurityController.getDeviceAdminInfo(); - mPrimaryFooterIconDrawable = mSecurityController.getIcon(info); - } - } else { - mPrimaryFooterIconDrawable = null; - } + // Update the UI. mMainHandler.post(mUpdatePrimaryIcon); - mMainHandler.post(mUpdateDisplayState); } - @Nullable - protected CharSequence getFooterText(boolean isDeviceManaged, boolean hasWorkProfile, - boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled, - String vpnName, String vpnNameWorkProfile, CharSequence organizationName, - CharSequence workProfileOrganizationName, - boolean isProfileOwnerOfOrganizationOwnedDevice, boolean isParentalControlsEnabled, - boolean isWorkProfileOn) { - if (isParentalControlsEnabled) { - return mContext.getString(R.string.quick_settings_disclosure_parental_controls); - } - if (isDeviceManaged || DEBUG_FORCE_VISIBLE) { - return getManagedDeviceFooterText(hasCACerts, hasCACertsInWorkProfile, - isNetworkLoggingEnabled, vpnName, vpnNameWorkProfile, organizationName); - } - return getManagedAndPersonalProfileFooterText(hasWorkProfile, hasCACerts, - hasCACertsInWorkProfile, isNetworkLoggingEnabled, vpnName, vpnNameWorkProfile, - workProfileOrganizationName, isProfileOwnerOfOrganizationOwnedDevice, - isWorkProfileOn); - } - - private String getManagedDeviceFooterText( - boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled, - String vpnName, String vpnNameWorkProfile, CharSequence organizationName) { - if (hasCACerts || hasCACertsInWorkProfile || isNetworkLoggingEnabled) { - return getManagedDeviceMonitoringText(organizationName); - } - if (vpnName != null || vpnNameWorkProfile != null) { - return getManagedDeviceVpnText(vpnName, vpnNameWorkProfile, organizationName); - } - return getMangedDeviceGeneralText(organizationName); - } - - private String getManagedDeviceMonitoringText(CharSequence organizationName) { - if (organizationName == null) { - return mDpm.getResources().getString( - QS_MSG_MANAGEMENT_MONITORING, mManagementMonitoringStringSupplier); - } - return mDpm.getResources().getString( - QS_MSG_NAMED_MANAGEMENT_MONITORING, - () -> mContext.getString( - R.string.quick_settings_disclosure_named_management_monitoring, - organizationName), - organizationName); - } - - private String getManagedDeviceVpnText( - String vpnName, String vpnNameWorkProfile, CharSequence organizationName) { - if (vpnName != null && vpnNameWorkProfile != null) { - if (organizationName == null) { - return mDpm.getResources().getString( - QS_MSG_MANAGEMENT_MULTIPLE_VPNS, mManagementMultipleVpnStringSupplier); - } - return mDpm.getResources().getString( - QS_MSG_NAMED_MANAGEMENT_MULTIPLE_VPNS, - () -> mContext.getString( - R.string.quick_settings_disclosure_named_management_vpns, - organizationName), - organizationName); - } - String name = vpnName != null ? vpnName : vpnNameWorkProfile; - if (organizationName == null) { - return mDpm.getResources().getString( - QS_MSG_MANAGEMENT_NAMED_VPN, - () -> mContext.getString( - R.string.quick_settings_disclosure_management_named_vpn, - name), - name); - } - return mDpm.getResources().getString( - QS_MSG_NAMED_MANAGEMENT_NAMED_VPN, - () -> mContext.getString( - R.string.quick_settings_disclosure_named_management_named_vpn, - organizationName, - name), - organizationName, - name); - } - - private String getMangedDeviceGeneralText(CharSequence organizationName) { - if (organizationName == null) { - return mDpm.getResources().getString(QS_MSG_MANAGEMENT, mManagementMessageSupplier); - } - if (isFinancedDevice()) { - return mContext.getString( - R.string.quick_settings_financed_disclosure_named_management, - organizationName); - } else { - return mDpm.getResources().getString( - QS_MSG_NAMED_MANAGEMENT, - () -> mContext.getString( - R.string.quick_settings_disclosure_named_management, - organizationName), - organizationName); - } - } - - private String getManagedAndPersonalProfileFooterText(boolean hasWorkProfile, - boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled, - String vpnName, String vpnNameWorkProfile, CharSequence workProfileOrganizationName, - boolean isProfileOwnerOfOrganizationOwnedDevice, boolean isWorkProfileOn) { - if (hasCACerts || (hasCACertsInWorkProfile && isWorkProfileOn)) { - return getMonitoringText( - hasCACerts, hasCACertsInWorkProfile, workProfileOrganizationName, - isWorkProfileOn); - } - if (vpnName != null || (vpnNameWorkProfile != null && isWorkProfileOn)) { - return getVpnText(hasWorkProfile, vpnName, vpnNameWorkProfile, isWorkProfileOn); - } - if (hasWorkProfile && isNetworkLoggingEnabled && isWorkProfileOn) { - return getManagedProfileNetworkActivityText(); - } - if (isProfileOwnerOfOrganizationOwnedDevice) { - return getMangedDeviceGeneralText(workProfileOrganizationName); - } - return null; - } - - private String getMonitoringText(boolean hasCACerts, boolean hasCACertsInWorkProfile, - CharSequence workProfileOrganizationName, boolean isWorkProfileOn) { - if (hasCACertsInWorkProfile && isWorkProfileOn) { - if (workProfileOrganizationName == null) { - return mDpm.getResources().getString( - QS_MSG_WORK_PROFILE_MONITORING, mWorkProfileMonitoringStringSupplier); - } - return mDpm.getResources().getString( - QS_MSG_NAMED_WORK_PROFILE_MONITORING, - () -> mContext.getString( - R.string.quick_settings_disclosure_named_managed_profile_monitoring, - workProfileOrganizationName), - workProfileOrganizationName); - } - if (hasCACerts) { - return mContext.getString(R.string.quick_settings_disclosure_monitoring); - } - return null; - } - - private String getVpnText(boolean hasWorkProfile, String vpnName, String vpnNameWorkProfile, - boolean isWorkProfileOn) { - if (vpnName != null && vpnNameWorkProfile != null) { - return mContext.getString(R.string.quick_settings_disclosure_vpns); - } - if (vpnNameWorkProfile != null && isWorkProfileOn) { - return mDpm.getResources().getString( - QS_MSG_WORK_PROFILE_NAMED_VPN, - () -> mContext.getString( - R.string.quick_settings_disclosure_managed_profile_named_vpn, - vpnNameWorkProfile), - vpnNameWorkProfile); - } - if (vpnName != null) { - if (hasWorkProfile) { - return mDpm.getResources().getString( - QS_MSG_PERSONAL_PROFILE_NAMED_VPN, - () -> mContext.getString( - R.string.quick_settings_disclosure_personal_profile_named_vpn, - vpnName), - vpnName); - } - return mContext.getString(R.string.quick_settings_disclosure_named_vpn, - vpnName); - } - return null; - } - - private String getManagedProfileNetworkActivityText() { - return mDpm.getResources().getString( - QS_MSG_WORK_PROFILE_NETWORK, mWorkProfileNetworkStringSupplier); - } - - @Override - public void onClick(DialogInterface dialog, int which) { - if (which == DialogInterface.BUTTON_NEGATIVE) { - final Intent intent = new Intent(Settings.ACTION_ENTERPRISE_PRIVACY_SETTINGS); - dialog.dismiss(); - // This dismisses the shade on opening the activity - mActivityStarter.postStartActivityDismissingKeyguard(intent, 0); - } - } - - private void createDialog() { - mShouldUseSettingsButton.set(false); - mHandler.post(() -> { - String settingsButtonText = getSettingsButton(); - final View view = createDialogView(); - mMainHandler.post(() -> { - mDialog = new SystemUIDialog(mContext, 0); // Use mContext theme - mDialog.requestWindowFeature(Window.FEATURE_NO_TITLE); - mDialog.setButton(DialogInterface.BUTTON_POSITIVE, getPositiveButton(), this); - mDialog.setButton(DialogInterface.BUTTON_NEGATIVE, mShouldUseSettingsButton.get() - ? settingsButtonText : getNegativeButton(), this); - - mDialog.setView(view); - if (mView.isAggregatedVisible()) { - mDialogLaunchAnimator.showFromView(mDialog, mView, new DialogCuj( - InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN, INTERACTION_JANK_TAG)); - } else { - mDialog.show(); - } - }); - }); - } - - @VisibleForTesting - Dialog getDialog() { - return mDialog; - } - - @VisibleForTesting - View createDialogView() { - if (mSecurityController.isParentalControlsEnabled()) { - return createParentalControlsDialogView(); - } - return createOrganizationDialogView(); - } - - private View createOrganizationDialogView() { - final boolean isDeviceManaged = mSecurityController.isDeviceManaged(); - final boolean hasWorkProfile = mSecurityController.hasWorkProfile(); - final CharSequence deviceOwnerOrganization = - mSecurityController.getDeviceOwnerOrganizationName(); - final boolean hasCACerts = mSecurityController.hasCACertInCurrentUser(); - final boolean hasCACertsInWorkProfile = mSecurityController.hasCACertInWorkProfile(); - final boolean isNetworkLoggingEnabled = mSecurityController.isNetworkLoggingEnabled(); - final String vpnName = mSecurityController.getPrimaryVpnName(); - final String vpnNameWorkProfile = mSecurityController.getWorkProfileVpnName(); - - View dialogView = LayoutInflater.from(mContext) - .inflate(R.layout.quick_settings_footer_dialog, null, false); - - // device management section - TextView deviceManagementSubtitle = - dialogView.findViewById(R.id.device_management_subtitle); - deviceManagementSubtitle.setText(getManagementTitle(deviceOwnerOrganization)); - - CharSequence managementMessage = getManagementMessage(isDeviceManaged, - deviceOwnerOrganization); - if (managementMessage == null) { - dialogView.findViewById(R.id.device_management_disclosures).setVisibility(View.GONE); - } else { - dialogView.findViewById(R.id.device_management_disclosures).setVisibility(View.VISIBLE); - TextView deviceManagementWarning = - (TextView) dialogView.findViewById(R.id.device_management_warning); - deviceManagementWarning.setText(managementMessage); - mShouldUseSettingsButton.set(true); - } - - // ca certificate section - CharSequence caCertsMessage = getCaCertsMessage(isDeviceManaged, hasCACerts, - hasCACertsInWorkProfile); - if (caCertsMessage == null) { - dialogView.findViewById(R.id.ca_certs_disclosures).setVisibility(View.GONE); - } else { - dialogView.findViewById(R.id.ca_certs_disclosures).setVisibility(View.VISIBLE); - TextView caCertsWarning = (TextView) dialogView.findViewById(R.id.ca_certs_warning); - caCertsWarning.setText(caCertsMessage); - // Make "Open trusted credentials"-link clickable - caCertsWarning.setMovementMethod(new LinkMovementMethod()); - - TextView caCertsSubtitle = (TextView) dialogView.findViewById(R.id.ca_certs_subtitle); - String caCertsSubtitleMessage = mDpm.getResources().getString( - QS_DIALOG_MONITORING_CA_CERT_SUBTITLE, mMonitoringSubtitleCaCertStringSupplier); - caCertsSubtitle.setText(caCertsSubtitleMessage); - - } - - // network logging section - CharSequence networkLoggingMessage = getNetworkLoggingMessage(isDeviceManaged, - isNetworkLoggingEnabled); - if (networkLoggingMessage == null) { - dialogView.findViewById(R.id.network_logging_disclosures).setVisibility(View.GONE); - } else { - dialogView.findViewById(R.id.network_logging_disclosures).setVisibility(View.VISIBLE); - TextView networkLoggingWarning = - (TextView) dialogView.findViewById(R.id.network_logging_warning); - networkLoggingWarning.setText(networkLoggingMessage); - - TextView networkLoggingSubtitle = (TextView) dialogView.findViewById( - R.id.network_logging_subtitle); - String networkLoggingSubtitleMessage = mDpm.getResources().getString( - QS_DIALOG_MONITORING_NETWORK_SUBTITLE, - mMonitoringSubtitleNetworkStringSupplier); - networkLoggingSubtitle.setText(networkLoggingSubtitleMessage); - } - - // vpn section - CharSequence vpnMessage = getVpnMessage(isDeviceManaged, hasWorkProfile, vpnName, - vpnNameWorkProfile); - if (vpnMessage == null) { - dialogView.findViewById(R.id.vpn_disclosures).setVisibility(View.GONE); - } else { - dialogView.findViewById(R.id.vpn_disclosures).setVisibility(View.VISIBLE); - TextView vpnWarning = (TextView) dialogView.findViewById(R.id.vpn_warning); - vpnWarning.setText(vpnMessage); - // Make "Open VPN Settings"-link clickable - vpnWarning.setMovementMethod(new LinkMovementMethod()); - - TextView vpnSubtitle = (TextView) dialogView.findViewById(R.id.vpn_subtitle); - String vpnSubtitleMessage = mDpm.getResources().getString( - QS_DIALOG_MONITORING_VPN_SUBTITLE, mMonitoringSubtitleVpnStringSupplier); - vpnSubtitle.setText(vpnSubtitleMessage); - } - - // Note: if a new section is added, should update configSubtitleVisibility to include - // the handling of the subtitle - configSubtitleVisibility(managementMessage != null, - caCertsMessage != null, - networkLoggingMessage != null, - vpnMessage != null, - dialogView); - - return dialogView; - } - - private View createParentalControlsDialogView() { - View dialogView = LayoutInflater.from(mContext) - .inflate(R.layout.quick_settings_footer_dialog_parental_controls, null, false); - - DeviceAdminInfo info = mSecurityController.getDeviceAdminInfo(); - Drawable icon = mSecurityController.getIcon(info); - if (icon != null) { - ImageView imageView = (ImageView) dialogView.findViewById(R.id.parental_controls_icon); - imageView.setImageDrawable(icon); - } - - TextView parentalControlsTitle = - (TextView) dialogView.findViewById(R.id.parental_controls_title); - parentalControlsTitle.setText(mSecurityController.getLabel(info)); - - return dialogView; - } - - protected void configSubtitleVisibility(boolean showDeviceManagement, boolean showCaCerts, - boolean showNetworkLogging, boolean showVpn, View dialogView) { - // Device Management title should always been shown - // When there is a Device Management message, all subtitles should be shown - if (showDeviceManagement) { - return; - } - // Hide the subtitle if there is only 1 message shown - int mSectionCountExcludingDeviceMgt = 0; - if (showCaCerts) { mSectionCountExcludingDeviceMgt++; } - if (showNetworkLogging) { mSectionCountExcludingDeviceMgt++; } - if (showVpn) { mSectionCountExcludingDeviceMgt++; } - - // No work needed if there is no sections or more than 1 section - if (mSectionCountExcludingDeviceMgt != 1) { - return; - } - if (showCaCerts) { - dialogView.findViewById(R.id.ca_certs_subtitle).setVisibility(View.GONE); - } - if (showNetworkLogging) { - dialogView.findViewById(R.id.network_logging_subtitle).setVisibility(View.GONE); - } - if (showVpn) { - dialogView.findViewById(R.id.vpn_subtitle).setVisibility(View.GONE); - } - } - - // This should not be called on the main thread to avoid making an IPC. - @VisibleForTesting - String getSettingsButton() { - return mDpm.getResources().getString( - QS_DIALOG_VIEW_POLICIES, mViewPoliciesButtonStringSupplier); - } - - private String getPositiveButton() { - return mContext.getString(R.string.ok); - } - - @Nullable - private String getNegativeButton() { - if (mSecurityController.isParentalControlsEnabled()) { - return mContext.getString(R.string.monitoring_button_view_controls); - } - return null; - } - - @Nullable - protected CharSequence getManagementMessage(boolean isDeviceManaged, - CharSequence organizationName) { - if (!isDeviceManaged) { - return null; - } - if (organizationName != null) { - if (isFinancedDevice()) { - return mContext.getString(R.string.monitoring_financed_description_named_management, - organizationName, organizationName); - } else { - return mDpm.getResources().getString( - QS_DIALOG_NAMED_MANAGEMENT, - () -> mContext.getString( - R.string.monitoring_description_named_management, - organizationName), - organizationName); - } - } - return mDpm.getResources().getString(QS_DIALOG_MANAGEMENT, mManagementDialogStringSupplier); - } - - @Nullable - protected CharSequence getCaCertsMessage(boolean isDeviceManaged, boolean hasCACerts, - boolean hasCACertsInWorkProfile) { - if (!(hasCACerts || hasCACertsInWorkProfile)) return null; - if (isDeviceManaged) { - return mDpm.getResources().getString( - QS_DIALOG_MANAGEMENT_CA_CERT, mManagementDialogCaCertStringSupplier); - } - if (hasCACertsInWorkProfile) { - return mDpm.getResources().getString( - QS_DIALOG_WORK_PROFILE_CA_CERT, mWorkProfileDialogCaCertStringSupplier); - } - return mContext.getString(R.string.monitoring_description_ca_certificate); - } - - @Nullable - protected CharSequence getNetworkLoggingMessage(boolean isDeviceManaged, - boolean isNetworkLoggingEnabled) { - if (!isNetworkLoggingEnabled) return null; - if (isDeviceManaged) { - return mDpm.getResources().getString( - QS_DIALOG_MANAGEMENT_NETWORK, mManagementDialogNetworkStringSupplier); - } else { - return mDpm.getResources().getString( - QS_DIALOG_WORK_PROFILE_NETWORK, mWorkProfileDialogNetworkStringSupplier); - } - } - - @Nullable - protected CharSequence getVpnMessage(boolean isDeviceManaged, boolean hasWorkProfile, - String vpnName, String vpnNameWorkProfile) { - if (vpnName == null && vpnNameWorkProfile == null) return null; - final SpannableStringBuilder message = new SpannableStringBuilder(); - if (isDeviceManaged) { - if (vpnName != null && vpnNameWorkProfile != null) { - String namedVpns = mDpm.getResources().getString( - QS_DIALOG_MANAGEMENT_TWO_NAMED_VPN, - () -> mContext.getString( - R.string.monitoring_description_two_named_vpns, - vpnName, vpnNameWorkProfile), - vpnName, vpnNameWorkProfile); - message.append(namedVpns); - } else { - String name = vpnName != null ? vpnName : vpnNameWorkProfile; - String namedVp = mDpm.getResources().getString( - QS_DIALOG_MANAGEMENT_NAMED_VPN, - () -> mContext.getString(R.string.monitoring_description_named_vpn, name), - name); - message.append(namedVp); - } - } else { - if (vpnName != null && vpnNameWorkProfile != null) { - String namedVpns = mDpm.getResources().getString( - QS_DIALOG_MANAGEMENT_TWO_NAMED_VPN, - () -> mContext.getString( - R.string.monitoring_description_two_named_vpns, - vpnName, vpnNameWorkProfile), - vpnName, vpnNameWorkProfile); - message.append(namedVpns); - } else if (vpnNameWorkProfile != null) { - String namedVpn = mDpm.getResources().getString( - QS_DIALOG_WORK_PROFILE_NAMED_VPN, - () -> mContext.getString( - R.string.monitoring_description_managed_profile_named_vpn, - vpnNameWorkProfile), - vpnNameWorkProfile); - message.append(namedVpn); - } else if (hasWorkProfile) { - String namedVpn = mDpm.getResources().getString( - QS_DIALOG_PERSONAL_PROFILE_NAMED_VPN, - () -> mContext.getString( - R.string.monitoring_description_personal_profile_named_vpn, - vpnName), - vpnName); - message.append(namedVpn); - } else { - message.append(mContext.getString(R.string.monitoring_description_named_vpn, - vpnName)); - } - } - message.append(mContext.getString(R.string.monitoring_description_vpn_settings_separator)); - message.append(mContext.getString(R.string.monitoring_description_vpn_settings), - new VpnSpan(), 0); - return message; - } - - @VisibleForTesting - CharSequence getManagementTitle(CharSequence deviceOwnerOrganization) { - if (deviceOwnerOrganization != null && isFinancedDevice()) { - return mContext.getString(R.string.monitoring_title_financed_device, - deviceOwnerOrganization); - } else { - return mDpm.getResources().getString( - QS_DIALOG_MANAGEMENT_TITLE, - mManagementTitleSupplier); - } - } - - private boolean isFinancedDevice() { - return mSecurityController.isDeviceManaged() - && mSecurityController.getDeviceOwnerType( - mSecurityController.getDeviceOwnerComponentOnAnyUser()) - == DEVICE_OWNER_TYPE_FINANCED; - } - private final Runnable mUpdatePrimaryIcon = new Runnable() { @Override public void run() { - if (mPrimaryFooterIconDrawable != null) { - mPrimaryFooterIcon.setImageDrawable(mPrimaryFooterIconDrawable); - } else { - mPrimaryFooterIcon.setImageResource(mFooterIconId); + if (mFooterIcon instanceof Icon.Loaded) { + mPrimaryFooterIcon.setImageDrawable(((Icon.Loaded) mFooterIcon).getDrawable()); + } else if (mFooterIcon instanceof Icon.Resource) { + mPrimaryFooterIcon.setImageResource(((Icon.Resource) mFooterIcon).getRes()); } } }; @@ -890,10 +210,18 @@ class QSSecurityFooter extends ViewController if (mFooterTextContent != null) { mFooterText.setText(mFooterTextContent); } - mView.setVisibility(mIsVisible || DEBUG_FORCE_VISIBLE ? View.VISIBLE : View.GONE); + mView.setVisibility(mIsVisible ? View.VISIBLE : View.GONE); if (mVisibilityChangedListener != null) { mVisibilityChangedListener.onVisibilityChanged(mView.getVisibility()); } + + if (mIsVisible && mIsClickable) { + mView.setClickable(true); + mView.findViewById(R.id.footer_icon).setVisibility(View.VISIBLE); + } else { + mView.setClickable(false); + mView.findViewById(R.id.footer_icon).setVisibility(View.GONE); + } } }; @@ -929,25 +257,4 @@ class QSSecurityFooter extends ViewController } } } - - protected class VpnSpan extends ClickableSpan { - @Override - public void onClick(View widget) { - final Intent intent = new Intent(Settings.ACTION_VPN_SETTINGS); - mDialog.dismiss(); - // This dismisses the shade on opening the activity - mActivityStarter.postStartActivityDismissingKeyguard(intent, 0); - } - - // for testing, to compare two CharSequences containing VpnSpans - @Override - public boolean equals(Object object) { - return object instanceof VpnSpan; - } - - @Override - public int hashCode() { - return 314159257; // prime - } - } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java new file mode 100644 index 0000000000000..f6322743eaa11 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java @@ -0,0 +1,793 @@ +/* + * Copyright (C) 2014 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; + +import static android.app.admin.DevicePolicyManager.DEVICE_OWNER_TYPE_FINANCED; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_CA_CERT; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_NETWORK; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_TITLE; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MANAGEMENT_TWO_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MONITORING_CA_CERT_SUBTITLE; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MONITORING_NETWORK_SUBTITLE; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_MONITORING_VPN_SUBTITLE; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_NAMED_MANAGEMENT; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_PERSONAL_PROFILE_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_VIEW_POLICIES; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_WORK_PROFILE_CA_CERT; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_WORK_PROFILE_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_DIALOG_WORK_PROFILE_NETWORK; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT_MONITORING; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT_MULTIPLE_VPNS; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_MANAGEMENT_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT_MONITORING; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT_MULTIPLE_VPNS; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_MANAGEMENT_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_NAMED_WORK_PROFILE_MONITORING; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_PERSONAL_PROFILE_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_WORK_PROFILE_MONITORING; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_WORK_PROFILE_NAMED_VPN; +import static android.app.admin.DevicePolicyResources.Strings.SystemUi.QS_MSG_WORK_PROFILE_NETWORK; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.admin.DeviceAdminInfo; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.UserInfo; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.os.UserManager; +import android.provider.Settings; +import android.text.SpannableStringBuilder; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.Window; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.android.internal.jank.InteractionJankMonitor; +import com.android.systemui.R; +import com.android.systemui.animation.DialogCuj; +import com.android.systemui.animation.DialogLaunchAnimator; +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.dagger.qualifiers.Background; +import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.plugins.ActivityStarter; +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig; +import com.android.systemui.security.data.model.SecurityModel; +import com.android.systemui.settings.UserTracker; +import com.android.systemui.statusbar.phone.SystemUIDialog; +import com.android.systemui.statusbar.policy.SecurityController; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import javax.inject.Inject; + +/** Helper class for the configuration of the QS security footer button. */ +@SysUISingleton +public class QSSecurityFooterUtils implements DialogInterface.OnClickListener { + protected static final String TAG = "QSSecurityFooterUtils"; + protected static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + private static final boolean DEBUG_FORCE_VISIBLE = false; + + private static final String INTERACTION_JANK_TAG = "managed_device_info"; + + @Application private Context mContext; + private final DevicePolicyManager mDpm; + + private final SecurityController mSecurityController; + private final ActivityStarter mActivityStarter; + private final Handler mMainHandler; + private final UserTracker mUserTracker; + private final DialogLaunchAnimator mDialogLaunchAnimator; + + private final AtomicBoolean mShouldUseSettingsButton = new AtomicBoolean(false); + + protected Handler mBgHandler; + private AlertDialog mDialog; + + private Supplier mManagementTitleSupplier = () -> + mContext == null ? null : mContext.getString(R.string.monitoring_title_device_owned); + + private Supplier mManagementMessageSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.quick_settings_disclosure_management); + + private Supplier mManagementMonitoringStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.quick_settings_disclosure_management_monitoring); + + private Supplier mManagementMultipleVpnStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.quick_settings_disclosure_management_vpns); + + private Supplier mWorkProfileMonitoringStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.quick_settings_disclosure_managed_profile_monitoring); + + private Supplier mWorkProfileNetworkStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.quick_settings_disclosure_managed_profile_network_activity); + + private Supplier mMonitoringSubtitleCaCertStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_subtitle_ca_certificate); + + private Supplier mMonitoringSubtitleNetworkStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_subtitle_network_logging); + + private Supplier mMonitoringSubtitleVpnStringSupplier = () -> + mContext == null ? null : mContext.getString(R.string.monitoring_subtitle_vpn); + + private Supplier mViewPoliciesButtonStringSupplier = () -> + mContext == null ? null : mContext.getString(R.string.monitoring_button_view_policies); + + private Supplier mManagementDialogStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_description_management); + + private Supplier mManagementDialogCaCertStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_description_management_ca_certificate); + + private Supplier mWorkProfileDialogCaCertStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_description_managed_profile_ca_certificate); + + private Supplier mManagementDialogNetworkStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_description_management_network_logging); + + private Supplier mWorkProfileDialogNetworkStringSupplier = () -> + mContext == null ? null : mContext.getString( + R.string.monitoring_description_managed_profile_network_logging); + + @Inject + QSSecurityFooterUtils( + @Application Context context, DevicePolicyManager devicePolicyManager, + UserTracker userTracker, @Main Handler mainHandler, ActivityStarter activityStarter, + SecurityController securityController, @Background Looper bgLooper, + DialogLaunchAnimator dialogLaunchAnimator) { + mContext = context; + mDpm = devicePolicyManager; + mUserTracker = userTracker; + mMainHandler = mainHandler; + mActivityStarter = activityStarter; + mSecurityController = securityController; + mBgHandler = new Handler(bgLooper); + mDialogLaunchAnimator = dialogLaunchAnimator; + } + + /** Show the device monitoring dialog. */ + public void showDeviceMonitoringDialog(Context quickSettingsContext, @Nullable View view) { + createDialog(quickSettingsContext, view); + } + + /** + * Return the {@link SecurityButtonConfig} of the security button, or {@code null} if no + * security button should be shown. + */ + @Nullable + public SecurityButtonConfig getButtonConfig(SecurityModel securityModel) { + final boolean isDeviceManaged = securityModel.isDeviceManaged(); + final UserInfo currentUser = mUserTracker.getUserInfo(); + final boolean isDemoDevice = UserManager.isDeviceInDemoMode(mContext) && currentUser != null + && currentUser.isDemo(); + final boolean hasWorkProfile = securityModel.getHasWorkProfile(); + final boolean hasCACerts = securityModel.getHasCACertInCurrentUser(); + final boolean hasCACertsInWorkProfile = securityModel.getHasCACertInWorkProfile(); + final boolean isNetworkLoggingEnabled = securityModel.isNetworkLoggingEnabled(); + final String vpnName = securityModel.getPrimaryVpnName(); + final String vpnNameWorkProfile = securityModel.getWorkProfileVpnName(); + final CharSequence organizationName = securityModel.getDeviceOwnerOrganizationName(); + final CharSequence workProfileOrganizationName = + securityModel.getWorkProfileOrganizationName(); + final boolean isProfileOwnerOfOrganizationOwnedDevice = + securityModel.isProfileOwnerOfOrganizationOwnedDevice(); + final boolean isParentalControlsEnabled = securityModel.isParentalControlsEnabled(); + final boolean isWorkProfileOn = securityModel.isWorkProfileOn(); + final boolean hasDisclosableWorkProfilePolicy = hasCACertsInWorkProfile + || vpnNameWorkProfile != null || (hasWorkProfile && isNetworkLoggingEnabled); + // Update visibility of footer + boolean isVisible = (isDeviceManaged && !isDemoDevice) + || hasCACerts + || vpnName != null + || isProfileOwnerOfOrganizationOwnedDevice + || isParentalControlsEnabled + || (hasDisclosableWorkProfilePolicy && isWorkProfileOn); + if (!isVisible && !DEBUG_FORCE_VISIBLE) { + return null; + } + + // Update the view to be untappable if the device is an organization-owned device with a + // managed profile and there is either: + // a) no policy set which requires a privacy disclosure. + // b) a specific work policy set but the work profile is turned off. + boolean isClickable = !(isProfileOwnerOfOrganizationOwnedDevice + && (!hasDisclosableWorkProfilePolicy || !isWorkProfileOn)); + + String text = getFooterText(isDeviceManaged, hasWorkProfile, + hasCACerts, hasCACertsInWorkProfile, isNetworkLoggingEnabled, vpnName, + vpnNameWorkProfile, organizationName, workProfileOrganizationName, + isProfileOwnerOfOrganizationOwnedDevice, isParentalControlsEnabled, + isWorkProfileOn).toString(); + + Icon icon; + if (isParentalControlsEnabled) { + icon = new Icon.Loaded(securityModel.getDeviceAdminIcon()); + } else if (vpnName != null || vpnNameWorkProfile != null) { + if (securityModel.isVpnBranded()) { + icon = new Icon.Resource(R.drawable.stat_sys_branded_vpn); + } else { + icon = new Icon.Resource(R.drawable.stat_sys_vpn_ic); + } + } else { + icon = new Icon.Resource(R.drawable.ic_info_outline); + } + + return new SecurityButtonConfig(icon, text, isClickable); + } + + @Nullable + protected CharSequence getFooterText(boolean isDeviceManaged, boolean hasWorkProfile, + boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled, + String vpnName, String vpnNameWorkProfile, CharSequence organizationName, + CharSequence workProfileOrganizationName, + boolean isProfileOwnerOfOrganizationOwnedDevice, boolean isParentalControlsEnabled, + boolean isWorkProfileOn) { + if (isParentalControlsEnabled) { + return mContext.getString(R.string.quick_settings_disclosure_parental_controls); + } + if (isDeviceManaged || DEBUG_FORCE_VISIBLE) { + return getManagedDeviceFooterText(hasCACerts, hasCACertsInWorkProfile, + isNetworkLoggingEnabled, vpnName, vpnNameWorkProfile, organizationName); + } + return getManagedAndPersonalProfileFooterText(hasWorkProfile, hasCACerts, + hasCACertsInWorkProfile, isNetworkLoggingEnabled, vpnName, vpnNameWorkProfile, + workProfileOrganizationName, isProfileOwnerOfOrganizationOwnedDevice, + isWorkProfileOn); + } + + private String getManagedDeviceFooterText( + boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled, + String vpnName, String vpnNameWorkProfile, CharSequence organizationName) { + if (hasCACerts || hasCACertsInWorkProfile || isNetworkLoggingEnabled) { + return getManagedDeviceMonitoringText(organizationName); + } + if (vpnName != null || vpnNameWorkProfile != null) { + return getManagedDeviceVpnText(vpnName, vpnNameWorkProfile, organizationName); + } + return getMangedDeviceGeneralText(organizationName); + } + + private String getManagedDeviceMonitoringText(CharSequence organizationName) { + if (organizationName == null) { + return mDpm.getResources().getString( + QS_MSG_MANAGEMENT_MONITORING, mManagementMonitoringStringSupplier); + } + return mDpm.getResources().getString( + QS_MSG_NAMED_MANAGEMENT_MONITORING, + () -> mContext.getString( + R.string.quick_settings_disclosure_named_management_monitoring, + organizationName), + organizationName); + } + + private String getManagedDeviceVpnText( + String vpnName, String vpnNameWorkProfile, CharSequence organizationName) { + if (vpnName != null && vpnNameWorkProfile != null) { + if (organizationName == null) { + return mDpm.getResources().getString( + QS_MSG_MANAGEMENT_MULTIPLE_VPNS, mManagementMultipleVpnStringSupplier); + } + return mDpm.getResources().getString( + QS_MSG_NAMED_MANAGEMENT_MULTIPLE_VPNS, + () -> mContext.getString( + R.string.quick_settings_disclosure_named_management_vpns, + organizationName), + organizationName); + } + String name = vpnName != null ? vpnName : vpnNameWorkProfile; + if (organizationName == null) { + return mDpm.getResources().getString( + QS_MSG_MANAGEMENT_NAMED_VPN, + () -> mContext.getString( + R.string.quick_settings_disclosure_management_named_vpn, + name), + name); + } + return mDpm.getResources().getString( + QS_MSG_NAMED_MANAGEMENT_NAMED_VPN, + () -> mContext.getString( + R.string.quick_settings_disclosure_named_management_named_vpn, + organizationName, + name), + organizationName, + name); + } + + private String getMangedDeviceGeneralText(CharSequence organizationName) { + if (organizationName == null) { + return mDpm.getResources().getString(QS_MSG_MANAGEMENT, mManagementMessageSupplier); + } + if (isFinancedDevice()) { + return mContext.getString( + R.string.quick_settings_financed_disclosure_named_management, + organizationName); + } else { + return mDpm.getResources().getString( + QS_MSG_NAMED_MANAGEMENT, + () -> mContext.getString( + R.string.quick_settings_disclosure_named_management, + organizationName), + organizationName); + } + } + + private String getManagedAndPersonalProfileFooterText(boolean hasWorkProfile, + boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled, + String vpnName, String vpnNameWorkProfile, CharSequence workProfileOrganizationName, + boolean isProfileOwnerOfOrganizationOwnedDevice, boolean isWorkProfileOn) { + if (hasCACerts || (hasCACertsInWorkProfile && isWorkProfileOn)) { + return getMonitoringText( + hasCACerts, hasCACertsInWorkProfile, workProfileOrganizationName, + isWorkProfileOn); + } + if (vpnName != null || (vpnNameWorkProfile != null && isWorkProfileOn)) { + return getVpnText(hasWorkProfile, vpnName, vpnNameWorkProfile, isWorkProfileOn); + } + if (hasWorkProfile && isNetworkLoggingEnabled && isWorkProfileOn) { + return getManagedProfileNetworkActivityText(); + } + if (isProfileOwnerOfOrganizationOwnedDevice) { + return getMangedDeviceGeneralText(workProfileOrganizationName); + } + return null; + } + + private String getMonitoringText(boolean hasCACerts, boolean hasCACertsInWorkProfile, + CharSequence workProfileOrganizationName, boolean isWorkProfileOn) { + if (hasCACertsInWorkProfile && isWorkProfileOn) { + if (workProfileOrganizationName == null) { + return mDpm.getResources().getString( + QS_MSG_WORK_PROFILE_MONITORING, mWorkProfileMonitoringStringSupplier); + } + return mDpm.getResources().getString( + QS_MSG_NAMED_WORK_PROFILE_MONITORING, + () -> mContext.getString( + R.string.quick_settings_disclosure_named_managed_profile_monitoring, + workProfileOrganizationName), + workProfileOrganizationName); + } + if (hasCACerts) { + return mContext.getString(R.string.quick_settings_disclosure_monitoring); + } + return null; + } + + private String getVpnText(boolean hasWorkProfile, String vpnName, String vpnNameWorkProfile, + boolean isWorkProfileOn) { + if (vpnName != null && vpnNameWorkProfile != null) { + return mContext.getString(R.string.quick_settings_disclosure_vpns); + } + if (vpnNameWorkProfile != null && isWorkProfileOn) { + return mDpm.getResources().getString( + QS_MSG_WORK_PROFILE_NAMED_VPN, + () -> mContext.getString( + R.string.quick_settings_disclosure_managed_profile_named_vpn, + vpnNameWorkProfile), + vpnNameWorkProfile); + } + if (vpnName != null) { + if (hasWorkProfile) { + return mDpm.getResources().getString( + QS_MSG_PERSONAL_PROFILE_NAMED_VPN, + () -> mContext.getString( + R.string.quick_settings_disclosure_personal_profile_named_vpn, + vpnName), + vpnName); + } + return mContext.getString(R.string.quick_settings_disclosure_named_vpn, + vpnName); + } + return null; + } + + private String getManagedProfileNetworkActivityText() { + return mDpm.getResources().getString( + QS_MSG_WORK_PROFILE_NETWORK, mWorkProfileNetworkStringSupplier); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_NEGATIVE) { + final Intent intent = new Intent(Settings.ACTION_ENTERPRISE_PRIVACY_SETTINGS); + dialog.dismiss(); + // This dismisses the shade on opening the activity + mActivityStarter.postStartActivityDismissingKeyguard(intent, 0); + } + } + + private void createDialog(Context quickSettingsContext, @Nullable View view) { + mShouldUseSettingsButton.set(false); + mBgHandler.post(() -> { + String settingsButtonText = getSettingsButton(); + final View dialogView = createDialogView(); + mMainHandler.post(() -> { + mDialog = new SystemUIDialog(quickSettingsContext, 0); + mDialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + mDialog.setButton(DialogInterface.BUTTON_POSITIVE, getPositiveButton(), this); + mDialog.setButton(DialogInterface.BUTTON_NEGATIVE, mShouldUseSettingsButton.get() + ? settingsButtonText : getNegativeButton(), this); + + mDialog.setView(dialogView); + if (view != null && view.isAggregatedVisible()) { + mDialogLaunchAnimator.showFromView(mDialog, view, new DialogCuj( + InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN, INTERACTION_JANK_TAG)); + } else { + mDialog.show(); + } + }); + }); + } + + @VisibleForTesting + Dialog getDialog() { + return mDialog; + } + + @VisibleForTesting + View createDialogView() { + if (mSecurityController.isParentalControlsEnabled()) { + return createParentalControlsDialogView(); + } + return createOrganizationDialogView(); + } + + private View createOrganizationDialogView() { + final boolean isDeviceManaged = mSecurityController.isDeviceManaged(); + final boolean hasWorkProfile = mSecurityController.hasWorkProfile(); + final CharSequence deviceOwnerOrganization = + mSecurityController.getDeviceOwnerOrganizationName(); + final boolean hasCACerts = mSecurityController.hasCACertInCurrentUser(); + final boolean hasCACertsInWorkProfile = mSecurityController.hasCACertInWorkProfile(); + final boolean isNetworkLoggingEnabled = mSecurityController.isNetworkLoggingEnabled(); + final String vpnName = mSecurityController.getPrimaryVpnName(); + final String vpnNameWorkProfile = mSecurityController.getWorkProfileVpnName(); + + View dialogView = LayoutInflater.from(mContext) + .inflate(R.layout.quick_settings_footer_dialog, null, false); + + // device management section + TextView deviceManagementSubtitle = + dialogView.findViewById(R.id.device_management_subtitle); + deviceManagementSubtitle.setText(getManagementTitle(deviceOwnerOrganization)); + + CharSequence managementMessage = getManagementMessage(isDeviceManaged, + deviceOwnerOrganization); + if (managementMessage == null) { + dialogView.findViewById(R.id.device_management_disclosures).setVisibility(View.GONE); + } else { + dialogView.findViewById(R.id.device_management_disclosures).setVisibility(View.VISIBLE); + TextView deviceManagementWarning = + (TextView) dialogView.findViewById(R.id.device_management_warning); + deviceManagementWarning.setText(managementMessage); + mShouldUseSettingsButton.set(true); + } + + // ca certificate section + CharSequence caCertsMessage = getCaCertsMessage(isDeviceManaged, hasCACerts, + hasCACertsInWorkProfile); + if (caCertsMessage == null) { + dialogView.findViewById(R.id.ca_certs_disclosures).setVisibility(View.GONE); + } else { + dialogView.findViewById(R.id.ca_certs_disclosures).setVisibility(View.VISIBLE); + TextView caCertsWarning = (TextView) dialogView.findViewById(R.id.ca_certs_warning); + caCertsWarning.setText(caCertsMessage); + // Make "Open trusted credentials"-link clickable + caCertsWarning.setMovementMethod(new LinkMovementMethod()); + + TextView caCertsSubtitle = (TextView) dialogView.findViewById(R.id.ca_certs_subtitle); + String caCertsSubtitleMessage = mDpm.getResources().getString( + QS_DIALOG_MONITORING_CA_CERT_SUBTITLE, mMonitoringSubtitleCaCertStringSupplier); + caCertsSubtitle.setText(caCertsSubtitleMessage); + + } + + // network logging section + CharSequence networkLoggingMessage = getNetworkLoggingMessage(isDeviceManaged, + isNetworkLoggingEnabled); + if (networkLoggingMessage == null) { + dialogView.findViewById(R.id.network_logging_disclosures).setVisibility(View.GONE); + } else { + dialogView.findViewById(R.id.network_logging_disclosures).setVisibility(View.VISIBLE); + TextView networkLoggingWarning = + (TextView) dialogView.findViewById(R.id.network_logging_warning); + networkLoggingWarning.setText(networkLoggingMessage); + + TextView networkLoggingSubtitle = (TextView) dialogView.findViewById( + R.id.network_logging_subtitle); + String networkLoggingSubtitleMessage = mDpm.getResources().getString( + QS_DIALOG_MONITORING_NETWORK_SUBTITLE, + mMonitoringSubtitleNetworkStringSupplier); + networkLoggingSubtitle.setText(networkLoggingSubtitleMessage); + } + + // vpn section + CharSequence vpnMessage = getVpnMessage(isDeviceManaged, hasWorkProfile, vpnName, + vpnNameWorkProfile); + if (vpnMessage == null) { + dialogView.findViewById(R.id.vpn_disclosures).setVisibility(View.GONE); + } else { + dialogView.findViewById(R.id.vpn_disclosures).setVisibility(View.VISIBLE); + TextView vpnWarning = (TextView) dialogView.findViewById(R.id.vpn_warning); + vpnWarning.setText(vpnMessage); + // Make "Open VPN Settings"-link clickable + vpnWarning.setMovementMethod(new LinkMovementMethod()); + + TextView vpnSubtitle = (TextView) dialogView.findViewById(R.id.vpn_subtitle); + String vpnSubtitleMessage = mDpm.getResources().getString( + QS_DIALOG_MONITORING_VPN_SUBTITLE, mMonitoringSubtitleVpnStringSupplier); + vpnSubtitle.setText(vpnSubtitleMessage); + } + + // Note: if a new section is added, should update configSubtitleVisibility to include + // the handling of the subtitle + configSubtitleVisibility(managementMessage != null, + caCertsMessage != null, + networkLoggingMessage != null, + vpnMessage != null, + dialogView); + + return dialogView; + } + + private View createParentalControlsDialogView() { + View dialogView = LayoutInflater.from(mContext) + .inflate(R.layout.quick_settings_footer_dialog_parental_controls, null, false); + + DeviceAdminInfo info = mSecurityController.getDeviceAdminInfo(); + Drawable icon = mSecurityController.getIcon(info); + if (icon != null) { + ImageView imageView = (ImageView) dialogView.findViewById(R.id.parental_controls_icon); + imageView.setImageDrawable(icon); + } + + TextView parentalControlsTitle = + (TextView) dialogView.findViewById(R.id.parental_controls_title); + parentalControlsTitle.setText(mSecurityController.getLabel(info)); + + return dialogView; + } + + protected void configSubtitleVisibility(boolean showDeviceManagement, boolean showCaCerts, + boolean showNetworkLogging, boolean showVpn, View dialogView) { + // Device Management title should always been shown + // When there is a Device Management message, all subtitles should be shown + if (showDeviceManagement) { + return; + } + // Hide the subtitle if there is only 1 message shown + int mSectionCountExcludingDeviceMgt = 0; + if (showCaCerts) { + mSectionCountExcludingDeviceMgt++; + } + if (showNetworkLogging) { + mSectionCountExcludingDeviceMgt++; + } + if (showVpn) { + mSectionCountExcludingDeviceMgt++; + } + + // No work needed if there is no sections or more than 1 section + if (mSectionCountExcludingDeviceMgt != 1) { + return; + } + if (showCaCerts) { + dialogView.findViewById(R.id.ca_certs_subtitle).setVisibility(View.GONE); + } + if (showNetworkLogging) { + dialogView.findViewById(R.id.network_logging_subtitle).setVisibility(View.GONE); + } + if (showVpn) { + dialogView.findViewById(R.id.vpn_subtitle).setVisibility(View.GONE); + } + } + + // This should not be called on the main thread to avoid making an IPC. + @VisibleForTesting + String getSettingsButton() { + return mDpm.getResources().getString( + QS_DIALOG_VIEW_POLICIES, mViewPoliciesButtonStringSupplier); + } + + private String getPositiveButton() { + return mContext.getString(R.string.ok); + } + + @Nullable + private String getNegativeButton() { + if (mSecurityController.isParentalControlsEnabled()) { + return mContext.getString(R.string.monitoring_button_view_controls); + } + return null; + } + + @Nullable + protected CharSequence getManagementMessage(boolean isDeviceManaged, + CharSequence organizationName) { + if (!isDeviceManaged) { + return null; + } + if (organizationName != null) { + if (isFinancedDevice()) { + return mContext.getString(R.string.monitoring_financed_description_named_management, + organizationName, organizationName); + } else { + return mDpm.getResources().getString( + QS_DIALOG_NAMED_MANAGEMENT, + () -> mContext.getString( + R.string.monitoring_description_named_management, + organizationName), + organizationName); + } + } + return mDpm.getResources().getString(QS_DIALOG_MANAGEMENT, mManagementDialogStringSupplier); + } + + @Nullable + protected CharSequence getCaCertsMessage(boolean isDeviceManaged, boolean hasCACerts, + boolean hasCACertsInWorkProfile) { + if (!(hasCACerts || hasCACertsInWorkProfile)) return null; + if (isDeviceManaged) { + return mDpm.getResources().getString( + QS_DIALOG_MANAGEMENT_CA_CERT, mManagementDialogCaCertStringSupplier); + } + if (hasCACertsInWorkProfile) { + return mDpm.getResources().getString( + QS_DIALOG_WORK_PROFILE_CA_CERT, mWorkProfileDialogCaCertStringSupplier); + } + return mContext.getString(R.string.monitoring_description_ca_certificate); + } + + @Nullable + protected CharSequence getNetworkLoggingMessage(boolean isDeviceManaged, + boolean isNetworkLoggingEnabled) { + if (!isNetworkLoggingEnabled) return null; + if (isDeviceManaged) { + return mDpm.getResources().getString( + QS_DIALOG_MANAGEMENT_NETWORK, mManagementDialogNetworkStringSupplier); + } else { + return mDpm.getResources().getString( + QS_DIALOG_WORK_PROFILE_NETWORK, mWorkProfileDialogNetworkStringSupplier); + } + } + + @Nullable + protected CharSequence getVpnMessage(boolean isDeviceManaged, boolean hasWorkProfile, + String vpnName, String vpnNameWorkProfile) { + if (vpnName == null && vpnNameWorkProfile == null) return null; + final SpannableStringBuilder message = new SpannableStringBuilder(); + if (isDeviceManaged) { + if (vpnName != null && vpnNameWorkProfile != null) { + String namedVpns = mDpm.getResources().getString( + QS_DIALOG_MANAGEMENT_TWO_NAMED_VPN, + () -> mContext.getString( + R.string.monitoring_description_two_named_vpns, + vpnName, vpnNameWorkProfile), + vpnName, vpnNameWorkProfile); + message.append(namedVpns); + } else { + String name = vpnName != null ? vpnName : vpnNameWorkProfile; + String namedVp = mDpm.getResources().getString( + QS_DIALOG_MANAGEMENT_NAMED_VPN, + () -> mContext.getString(R.string.monitoring_description_named_vpn, name), + name); + message.append(namedVp); + } + } else { + if (vpnName != null && vpnNameWorkProfile != null) { + String namedVpns = mDpm.getResources().getString( + QS_DIALOG_MANAGEMENT_TWO_NAMED_VPN, + () -> mContext.getString( + R.string.monitoring_description_two_named_vpns, + vpnName, vpnNameWorkProfile), + vpnName, vpnNameWorkProfile); + message.append(namedVpns); + } else if (vpnNameWorkProfile != null) { + String namedVpn = mDpm.getResources().getString( + QS_DIALOG_WORK_PROFILE_NAMED_VPN, + () -> mContext.getString( + R.string.monitoring_description_managed_profile_named_vpn, + vpnNameWorkProfile), + vpnNameWorkProfile); + message.append(namedVpn); + } else if (hasWorkProfile) { + String namedVpn = mDpm.getResources().getString( + QS_DIALOG_PERSONAL_PROFILE_NAMED_VPN, + () -> mContext.getString( + R.string.monitoring_description_personal_profile_named_vpn, + vpnName), + vpnName); + message.append(namedVpn); + } else { + message.append(mContext.getString(R.string.monitoring_description_named_vpn, + vpnName)); + } + } + message.append(mContext.getString(R.string.monitoring_description_vpn_settings_separator)); + message.append(mContext.getString(R.string.monitoring_description_vpn_settings), + new VpnSpan(), 0); + return message; + } + + @VisibleForTesting + CharSequence getManagementTitle(CharSequence deviceOwnerOrganization) { + if (deviceOwnerOrganization != null && isFinancedDevice()) { + return mContext.getString(R.string.monitoring_title_financed_device, + deviceOwnerOrganization); + } else { + return mDpm.getResources().getString( + QS_DIALOG_MANAGEMENT_TITLE, + mManagementTitleSupplier); + } + } + + private boolean isFinancedDevice() { + return mSecurityController.isDeviceManaged() + && mSecurityController.getDeviceOwnerType( + mSecurityController.getDeviceOwnerComponentOnAnyUser()) + == DEVICE_OWNER_TYPE_FINANCED; + } + + protected class VpnSpan extends ClickableSpan { + @Override + public void onClick(View widget) { + final Intent intent = new Intent(Settings.ACTION_VPN_SETTINGS); + mDialog.dismiss(); + // This dismisses the shade on opening the activity + mActivityStarter.postStartActivityDismissingKeyguard(intent, 0); + } + + // for testing, to compare two CharSequences containing VpnSpans + @Override + public boolean equals(Object object) { + return object instanceof VpnSpan; + } + + @Override + public int hashCode() { + return 314159257; // prime + } + } +} 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..38fe34eb8f9f5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt @@ -0,0 +1,39 @@ +/* + * 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.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 +} 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/domain/model/SecurityButtonConfig.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/domain/model/SecurityButtonConfig.kt new file mode 100644 index 0000000000000..be9c0c1de799c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/domain/model/SecurityButtonConfig.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.qs.footer.domain.model + +import com.android.systemui.common.shared.model.Icon + +/** The config for the security button. */ +data class SecurityButtonConfig( + val icon: Icon, + val text: String, + val isClickable: Boolean, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt new file mode 100644 index 0000000000000..8dd506ec87754 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt @@ -0,0 +1,321 @@ +/* + * 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.binder + +import android.content.Context +import android.graphics.PorterDuff +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.systemui.R +import com.android.systemui.common.ui.binder.ContentDescriptionViewBinder +import com.android.systemui.common.ui.binder.IconViewBinder +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.people.ui.view.PeopleViewBinder.bind +import com.android.systemui.qs.FooterActionsView +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsButtonViewModel +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsForegroundServicesButtonViewModel +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +/** A ViewBinder for [FooterActionsViewBinder]. */ +object FooterActionsViewBinder { + /** + * Create a [FooterActionsView] that can later be [bound][bind] to a [FooterActionsViewModel]. + */ + @JvmStatic + fun create(context: Context): FooterActionsView { + return LayoutInflater.from(context).inflate(R.layout.footer_actions, /* root= */ null) + as FooterActionsView + } + + /** Bind [view] to [viewModel]. */ + @JvmStatic + fun bind( + view: FooterActionsView, + viewModel: FooterActionsViewModel, + qsVisibilityLifecycleOwner: LifecycleOwner, + ) { + // Remove all children of the FooterActionsView that are used by the old implementation. + // TODO(b/242040009): Clean up the XML once the old implementation is removed. + view.removeAllViews() + + // Add the views used by this new implementation. + val context = view.context + val inflater = LayoutInflater.from(context) + + val securityHolder = TextButtonViewHolder.createAndAdd(inflater, view) + val foregroundServicesWithTextHolder = TextButtonViewHolder.createAndAdd(inflater, view) + val foregroundServicesWithNumberHolder = NumberButtonViewHolder.createAndAdd(inflater, view) + val userSwitcherHolder = IconButtonViewHolder.createAndAdd(inflater, view, isLast = false) + val settingsHolder = + IconButtonViewHolder.createAndAdd(inflater, view, isLast = viewModel.power == null) + + // Bind the static power and settings buttons. + bindButton(settingsHolder, viewModel.settings) + + if (viewModel.power != null) { + val powerHolder = IconButtonViewHolder.createAndAdd(inflater, view, isLast = true) + bindButton(powerHolder, viewModel.power) + } + + // There are 2 lifecycle scopes we are using here: + // 1) The scope created by [repeatWhenAttached] when [view] is attached, and destroyed + // when the [view] is detached. We use this as the parent scope for all our [viewModel] + // state collection, given that we don't want to do any work when [view] is detached. + // 2) The scope owned by [lifecycleOwner], which should be RESUMED only when Quick + // Settings are visible. We use this to make sure we collect UI state only when the + // View is visible. + // + // Given that we start our collection when the Quick Settings become visible, which happens + // every time the user swipes down the shade, we remember our previous UI state already + // bound to the UI to avoid binding the same values over and over for nothing. + + // TODO(b/242040009): Look into using only a single scope. + + var previousSecurity: FooterActionsSecurityButtonViewModel? = null + var previousForegroundServices: FooterActionsForegroundServicesButtonViewModel? = null + var previousUserSwitcher: FooterActionsButtonViewModel? = null + + view.repeatWhenAttached { + val attachedScope = this.lifecycleScope + + attachedScope.launch { + // Listen for dialog requests as soon as we are attached, even when not visible. + // TODO(b/242040009): Should this move somewhere else? + launch { viewModel.observeDeviceMonitoringDialogRequests(view.context) } + + // Make sure we set the correct visibility and alpha even when QS are not currently + // shown. + launch { + viewModel.isVisible.collect { isVisible -> view.isInvisible = !isVisible } + } + + launch { viewModel.alpha.collect { view.alpha = it } } + launch { viewModel.backgroundAlpha.collect { view.backgroundAlpha = it } } + } + + // Listen for model changes only when QS are visible. + qsVisibilityLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + // Security. + launch { + viewModel.security.collect { security -> + if (previousSecurity != security) { + bindSecurity(securityHolder, security) + previousSecurity = security + } + } + } + + // Foreground services. + launch { + viewModel.foregroundServices.collect { foregroundServices -> + if (previousForegroundServices != foregroundServices) { + bindForegroundService( + foregroundServicesWithNumberHolder, + foregroundServicesWithTextHolder, + foregroundServices, + ) + previousForegroundServices = foregroundServices + } + } + } + + // User switcher. + launch { + viewModel.userSwitcher.collect { userSwitcher -> + if (previousUserSwitcher != userSwitcher) { + bindButton(userSwitcherHolder, userSwitcher) + previousUserSwitcher = userSwitcher + } + } + } + } + } + } + + private fun bindSecurity( + securityHolder: TextButtonViewHolder, + security: FooterActionsSecurityButtonViewModel?, + ) { + val securityView = securityHolder.view + securityView.isVisible = security != null + if (security == null) { + return + } + + // Make sure that the chevron is visible and that the button is clickable if there is a + // listener. + val chevron = securityHolder.chevron + if (security.onClick != null) { + securityView.isClickable = true + securityView.setOnClickListener(security.onClick) + chevron.isVisible = true + } else { + securityView.isClickable = false + securityView.setOnClickListener(null) + chevron.isVisible = false + } + + securityHolder.text.text = security.text + securityHolder.newDot.isVisible = false + IconViewBinder.bind(security.icon, securityHolder.icon) + } + + private fun bindForegroundService( + foregroundServicesWithNumberHolder: NumberButtonViewHolder, + foregroundServicesWithTextHolder: TextButtonViewHolder, + foregroundServices: FooterActionsForegroundServicesButtonViewModel?, + ) { + val foregroundServicesWithNumberView = foregroundServicesWithNumberHolder.view + val foregroundServicesWithTextView = foregroundServicesWithTextHolder.view + if (foregroundServices == null) { + foregroundServicesWithNumberView.isVisible = false + foregroundServicesWithTextView.isVisible = false + return + } + + val foregroundServicesCount = foregroundServices.foregroundServicesCount + if (foregroundServices.displayText) { + // Button with text, icon and chevron. + foregroundServicesWithNumberView.isVisible = false + + foregroundServicesWithTextView.isVisible = true + foregroundServicesWithTextView.setOnClickListener(foregroundServices.onClick) + foregroundServicesWithTextHolder.text.text = foregroundServices.text + foregroundServicesWithTextHolder.newDot.isVisible = foregroundServices.hasNewChanges + } else { + // Small button with the number only. + foregroundServicesWithTextView.isVisible = false + + foregroundServicesWithNumberView.visibility = View.VISIBLE + foregroundServicesWithNumberView.setOnClickListener(foregroundServices.onClick) + foregroundServicesWithNumberHolder.number.text = foregroundServicesCount.toString() + foregroundServicesWithNumberHolder.number.contentDescription = foregroundServices.text + foregroundServicesWithNumberHolder.newDot.isVisible = foregroundServices.hasNewChanges + } + } + + private fun bindButton(button: IconButtonViewHolder, model: FooterActionsButtonViewModel?) { + val buttonView = button.view + buttonView.isVisible = model != null + if (model == null) { + return + } + + buttonView.setBackgroundResource(model.background) + buttonView.setOnClickListener(model.onClick) + + val icon = model.icon + val iconView = button.icon + val contentDescription = model.contentDescription + + IconViewBinder.bind(icon, iconView) + ContentDescriptionViewBinder.bind(contentDescription, iconView) + if (model.iconTint != null) { + iconView.setColorFilter(model.iconTint, PorterDuff.Mode.SRC_IN) + } else { + iconView.clearColorFilter() + } + } +} + +private class TextButtonViewHolder(val view: View) { + val icon = view.requireViewById(R.id.icon) + val text = view.requireViewById(R.id.text) + val newDot = view.requireViewById(R.id.new_dot) + val chevron = view.requireViewById(R.id.chevron_icon) + + companion object { + fun createAndAdd(inflater: LayoutInflater, root: ViewGroup): TextButtonViewHolder { + val view = + inflater.inflate( + R.layout.footer_actions_text_button, + /* root= */ root, + /* attachToRoot= */ false, + ) + root.addView(view) + return TextButtonViewHolder(view) + } + } +} + +private class NumberButtonViewHolder(val view: View) { + val number = view.requireViewById(R.id.number) + val newDot = view.requireViewById(R.id.new_dot) + + companion object { + fun createAndAdd(inflater: LayoutInflater, root: ViewGroup): NumberButtonViewHolder { + val view = + inflater.inflate( + R.layout.footer_actions_number_button, + /* root= */ root, + /* attachToRoot= */ false, + ) + root.addView(view) + return NumberButtonViewHolder(view) + } + } +} + +private class IconButtonViewHolder(val view: View) { + val icon = view.requireViewById(R.id.icon) + + companion object { + fun createAndAdd( + inflater: LayoutInflater, + root: ViewGroup, + isLast: Boolean, + ): IconButtonViewHolder { + val view = + inflater.inflate( + R.layout.footer_actions_icon_button, + /* root= */ root, + /* attachToRoot= */ false, + ) + + // All buttons have a background with an inset of qs_footer_action_inset, so the last + // button must have a negative inset of -qs_footer_action_inset to compensate and be + // aligned with its parent. + val marginEnd = + if (isLast) { + -view.context.resources.getDimensionPixelSize(R.dimen.qs_footer_action_inset) + } else { + 0 + } + + val size = + view.context.resources.getDimensionPixelSize(R.dimen.qs_footer_action_button_size) + root.addView( + view, + LinearLayout.LayoutParams(size, size).apply { this.marginEnd = marginEnd }, + ) + return IconButtonViewHolder(view) + } + } +} 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..b556a3e0d66b6 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt @@ -0,0 +1,310 @@ +/* + * 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.distinctUntilChanged +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, + ) + } + .distinctUntilChanged() + + /** 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, + ) + } + .distinctUntilChanged() + + /** 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) + } + } + } + .distinctUntilChanged() + + /** 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/qs/tileimpl/QSTileViewImpl.kt b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt index 5147d59340394..2731d64ee4e70 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt @@ -44,6 +44,7 @@ import com.android.settingslib.Utils import com.android.systemui.FontSizeUtils import com.android.systemui.R import com.android.systemui.animation.LaunchableView +import com.android.systemui.animation.LaunchableViewDelegate import com.android.systemui.plugins.qs.QSIconView import com.android.systemui.plugins.qs.QSTile import com.android.systemui.plugins.qs.QSTile.BooleanState @@ -138,8 +139,11 @@ open class QSTileViewImpl @JvmOverloads constructor( private var lastStateDescription: CharSequence? = null private var tileState = false private var lastState = INVALID - private var blockVisibilityChanges = false - private var lastVisibility = View.VISIBLE + private val launchableViewDelegate = LaunchableViewDelegate( + this, + superSetVisibility = { super.setVisibility(it) }, + superSetTransitionVisibility = { super.setTransitionVisibility(it) }, + ) private val locInScreen = IntArray(2) @@ -343,33 +347,15 @@ open class QSTileViewImpl @JvmOverloads constructor( } override fun setShouldBlockVisibilityChanges(block: Boolean) { - blockVisibilityChanges = block - - if (block) { - lastVisibility = visibility - } else { - visibility = lastVisibility - } + launchableViewDelegate.setShouldBlockVisibilityChanges(block) } override fun setVisibility(visibility: Int) { - if (blockVisibilityChanges) { - lastVisibility = visibility - return - } - - super.setVisibility(visibility) + launchableViewDelegate.setVisibility(visibility) } override fun setTransitionVisibility(visibility: Int) { - if (blockVisibilityChanges) { - // View.setTransitionVisibility just sets the visibility flag, so we don't have to save - // the transition visibility separately from the normal visibility. - lastVisibility = visibility - return - } - - super.setTransitionVisibility(visibility) + launchableViewDelegate.setTransitionVisibility(visibility) } // Accessibility diff --git a/packages/SystemUI/src/com/android/systemui/security/data/model/SecurityModel.kt b/packages/SystemUI/src/com/android/systemui/security/data/model/SecurityModel.kt new file mode 100644 index 0000000000000..50af260684f8c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/security/data/model/SecurityModel.kt @@ -0,0 +1,89 @@ +/* + * 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.model + +import android.graphics.drawable.Drawable +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.statusbar.policy.SecurityController +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** The security info exposed by [com.android.systemui.statusbar.policy.SecurityController]. */ +// TODO(b/242040009): Consider splitting this model into smaller submodels. +data class SecurityModel( + val isDeviceManaged: Boolean, + val hasWorkProfile: Boolean, + val isWorkProfileOn: Boolean, + val isProfileOwnerOfOrganizationOwnedDevice: Boolean, + val deviceOwnerOrganizationName: String?, + val workProfileOrganizationName: String?, + val isNetworkLoggingEnabled: Boolean, + val isVpnBranded: Boolean, + val primaryVpnName: String?, + val workProfileVpnName: String?, + val hasCACertInCurrentUser: Boolean, + val hasCACertInWorkProfile: Boolean, + val isParentalControlsEnabled: Boolean, + val deviceAdminIcon: Drawable?, +) { + companion object { + /** Create a [SecurityModel] from the current [securityController] state. */ + suspend fun create( + securityController: SecurityController, + @Background bgDispatcher: CoroutineDispatcher, + ): SecurityModel { + return withContext(bgDispatcher) { create(securityController) } + } + + /** + * Create a [SecurityModel] from the current [securityController] state. + * + * Important: This method should be called from a background thread as this will do a lot of + * binder calls. + */ + // TODO(b/242040009): Remove this. + @JvmStatic + fun create(securityController: SecurityController): SecurityModel { + val deviceAdminInfo = + if (securityController.isParentalControlsEnabled) { + securityController.deviceAdminInfo + } else { + null + } + + return SecurityModel( + isDeviceManaged = securityController.isDeviceManaged, + hasWorkProfile = securityController.hasWorkProfile(), + isWorkProfileOn = securityController.isWorkProfileOn, + isProfileOwnerOfOrganizationOwnedDevice = + securityController.isProfileOwnerOfOrganizationOwnedDevice, + deviceOwnerOrganizationName = + securityController.deviceOwnerOrganizationName?.toString(), + workProfileOrganizationName = + securityController.workProfileOrganizationName?.toString(), + isNetworkLoggingEnabled = securityController.isNetworkLoggingEnabled, + isVpnBranded = securityController.isVpnBranded, + primaryVpnName = securityController.primaryVpnName, + workProfileVpnName = securityController.workProfileVpnName, + hasCACertInCurrentUser = securityController.hasCACertInCurrentUser(), + hasCACertInWorkProfile = securityController.hasCACertInWorkProfile(), + isParentalControlsEnabled = securityController.isParentalControlsEnabled, + deviceAdminIcon = securityController.getIcon(deviceAdminInfo), + ) + } + } +} 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/src/com/android/systemui/statusbar/AlphaOptimizedFrameLayout.java b/packages/SystemUI/src/com/android/systemui/statusbar/AlphaOptimizedFrameLayout.java index 359272e8a7e03..662f70ef269ea 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/AlphaOptimizedFrameLayout.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/AlphaOptimizedFrameLayout.java @@ -20,12 +20,28 @@ import android.content.Context; import android.util.AttributeSet; import android.widget.FrameLayout; +import com.android.systemui.animation.LaunchableView; +import com.android.systemui.animation.LaunchableViewDelegate; + +import kotlin.Unit; + /** * A frame layout which does not have overlapping renderings commands and therefore does not need a * layer when alpha is changed. */ -public class AlphaOptimizedFrameLayout extends FrameLayout +public class AlphaOptimizedFrameLayout extends FrameLayout implements LaunchableView { + private final LaunchableViewDelegate mLaunchableViewDelegate = new LaunchableViewDelegate( + this, + visibility -> { + super.setVisibility(visibility); + return Unit.INSTANCE; + }, + visibility -> { + super.setTransitionVisibility(visibility); + return Unit.INSTANCE; + }); + public AlphaOptimizedFrameLayout(Context context) { super(context); } @@ -47,4 +63,19 @@ public class AlphaOptimizedFrameLayout extends FrameLayout public boolean hasOverlappingRendering() { return false; } + + @Override + public void setShouldBlockVisibilityChanges(boolean block) { + mLaunchableViewDelegate.setShouldBlockVisibilityChanges(block); + } + + @Override + public void setVisibility(int visibility) { + mLaunchableViewDelegate.setVisibility(visibility); + } + + @Override + public void setTransitionVisibility(int visibility) { + mLaunchableViewDelegate.setTransitionVisibility(visibility); + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitch.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitch.java index 5168533cd2b71..365fbace16080 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitch.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitch.java @@ -29,6 +29,7 @@ import com.android.systemui.R; /** * Container for image of the multi user switcher (tappable). */ +// TODO(b/242040009): Remove this file. public class MultiUserSwitch extends FrameLayout { public MultiUserSwitch(Context context, AttributeSet attrs) { super(context, attrs); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitchController.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitchController.java index 799e5feb1586d..4d6168989691a 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitchController.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/MultiUserSwitchController.java @@ -40,6 +40,7 @@ import com.android.systemui.util.ViewController; import javax.inject.Inject; /** View Controller for {@link MultiUserSwitch}. */ +// TODO(b/242040009): Remove this file. public class MultiUserSwitchController extends ViewController { private final UserManager mUserManager; private final UserSwitcherController mUserSwitcherController; 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/broadcast/BroadcastDispatcherTest.kt b/packages/SystemUI/tests/src/com/android/systemui/broadcast/BroadcastDispatcherTest.kt index 7795d2caf091d..434cb48bc422f 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/broadcast/BroadcastDispatcherTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/broadcast/BroadcastDispatcherTest.kt @@ -18,6 +18,7 @@ package com.android.systemui.broadcast import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent import android.content.IntentFilter import android.os.Handler import android.os.Looper @@ -32,22 +33,27 @@ import com.android.systemui.dump.DumpManager import com.android.systemui.settings.UserTracker import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock import com.android.systemui.util.time.FakeSystemClock +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.Executor import junit.framework.Assert.assertSame +import kotlinx.coroutines.flow.collect +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.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock -import org.mockito.Mockito.`when` import org.mockito.Mockito.anyInt import org.mockito.Mockito.inOrder import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations -import java.util.concurrent.Executor @RunWith(AndroidTestingRunner::class) @TestableLooper.RunWithLooper @@ -381,6 +387,39 @@ class BroadcastDispatcherTest : SysuiTestCase() { .clearPendingRemoval(broadcastReceiver, user1.identifier) } + @Test + fun testBroadcastFlow() = runBlockingTest { + val flow = broadcastDispatcher.broadcastFlow(intentFilter, user1) { intent, receiver -> + intent to receiver + } + + // Collect the values into collectedValues. + val collectedValues = mutableListOf>() + val job = launch { + flow.collect { collectedValues.add(it) } + } + + testableLooper.processAllMessages() + verify(mockUBRUser1).registerReceiver(capture(argumentCaptor), eq(DEFAULT_FLAG)) + val receiver = argumentCaptor.value.receiver + + // Simulate fake broadcasted intents. + val fakeIntents = listOf(mock(), mock(), mock()) + fakeIntents.forEach { receiver.onReceive(mockContext, it) } + + // The intents should have been collected. + advanceUntilIdle() + + val expectedValues = fakeIntents.map { it to receiver } + assertThat(collectedValues).containsExactlyElementsIn(expectedValues) + + // Stop the collection. + job.cancel() + + testableLooper.processAllMessages() + verify(mockUBRUser1).unregisterReceiver(receiver) + } + private fun setUserMock(mockContext: Context, user: UserHandle) { `when`(mockContext.user).thenReturn(user) `when`(mockContext.userId).thenReturn(user.identifier) diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/FgsManagerControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/FgsManagerControllerTest.java index 2927669020c85..7bae115d2edd9 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/FgsManagerControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/FgsManagerControllerTest.java @@ -16,6 +16,8 @@ package com.android.systemui.qs; +import static android.os.PowerExemptionManager.REASON_ALLOWLISTED_PACKAGE; + import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -34,6 +36,7 @@ import android.content.pm.PackageManager; import android.content.pm.UserInfo; import android.os.Binder; import android.os.RemoteException; +import android.os.UserHandle; import android.provider.DeviceConfig; import android.testing.AndroidTestingRunner; import android.testing.TestableLooper; @@ -185,9 +188,9 @@ public class FgsManagerControllerTest extends SysuiTestCase { public void testChangesSinceLastDialog() throws RemoteException { setUserProfiles(0); - Assert.assertFalse(mFmc.getChangesSinceDialog()); + Assert.assertFalse(mFmc.getNewChangesSinceDialogWasDismissed()); mIForegroundServiceObserver.onForegroundStateChanged(new Binder(), "pkg", 0, true); - Assert.assertTrue(mFmc.getChangesSinceDialog()); + Assert.assertTrue(mFmc.getNewChangesSinceDialogWasDismissed()); } @Test @@ -222,7 +225,41 @@ public class FgsManagerControllerTest extends SysuiTestCase { Assert.assertEquals(2, mFmc.getNumRunningPackages()); } + @Test + public void testButtonVisibilityOnShowAllowlistButtonFlagChange() throws Exception { + setUserProfiles(0); + setBackgroundRestrictionExemptionReason("pkg", 12345, REASON_ALLOWLISTED_PACKAGE); + final Binder binder = new Binder(); + setShowStopButtonForUserAllowlistedApps(true); + mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, true); + Assert.assertEquals(1, mFmc.visibleButtonsCount()); + + mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, false); + Assert.assertEquals(0, mFmc.visibleButtonsCount()); + + setShowStopButtonForUserAllowlistedApps(false); + mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, true); + Assert.assertEquals(0, mFmc.visibleButtonsCount()); + } + + private void setShowStopButtonForUserAllowlistedApps(boolean enable) { + mDeviceConfigProxyFake.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS, + enable ? "true" : "false", false); + mBackgroundExecutor.advanceClockToLast(); + mBackgroundExecutor.runAllReady(); + } + + private void setBackgroundRestrictionExemptionReason(String pkgName, int uid, int reason) + throws Exception { + Mockito.doReturn(uid) + .when(mPackageManager) + .getPackageUidAsUser(pkgName, UserHandle.getUserId(uid)); + Mockito.doReturn(reason) + .when(mIActivityManager) + .getBackgroundRestrictionExemptionReason(uid); + } FgsManagerController createFgsManagerController() throws RemoteException { ArgumentCaptor iForegroundServiceObserverArgumentCaptor = @@ -232,7 +269,7 @@ public class FgsManagerControllerTest extends SysuiTestCase { ArgumentCaptor showFgsManagerReceiverArgumentCaptor = ArgumentCaptor.forClass(BroadcastReceiver.class); - FgsManagerController result = new FgsManagerController( + FgsManagerController result = new FgsManagerControllerImpl( mContext, mMainExecutor, mBackgroundExecutor, diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSFragmentTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSFragmentTest.java index 10f6ce8c0ec9d..f08ad2453c5fc 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSFragmentTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSFragmentTest.java @@ -45,12 +45,15 @@ import com.android.systemui.R; import com.android.systemui.SysuiBaseFragmentTest; import com.android.systemui.animation.ShadeInterpolation; import com.android.systemui.dump.DumpManager; +import com.android.systemui.flags.FakeFeatureFlags; +import com.android.systemui.flags.Flags; import com.android.systemui.media.MediaHost; import com.android.systemui.plugins.FalsingManager; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.qs.customize.QSCustomizerController; import com.android.systemui.qs.dagger.QSFragmentComponent; import com.android.systemui.qs.external.TileServiceRequestController; +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel; import com.android.systemui.statusbar.CommandQueue; import com.android.systemui.statusbar.StatusBarState; import com.android.systemui.statusbar.phone.KeyguardBypassController; @@ -390,6 +393,8 @@ public class QSFragmentTest extends SysuiBaseFragmentTest { setUpMedia(); setUpOther(); + FakeFeatureFlags featureFlags = new FakeFeatureFlags(); + featureFlags.set(Flags.NEW_FOOTER_ACTIONS, false); return new QSFragment( new RemoteInputQuickSettingsDisabler( context, commandQueue, mock(ConfigurationController.class)), @@ -402,7 +407,10 @@ public class QSFragmentTest extends SysuiBaseFragmentTest { mQsComponentFactory, mock(QSFragmentDisableFlagsLogger.class), mFalsingManager, - mock(DumpManager.class)); + mock(DumpManager.class), + featureFlags, + mock(NewFooterActionsController.class), + mock(FooterActionsViewModel.Factory.class)); } private void setUpOther() { diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java index c1c0f78ecd6bc..233c267c3be07 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java @@ -100,6 +100,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { private TextView mFooterText; private TestableImageView mPrimaryFooterIcon; private QSSecurityFooter mFooter; + private QSSecurityFooterUtils mFooterUtils; @Mock private SecurityController mSecurityController; @Mock @@ -118,13 +119,16 @@ public class QSSecurityFooterTest extends SysuiTestCase { MockitoAnnotations.initMocks(this); mTestableLooper = TestableLooper.get(this); Looper looper = mTestableLooper.getLooper(); + Handler mainHandler = new Handler(looper); when(mUserTracker.getUserInfo()).thenReturn(mock(UserInfo.class)); mRootView = (ViewGroup) new LayoutInflaterBuilder(mContext) .replace("ImageView", TestableImageView.class) .build().inflate(R.layout.quick_settings_security_footer, null, false); - mFooter = new QSSecurityFooter(mRootView, mUserTracker, new Handler(looper), - mActivityStarter, mSecurityController, mDialogLaunchAnimator, looper, - mBroadcastDispatcher); + mFooterUtils = new QSSecurityFooterUtils(getContext(), + getContext().getSystemService(DevicePolicyManager.class), mUserTracker, + mainHandler, mActivityStarter, mSecurityController, looper, mDialogLaunchAnimator); + mFooter = new QSSecurityFooter(mRootView, mainHandler, mSecurityController, looper, + mBroadcastDispatcher, mFooterUtils); mFooterText = mRootView.findViewById(R.id.footer_text); mPrimaryFooterIcon = mRootView.findViewById(R.id.primary_footer_icon); @@ -520,7 +524,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { when(mSecurityController.isDeviceManaged()).thenReturn(true); assertEquals(mContext.getString(R.string.monitoring_title_device_owned), - mFooter.getManagementTitle(MANAGING_ORGANIZATION)); + mFooterUtils.getManagementTitle(MANAGING_ORGANIZATION)); } @Test @@ -531,12 +535,12 @@ public class QSSecurityFooterTest extends SysuiTestCase { assertEquals(mContext.getString(R.string.monitoring_title_financed_device, MANAGING_ORGANIZATION), - mFooter.getManagementTitle(MANAGING_ORGANIZATION)); + mFooterUtils.getManagementTitle(MANAGING_ORGANIZATION)); } @Test public void testGetManagementMessage_noManagement() { - assertEquals(null, mFooter.getManagementMessage( + assertEquals(null, mFooterUtils.getManagementMessage( /* isDeviceManaged= */ false, MANAGING_ORGANIZATION)); } @@ -544,10 +548,10 @@ public class QSSecurityFooterTest extends SysuiTestCase { public void testGetManagementMessage_deviceOwner() { assertEquals(mContext.getString(R.string.monitoring_description_named_management, MANAGING_ORGANIZATION), - mFooter.getManagementMessage( + mFooterUtils.getManagementMessage( /* isDeviceManaged= */ true, MANAGING_ORGANIZATION)); assertEquals(mContext.getString(R.string.monitoring_description_management), - mFooter.getManagementMessage( + mFooterUtils.getManagementMessage( /* isDeviceManaged= */ true, /* organizationName= */ null)); } @@ -560,68 +564,68 @@ public class QSSecurityFooterTest extends SysuiTestCase { assertEquals(mContext.getString(R.string.monitoring_financed_description_named_management, MANAGING_ORGANIZATION, MANAGING_ORGANIZATION), - mFooter.getManagementMessage( + mFooterUtils.getManagementMessage( /* isDeviceManaged= */ true, MANAGING_ORGANIZATION)); } @Test public void testGetCaCertsMessage() { - assertEquals(null, mFooter.getCaCertsMessage(true, false, false)); - assertEquals(null, mFooter.getCaCertsMessage(false, false, false)); + assertEquals(null, mFooterUtils.getCaCertsMessage(true, false, false)); + assertEquals(null, mFooterUtils.getCaCertsMessage(false, false, false)); assertEquals(mContext.getString(R.string.monitoring_description_management_ca_certificate), - mFooter.getCaCertsMessage(true, true, true)); + mFooterUtils.getCaCertsMessage(true, true, true)); assertEquals(mContext.getString(R.string.monitoring_description_management_ca_certificate), - mFooter.getCaCertsMessage(true, false, true)); + mFooterUtils.getCaCertsMessage(true, false, true)); assertEquals(mContext.getString( R.string.monitoring_description_managed_profile_ca_certificate), - mFooter.getCaCertsMessage(false, false, true)); + mFooterUtils.getCaCertsMessage(false, false, true)); assertEquals(mContext.getString( R.string.monitoring_description_ca_certificate), - mFooter.getCaCertsMessage(false, true, false)); + mFooterUtils.getCaCertsMessage(false, true, false)); } @Test public void testGetNetworkLoggingMessage() { // Test network logging message on a device with a device owner. // Network traffic may be monitored on the device. - assertEquals(null, mFooter.getNetworkLoggingMessage(true, false)); + assertEquals(null, mFooterUtils.getNetworkLoggingMessage(true, false)); assertEquals(mContext.getString(R.string.monitoring_description_management_network_logging), - mFooter.getNetworkLoggingMessage(true, true)); + mFooterUtils.getNetworkLoggingMessage(true, true)); // Test network logging message on a device with a managed profile owner // Network traffic may be monitored on the work profile. - assertEquals(null, mFooter.getNetworkLoggingMessage(false, false)); + assertEquals(null, mFooterUtils.getNetworkLoggingMessage(false, false)); assertEquals( mContext.getString(R.string.monitoring_description_managed_profile_network_logging), - mFooter.getNetworkLoggingMessage(false, true)); + mFooterUtils.getNetworkLoggingMessage(false, true)); } @Test public void testGetVpnMessage() { - assertEquals(null, mFooter.getVpnMessage(true, true, null, null)); + assertEquals(null, mFooterUtils.getVpnMessage(true, true, null, null)); assertEquals(addLink(mContext.getString(R.string.monitoring_description_two_named_vpns, VPN_PACKAGE, VPN_PACKAGE_2)), - mFooter.getVpnMessage(true, true, VPN_PACKAGE, VPN_PACKAGE_2)); + mFooterUtils.getVpnMessage(true, true, VPN_PACKAGE, VPN_PACKAGE_2)); assertEquals(addLink(mContext.getString(R.string.monitoring_description_two_named_vpns, VPN_PACKAGE, VPN_PACKAGE_2)), - mFooter.getVpnMessage(false, true, VPN_PACKAGE, VPN_PACKAGE_2)); + mFooterUtils.getVpnMessage(false, true, VPN_PACKAGE, VPN_PACKAGE_2)); assertEquals(addLink(mContext.getString(R.string.monitoring_description_named_vpn, VPN_PACKAGE)), - mFooter.getVpnMessage(true, false, VPN_PACKAGE, null)); + mFooterUtils.getVpnMessage(true, false, VPN_PACKAGE, null)); assertEquals(addLink(mContext.getString(R.string.monitoring_description_named_vpn, VPN_PACKAGE)), - mFooter.getVpnMessage(false, false, VPN_PACKAGE, null)); + mFooterUtils.getVpnMessage(false, false, VPN_PACKAGE, null)); assertEquals(addLink(mContext.getString(R.string.monitoring_description_named_vpn, VPN_PACKAGE_2)), - mFooter.getVpnMessage(true, true, null, VPN_PACKAGE_2)); + mFooterUtils.getVpnMessage(true, true, null, VPN_PACKAGE_2)); assertEquals(addLink(mContext.getString( R.string.monitoring_description_managed_profile_named_vpn, VPN_PACKAGE_2)), - mFooter.getVpnMessage(false, true, null, VPN_PACKAGE_2)); + mFooterUtils.getVpnMessage(false, true, null, VPN_PACKAGE_2)); assertEquals(addLink(mContext.getString( R.string.monitoring_description_personal_profile_named_vpn, VPN_PACKAGE)), - mFooter.getVpnMessage(false, true, VPN_PACKAGE, null)); + mFooterUtils.getVpnMessage(false, true, VPN_PACKAGE, null)); } @Test @@ -631,19 +635,19 @@ public class QSSecurityFooterTest extends SysuiTestCase { // Device Management subtitle should be shown when there is Device Management section only // Other sections visibility will be set somewhere else so it will not be tested here - mFooter.configSubtitleVisibility(true, false, false, false, view); + mFooterUtils.configSubtitleVisibility(true, false, false, false, view); assertEquals(View.VISIBLE, view.findViewById(R.id.device_management_subtitle).getVisibility()); // If there are multiple sections, all subtitles should be shown - mFooter.configSubtitleVisibility(true, true, false, false, view); + mFooterUtils.configSubtitleVisibility(true, true, false, false, view); assertEquals(View.VISIBLE, view.findViewById(R.id.device_management_subtitle).getVisibility()); assertEquals(View.VISIBLE, view.findViewById(R.id.ca_certs_subtitle).getVisibility()); // If there are multiple sections, all subtitles should be shown - mFooter.configSubtitleVisibility(true, true, true, true, view); + mFooterUtils.configSubtitleVisibility(true, true, true, true, view); assertEquals(View.VISIBLE, view.findViewById(R.id.device_management_subtitle).getVisibility()); assertEquals(View.VISIBLE, @@ -655,7 +659,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { // If there are multiple sections, all subtitles should be shown, event if there is no // Device Management section - mFooter.configSubtitleVisibility(false, true, true, true, view); + mFooterUtils.configSubtitleVisibility(false, true, true, true, view); assertEquals(View.VISIBLE, view.findViewById(R.id.ca_certs_subtitle).getVisibility()); assertEquals(View.VISIBLE, @@ -664,13 +668,13 @@ public class QSSecurityFooterTest extends SysuiTestCase { view.findViewById(R.id.vpn_subtitle).getVisibility()); // If there is only 1 section, the title should be hidden - mFooter.configSubtitleVisibility(false, true, false, false, view); + mFooterUtils.configSubtitleVisibility(false, true, false, false, view); assertEquals(View.GONE, view.findViewById(R.id.ca_certs_subtitle).getVisibility()); - mFooter.configSubtitleVisibility(false, false, true, false, view); + mFooterUtils.configSubtitleVisibility(false, false, true, false, view); assertEquals(View.GONE, view.findViewById(R.id.network_logging_subtitle).getVisibility()); - mFooter.configSubtitleVisibility(false, false, false, true, view); + mFooterUtils.configSubtitleVisibility(false, false, false, true, view); assertEquals(View.GONE, view.findViewById(R.id.vpn_subtitle).getVisibility()); } @@ -690,6 +694,9 @@ public class QSSecurityFooterTest extends SysuiTestCase { @Test public void testParentalControls() { + // Make sure the security footer is visible, so that the images are updated. + when(mSecurityController.isProfileOwnerOfOrganizationOwnedDevice()).thenReturn(true); + when(mSecurityController.isParentalControlsEnabled()).thenReturn(true); Drawable testDrawable = new VectorDrawable(); @@ -719,7 +726,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { when(mSecurityController.isParentalControlsEnabled()).thenReturn(true); when(mSecurityController.getLabel(any())).thenReturn(PARENTAL_CONTROLS_LABEL); - View view = mFooter.createDialogView(); + View view = mFooterUtils.createDialogView(); TextView textView = (TextView) view.findViewById(R.id.parental_controls_title); assertEquals(PARENTAL_CONTROLS_LABEL, textView.getText()); } @@ -742,7 +749,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { when(mSecurityController.getDeviceOwnerType(DEVICE_OWNER_COMPONENT)) .thenReturn(DEVICE_OWNER_TYPE_FINANCED); - View view = mFooter.createDialogView(); + View view = mFooterUtils.createDialogView(); TextView managementSubtitle = view.findViewById(R.id.device_management_subtitle); assertEquals(View.VISIBLE, managementSubtitle.getVisibility()); @@ -753,7 +760,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { assertEquals(mContext.getString(R.string.monitoring_financed_description_named_management, MANAGING_ORGANIZATION, MANAGING_ORGANIZATION), managementMessage.getText()); assertEquals(mContext.getString(R.string.monitoring_button_view_policies), - mFooter.getSettingsButton()); + mFooterUtils.getSettingsButton()); } @Test @@ -773,7 +780,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { AlertDialog dialog = dialogCaptor.getValue(); dialog.create(); - assertEquals(mFooter.getSettingsButton(), + assertEquals(mFooterUtils.getSettingsButton(), dialog.getButton(DialogInterface.BUTTON_NEGATIVE).getText()); dialog.dismiss(); @@ -816,8 +823,8 @@ public class QSSecurityFooterTest extends SysuiTestCase { new Intent(DevicePolicyManager.ACTION_SHOW_DEVICE_MONITORING_DIALOG)); mTestableLooper.processAllMessages(); - assertTrue(mFooter.getDialog().isShowing()); - mFooter.getDialog().dismiss(); + assertTrue(mFooterUtils.getDialog().isShowing()); + mFooterUtils.getDialog().dismiss(); } private CharSequence addLink(CharSequence description) { @@ -825,7 +832,7 @@ public class QSSecurityFooterTest extends SysuiTestCase { message.append(description); message.append(mContext.getString(R.string.monitoring_description_vpn_settings_separator)); message.append(mContext.getString(R.string.monitoring_description_vpn_settings), - mFooter.new VpnSpan(), 0); + mFooterUtils.new VpnSpan(), 0); return message; } } 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/src/com/android/systemui/flags/FakeFeatureFlags.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/flags/FakeFeatureFlags.kt similarity index 99% rename from packages/SystemUI/tests/src/com/android/systemui/flags/FakeFeatureFlags.kt rename to packages/SystemUI/tests/utils/src/com/android/systemui/flags/FakeFeatureFlags.kt index 7b1455cb2e465..b53ad0a3726f3 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/flags/FakeFeatureFlags.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/flags/FakeFeatureFlags.kt @@ -56,7 +56,6 @@ class FakeFeatureFlags : FeatureFlags { stringFlags.put(flag.id, value) } - override fun isEnabled(flag: UnreleasedFlag): Boolean = requireBooleanValue(flag.id) override fun isEnabled(flag: ReleasedFlag): Boolean = requireBooleanValue(flag.id) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt new file mode 100644 index 0000000000000..5272585793728 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt @@ -0,0 +1,80 @@ +/* + * 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 + +import android.view.View +import com.android.systemui.qs.FgsManagerController.OnDialogDismissedListener +import com.android.systemui.qs.FgsManagerController.OnNumberOfPackagesChangedListener +import kotlinx.coroutines.flow.MutableStateFlow + +/** A fake [FgsManagerController] to be used in tests. */ +class FakeFgsManagerController( + isAvailable: Boolean = true, + showFooterDot: Boolean = false, + numRunningPackages: Int = 0, +) : FgsManagerController { + override val isAvailable: MutableStateFlow = MutableStateFlow(isAvailable) + + override var numRunningPackages = numRunningPackages + set(value) { + if (value != field) { + field = value + newChangesSinceDialogWasDismissed = true + numRunningPackagesListeners.forEach { it.onNumberOfPackagesChanged(value) } + } + } + + override var newChangesSinceDialogWasDismissed = false + private set + + override val showFooterDot: MutableStateFlow = MutableStateFlow(showFooterDot) + + private val numRunningPackagesListeners = LinkedHashSet() + private val dialogDismissedListeners = LinkedHashSet() + + /** Simulate that a fgs dialog was just dismissed. */ + fun simulateDialogDismiss() { + newChangesSinceDialogWasDismissed = false + dialogDismissedListeners.forEach { it.onDialogDismissed() } + } + + override fun init() {} + + override fun showDialog(viewLaunchedFrom: View?) {} + + override fun addOnNumberOfPackagesChangedListener(listener: OnNumberOfPackagesChangedListener) { + numRunningPackagesListeners.add(listener) + } + + override fun removeOnNumberOfPackagesChangedListener( + listener: OnNumberOfPackagesChangedListener + ) { + numRunningPackagesListeners.remove(listener) + } + + override fun addOnDialogDismissedListener(listener: OnDialogDismissedListener) { + dialogDismissedListeners.add(listener) + } + + override fun removeOnDialogDismissedListener(listener: OnDialogDismissedListener) { + dialogDismissedListeners.remove(listener) + } + + override fun shouldUpdateFooterVisibility(): Boolean = false + + override fun visibleButtonsCount(): Int = 0 +} 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/settings/FakeUserTracker.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/settings/FakeUserTracker.kt new file mode 100644 index 0000000000000..b2b176420e402 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/settings/FakeUserTracker.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.settings + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.UserInfo +import android.os.UserHandle +import android.test.mock.MockContentResolver +import com.android.systemui.util.mockito.mock +import java.util.concurrent.Executor + +/** A fake [UserTracker] to be used in tests. */ +class FakeUserTracker( + userId: Int = 0, + userHandle: UserHandle = UserHandle.of(userId), + userInfo: UserInfo = mock(), + userProfiles: List = emptyList(), + userContentResolver: ContentResolver = MockContentResolver(), + userContext: Context = mock(), + private val onCreateCurrentUserContext: (Context) -> Context = { mock() }, +) : UserTracker { + val callbacks = mutableListOf() + + override val userId: Int = userId + override val userHandle: UserHandle = userHandle + override val userInfo: UserInfo = userInfo + override val userProfiles: List = userProfiles + + override val userContentResolver: ContentResolver = userContentResolver + override val userContext: Context = userContext + + override fun addCallback(callback: UserTracker.Callback, executor: Executor) { + callbacks.add(callback) + } + + override fun removeCallback(callback: UserTracker.Callback) { + callbacks.remove(callback) + } + + override fun createCurrentUserContext(context: Context): Context { + return onCreateCurrentUserContext(context) + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeSecurityController.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeSecurityController.kt new file mode 100644 index 0000000000000..c6aa3952c4b1b --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeSecurityController.kt @@ -0,0 +1,118 @@ +/* + * 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.statusbar.policy + +import android.app.admin.DeviceAdminInfo +import android.content.ComponentName +import android.graphics.drawable.Drawable +import java.io.PrintWriter + +/** A fake [SecurityController] to be used in tests. */ +class FakeSecurityController( + private val fakeState: FakeState = FakeState(), +) : SecurityController { + private val callbacks = LinkedHashSet() + + override fun addCallback(callback: SecurityController.SecurityControllerCallback) { + callbacks.add(callback) + } + + override fun removeCallback(callback: SecurityController.SecurityControllerCallback) { + callbacks.remove(callback) + } + + /** Update [fakeState], then notify the callbacks. */ + fun updateState(f: FakeState.() -> Unit) { + fakeState.f() + callbacks.forEach { it.onStateChanged() } + } + + override fun dump(pw: PrintWriter, args: Array) {} + + override fun isDeviceManaged(): Boolean = fakeState.isDeviceManaged + + override fun hasProfileOwner(): Boolean = fakeState.hasProfileOwner + + override fun hasWorkProfile(): Boolean = fakeState.hasWorkProfile + + override fun isWorkProfileOn(): Boolean = fakeState.isWorkProfileOn + + override fun isProfileOwnerOfOrganizationOwnedDevice(): Boolean = + fakeState.isProfileOwnerOfOrganizationOwnedDevice + + override fun getDeviceOwnerName(): String? = fakeState.deviceOwnerName + + override fun getProfileOwnerName(): String? = fakeState.profileOwnerName + + override fun getDeviceOwnerOrganizationName(): String? = fakeState.deviceOwnerOrganizationName + + override fun getWorkProfileOrganizationName(): String? = fakeState.workProfileOrganizationName + + override fun getDeviceOwnerComponentOnAnyUser(): ComponentName? = + fakeState.deviceOwnerComponentOnAnyUser + + override fun getDeviceOwnerType(admin: ComponentName?): Int = 0 + + override fun isNetworkLoggingEnabled(): Boolean = fakeState.isNetworkLoggingEnabled + + override fun isVpnEnabled(): Boolean = fakeState.isVpnEnabled + + override fun isVpnRestricted(): Boolean = fakeState.isVpnRestricted + + override fun isVpnBranded(): Boolean = fakeState.isVpnBranded + + override fun getPrimaryVpnName(): String? = fakeState.primaryVpnName + + override fun getWorkProfileVpnName(): String? = fakeState.workProfileVpnName + + override fun hasCACertInCurrentUser(): Boolean = fakeState.hasCACertInCurrentUser + + override fun hasCACertInWorkProfile(): Boolean = fakeState.hasCACertInWorkProfile + + override fun onUserSwitched(newUserId: Int) {} + + override fun isParentalControlsEnabled(): Boolean = fakeState.isParentalControlsEnabled + + override fun getDeviceAdminInfo(): DeviceAdminInfo? = fakeState.deviceAdminInfo + + override fun getIcon(info: DeviceAdminInfo?): Drawable? = null + + override fun getLabel(info: DeviceAdminInfo?): CharSequence? = null + + class FakeState( + var isDeviceManaged: Boolean = false, + var hasProfileOwner: Boolean = false, + var hasWorkProfile: Boolean = false, + var isWorkProfileOn: Boolean = false, + var isProfileOwnerOfOrganizationOwnedDevice: Boolean = false, + var deviceOwnerName: String? = null, + var profileOwnerName: String? = null, + var deviceOwnerOrganizationName: String? = null, + var workProfileOrganizationName: String? = null, + var deviceOwnerComponentOnAnyUser: ComponentName? = null, + var isNetworkLoggingEnabled: Boolean = false, + var isVpnEnabled: Boolean = false, + var isVpnRestricted: Boolean = false, + var isVpnBranded: Boolean = false, + var primaryVpnName: String? = null, + var workProfileVpnName: String? = null, + var hasCACertInCurrentUser: Boolean = false, + var hasCACertInWorkProfile: Boolean = false, + var isParentalControlsEnabled: Boolean = false, + var deviceAdminInfo: DeviceAdminInfo? = null, + ) +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeUserInfoController.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeUserInfoController.kt new file mode 100644 index 0000000000000..32b9a371674fb --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeUserInfoController.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.statusbar.policy + +import android.graphics.drawable.Drawable +import com.android.systemui.util.mockito.mock + +/** A fake [UserInfoController] to be used in tests. */ +class FakeUserInfoController( + private val fakeInfo: FakeInfo = FakeInfo(), +) : UserInfoController { + private val listeners = LinkedHashSet() + + /** Update [fakeInfo], then notify the listeners. */ + fun updateInfo(f: FakeInfo.() -> Unit) { + fakeInfo.f() + notifyListeners() + } + + private fun notifyListeners() { + listeners.forEach { listener -> + listener.onUserInfoChanged(fakeInfo.name, fakeInfo.picture, fakeInfo.userAccount) + } + } + + override fun addCallback(listener: UserInfoController.OnUserInfoChangedListener) { + listeners.add(listener) + + // The actual implementation notifies the listener when adding it. + listener.onUserInfoChanged(fakeInfo.name, fakeInfo.picture, fakeInfo.userAccount) + } + + override fun removeCallback(listener: UserInfoController.OnUserInfoChangedListener) { + listeners.remove(listener) + } + + override fun reloadUserInfo() {} + + class FakeInfo( + var name: String = "", + var picture: Drawable = mock(), + var userAccount: String = "", + ) +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/MockUserSwitcherControllerWrapper.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/MockUserSwitcherControllerWrapper.kt new file mode 100644 index 0000000000000..1304a12a5f7fb --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/MockUserSwitcherControllerWrapper.kt @@ -0,0 +1,59 @@ +/* + * 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.statusbar.policy + +import com.android.systemui.statusbar.policy.UserSwitcherController.UserSwitchCallback +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.mock +import org.mockito.Mockito.`when` as whenever + +/** + * A wrapper around a mocked [UserSwitcherController] to be used in tests. + * + * Note that this was implemented as a mock wrapper instead of fake implementation of a common + * interface given how big the UserSwitcherController grew. + */ +class MockUserSwitcherControllerWrapper( + currentUserName: String = "", +) { + val controller: UserSwitcherController = mock() + private val callbacks = LinkedHashSet() + + var currentUserName = currentUserName + set(value) { + if (value != field) { + field = value + notifyCallbacks() + } + } + + private fun notifyCallbacks() { + callbacks.forEach { it.onUserSwitched() } + } + + init { + whenever(controller.addUserSwitchCallback(any())).then { invocation -> + callbacks.add(invocation.arguments.first() as UserSwitchCallback) + } + + whenever(controller.removeUserSwitchCallback(any())).then { invocation -> + callbacks.remove(invocation.arguments.first() as UserSwitchCallback) + } + + whenever(controller.currentUserName).thenAnswer { this.currentUserName } + } +} 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", + ) +}