From dcd089d3e7ae4dd735ee13947740a1c5bda54b1e Mon Sep 17 00:00:00 2001 From: Jing Ji Date: Thu, 23 Jun 2022 20:43:36 -0700 Subject: [PATCH 01/10] Allow preventing unrestricted Apps from being Stopped This CL adds a flag to allow toggling the behavior on whether or not the foreground service task manager should show a stop button for unrestricted apps. Bug: 230449539 Test: Manual - device_config put systemui \ show_stop_button_for_user_allowlisted_apps true|false Test: atest FgsManagerControllerTest Change-Id: I4cfe54933d7c6807a7ffbac9ee709c7d8d6b83d3 Merged-In: I4cfe54933d7c6807a7ffbac9ee709c7d8d6b83d3 --- .../sysui/SystemUiDeviceConfigFlags.java | 6 +++ .../systemui/qs/FgsManagerController.kt | 33 +++++++++++++++++ .../systemui/qs/FgsManagerControllerTest.java | 37 +++++++++++++++++++ 3 files changed, 76 insertions(+) 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/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt b/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt index 0288c9fce64ae..8f6c3737d6dae 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 @@ -87,6 +89,7 @@ class FgsManagerController @Inject constructor( 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 @@ -96,6 +99,8 @@ class FgsManagerController @Inject constructor( private set var showFooterDot = false private set + var showStopBtnForUserAllowlistedApps = false + private set private val lock = Any() @@ -163,6 +168,9 @@ class FgsManagerController @Inject constructor( isAvailable = it.getBoolean(TASK_MANAGER_ENABLED, isAvailable) showFooterDot = it.getBoolean(TASK_MANAGER_SHOW_FOOTER_DOT, showFooterDot) + showStopBtnForUserAllowlistedApps = it.getBoolean( + TASK_MANAGER_SHOW_STOP_BUTTON_FOR_USER_ALLOWLISTED_APPS, + showStopBtnForUserAllowlistedApps) } isAvailable = deviceConfigProxy.getBoolean( @@ -173,6 +181,10 @@ class FgsManagerController @Inject constructor( 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) @@ -275,6 +287,20 @@ class FgsManagerController @Inject constructor( } } + @VisibleForTesting + @JvmName("getNumVisibleButtons") + internal fun getNumVisibleButtons(): Int { + synchronized(lock) { + return getNumVisibleButtonsLocked() + } + } + + private fun getNumVisibleButtonsLocked(): Int { + return runningServiceTokens.keys.count { + it.uiControl != UIControl.HIDE_BUTTON && currentProfileIds.contains(it.userId) + } + } + fun shouldUpdateFooterVisibility() = dialog == null fun showDialog(viewLaunchedFrom: View?) { @@ -505,6 +531,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 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..3dc10d0ac6cab 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; @@ -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.getNumVisibleButtons()); + + mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, false); + Assert.assertEquals(0, mFmc.getNumVisibleButtons()); + + setShowStopButtonForUserAllowlistedApps(false); + mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, true); + Assert.assertEquals(0, mFmc.getNumVisibleButtons()); + } + + 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 = From e496b54c2129615a9c7513ee5007d6fa49b31f75 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Fri, 19 Aug 2022 14:19:37 +0200 Subject: [PATCH 02/10] Add some fakes to SystemUI-test-utils This CL adds fake implementations for: - FgsManagerController - UserTracker - SecurityController - UserInfoController It also adds a wrapper around a mocked UserSwitcherController to make it easier to mock in tests while still handling callbacks. Those fakes are going to be used to test the new implementation of the QSFooterActions, which are going to be refactored using the modern Android architecture. See ag/19678215 to see how they are used. Bug: 242040009 Test: atest FgsManagerControllerTest Test: atest FooterActionsViewModelTest Test: atest FooterActionsInteractorTest Change-Id: I0a3ef36de81c1bd48f33d4d234a4ee903bf40fc6 --- .../systemui/qs/FgsManagerController.kt | 154 +++++++++++++----- .../systemui/qs/QSFgsManagerFooter.java | 8 +- .../systemui/qs/dagger/QSFragmentModule.java | 6 + .../systemui/qs/FgsManagerControllerTest.java | 12 +- .../systemui/flags/FakeFeatureFlags.kt | 1 - .../systemui/qs/FakeFgsManagerController.kt | 80 +++++++++ .../systemui/settings/FakeUserTracker.kt | 58 +++++++ .../policy/FakeSecurityController.kt | 118 ++++++++++++++ .../policy/FakeUserInfoController.kt | 58 +++++++ .../MockUserSwitcherControllerWrapper.kt | 59 +++++++ 10 files changed, 499 insertions(+), 55 deletions(-) rename packages/SystemUI/tests/{ => utils}/src/com/android/systemui/flags/FakeFeatureFlags.kt (99%) create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/settings/FakeUserTracker.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeSecurityController.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeUserInfoController.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/MockUserSwitcherControllerWrapper.kt diff --git a/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt b/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt index 8f6c3737d6dae..482a1397642b3 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/FgsManagerController.kt @@ -68,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, @@ -82,25 +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 - var showStopBtnForUserAllowlistedApps = 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() @@ -138,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 @@ -165,19 +228,19 @@ 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 ) @@ -232,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) @@ -278,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) @@ -287,9 +353,7 @@ class FgsManagerController @Inject constructor( } } - @VisibleForTesting - @JvmName("getNumVisibleButtons") - internal fun getNumVisibleButtons(): Int { + override fun visibleButtonsCount(): Int { synchronized(lock) { return getNumVisibleButtonsLocked() } @@ -301,9 +365,9 @@ class FgsManagerController @Inject constructor( } } - fun shouldUpdateFooterVisibility() = dialog == null + override fun shouldUpdateFooterVisibility() = dialog == null - fun showDialog(viewLaunchedFrom: View?) { + override fun showDialog(viewLaunchedFrom: View?) { synchronized(lock) { if (dialog == null) { @@ -328,7 +392,7 @@ class FgsManagerController @Inject constructor( this.dialog = dialog dialog.setOnDismissListener { - changesSinceDialog = false + newChangesSinceDialogWasDismissed = false synchronized(lock) { this.dialog = null updateAppItemsLocked() @@ -656,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/QSFgsManagerFooter.java b/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java index 875493d73f9eb..372550656d571 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSFgsManagerFooter.java @@ -149,9 +149,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/dagger/QSFragmentModule.java b/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java index aa505fb0b6bd4..6f6e4b428e949 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java +++ b/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java @@ -28,6 +28,8 @@ import com.android.systemui.battery.BatteryMeterView; import com.android.systemui.dagger.qualifiers.RootView; import com.android.systemui.plugins.qs.QS; import com.android.systemui.privacy.OngoingPrivacyChip; +import com.android.systemui.qs.FgsManagerController; +import com.android.systemui.qs.FgsManagerControllerImpl; import com.android.systemui.qs.FooterActionsView; import com.android.systemui.qs.QSContainerImpl; import com.android.systemui.qs.QSFooter; @@ -194,4 +196,8 @@ public interface QSFragmentModule { ) { return layoutInflater.inflate(R.layout.fgs_footer, footerActionsView, false); } + + /** */ + @Binds + FgsManagerController bindFgsManagerController(FgsManagerControllerImpl impl); } \ No newline at end of file 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 3dc10d0ac6cab..7bae115d2edd9 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/FgsManagerControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/FgsManagerControllerTest.java @@ -188,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 @@ -233,14 +233,14 @@ public class FgsManagerControllerTest extends SysuiTestCase { final Binder binder = new Binder(); setShowStopButtonForUserAllowlistedApps(true); mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, true); - Assert.assertEquals(1, mFmc.getNumVisibleButtons()); + Assert.assertEquals(1, mFmc.visibleButtonsCount()); mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, false); - Assert.assertEquals(0, mFmc.getNumVisibleButtons()); + Assert.assertEquals(0, mFmc.visibleButtonsCount()); setShowStopButtonForUserAllowlistedApps(false); mIForegroundServiceObserver.onForegroundStateChanged(binder, "pkg", 0, true); - Assert.assertEquals(0, mFmc.getNumVisibleButtons()); + Assert.assertEquals(0, mFmc.visibleButtonsCount()); } private void setShowStopButtonForUserAllowlistedApps(boolean enable) { @@ -269,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/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/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 } + } +} From e4eb991ba65f6a7a89427970f46916c9d0528c29 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Fri, 19 Aug 2022 16:42:10 +0200 Subject: [PATCH 03/10] Make the logic of QSSecurityFooter reusable This CL makes the logic of the QSSecurityFooter reusable by extracting the logic of refreshState() that maps the current security info into the security button configuration. This is going to be used by ag/19678215. This CL also introduces the generic Icon class to model an icon, which either can be a loaded Drawable or a reference to a Resource. This model will be shared by all layers of ag/19678215. The SecurityInfo class is going to be used by the data layer of ag/19678215, and SecurityButtonConfig by the the domain layer. Bug: 242040009 Test: atest QSSecurityFooterTest Change-Id: I0cd229b257a3eafd4c07a965abe536835724ecef --- .../systemui/common/shared/model/Icon.kt | 34 ++++ .../android/systemui/qs/QSSecurityFooter.java | 150 +++++++++++------- .../domain/model/SecurityButtonConfig.kt | 26 +++ .../security/data/model/SecurityModel.kt | 89 +++++++++++ .../systemui/qs/QSSecurityFooterTest.java | 3 + 5 files changed, 243 insertions(+), 59 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/common/shared/model/Icon.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/domain/model/SecurityButtonConfig.kt create mode 100644 packages/SystemUI/src/com/android/systemui/security/data/model/SecurityModel.kt 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/qs/QSSecurityFooter.java b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java index 87fcce455ea6d..d983ece8f280a 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java @@ -87,10 +87,13 @@ 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.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; @@ -102,8 +105,9 @@ import java.util.function.Supplier; import javax.inject.Inject; import javax.inject.Named; +/** Helper class for the configuration of the QS security footer button. */ @QSScope -class QSSecurityFooter extends ViewController +public class QSSecurityFooter extends ViewController implements OnClickListener, DialogInterface.OnClickListener, VisibilityChangedDispatcher { protected static final String TAG = "QSSecurityFooter"; @@ -130,11 +134,10 @@ class QSSecurityFooter extends ViewController 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; @@ -215,7 +218,7 @@ class QSSecurityFooter extends ViewController 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; @@ -287,8 +290,18 @@ class QSSecurityFooter extends ViewController .write(); } + // TODO(b/242040009): Remove this. public void showDeviceMonitoringDialog() { - createDialog(); + showDeviceMonitoringDialog(mContext, mView); + } + + /** + * Show the device monitoring dialog, and expand it from {@code view} if it's not null. + * + * Important: {@code view} must be associated to the same {@link Context} as the QSFragment. + */ + public void showDeviceMonitoringDialog(Context quickSettingsContext, @Nullable View view) { + createDialog(quickSettingsContext, view); } public void refreshState() { @@ -296,75 +309,86 @@ class QSSecurityFooter extends ViewController } private void handleRefreshState() { - final boolean isDeviceManaged = mSecurityController.isDeviceManaged(); + SecurityModel securityModel = SecurityModel.create(mSecurityController); + SecurityButtonConfig buttonConfig = getButtonConfig(securityModel); + + if (buttonConfig == null) { + mIsVisible = false; + } else { + mIsVisible = true; + mIsClickable = buttonConfig.isClickable(); + mFooterTextContent = buttonConfig.getText(); + mFooterIcon = buttonConfig.getIcon(); + } + + // Update the UI. + mMainHandler.post(mUpdatePrimaryIcon); + mMainHandler.post(mUpdateDisplayState); + } + + /** + * 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 = 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 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 = - mSecurityController.getWorkProfileOrganizationName(); + securityModel.getWorkProfileOrganizationName(); final boolean isProfileOwnerOfOrganizationOwnedDevice = - mSecurityController.isProfileOwnerOfOrganizationOwnedDevice(); - final boolean isParentalControlsEnabled = mSecurityController.isParentalControlsEnabled(); - final boolean isWorkProfileOn = mSecurityController.isWorkProfileOn(); + securityModel.isProfileOwnerOfOrganizationOwnedDevice(); + final boolean isParentalControlsEnabled = securityModel.isParentalControlsEnabled(); + final boolean isWorkProfileOn = securityModel.isWorkProfileOn(); final boolean hasDisclosableWorkProfilePolicy = hasCACertsInWorkProfile || vpnNameWorkProfile != null || (hasWorkProfile && isNetworkLoggingEnabled); // Update visibility of footer - mIsVisible = (isDeviceManaged && !isDemoDevice) + 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. - if (mIsVisible && isProfileOwnerOfOrganizationOwnedDevice - && (!hasDisclosableWorkProfilePolicy || !isWorkProfileOn)) { - mView.setClickable(false); - mView.findViewById(R.id.footer_icon).setVisibility(View.GONE); - } else { - mView.setClickable(true); - mView.findViewById(R.id.footer_icon).setVisibility(View.VISIBLE); - } - // Update the string - mFooterTextContent = getFooterText(isDeviceManaged, hasWorkProfile, + boolean isClickable = !(isProfileOwnerOfOrganizationOwnedDevice + && (!hasDisclosableWorkProfilePolicy || !isWorkProfileOn)); + + String text = 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; - } + isWorkProfileOn).toString(); - // Update the primary icon + Icon icon; if (isParentalControlsEnabled) { - if (mPrimaryFooterIconDrawable == null) { - DeviceAdminInfo info = mSecurityController.getDeviceAdminInfo(); - mPrimaryFooterIconDrawable = mSecurityController.getIcon(info); + 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 { - mPrimaryFooterIconDrawable = null; + icon = new Icon.Resource(R.drawable.ic_info_outline); } - mMainHandler.post(mUpdatePrimaryIcon); - mMainHandler.post(mUpdateDisplayState); + return new SecurityButtonConfig(icon, text, isClickable); } @Nullable @@ -547,21 +571,21 @@ class QSSecurityFooter extends ViewController } } - private void createDialog() { + private void createDialog(Context quickSettingsContext, @Nullable View view) { mShouldUseSettingsButton.set(false); mHandler.post(() -> { String settingsButtonText = getSettingsButton(); - final View view = createDialogView(); + final View dialogView = createDialogView(); mMainHandler.post(() -> { - mDialog = new SystemUIDialog(mContext, 0); // Use mContext theme + 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(view); - if (mView.isAggregatedVisible()) { - mDialogLaunchAnimator.showFromView(mDialog, mView, new DialogCuj( + 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(); @@ -876,10 +900,10 @@ class QSSecurityFooter extends ViewController 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()); } } }; @@ -894,6 +918,14 @@ class QSSecurityFooter extends ViewController 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); + } } }; 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/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/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java index c1c0f78ecd6bc..5d08c18d08d27 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java @@ -690,6 +690,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(); From d7d2de53a1bfa6468ab93c3630d0216a9b05a733 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Tue, 23 Aug 2022 14:28:49 +0200 Subject: [PATCH 04/10] Extract QSSecurityFooterUtils out of QSSecurityFooter This CL extracts QSSecurityFooterUtils out of QSSecurityFooter to make it usable outside of QSSecurityFooter, which is a ViewController scoped to the Quick Settings fragment. This will make the utils usable outside this controller, e.g. in an Interactor/ViewModel not scope to QS. Bug: 242040009 Test: atest QSSecurityFooterTest Change-Id: If8289c9f28525f35603d03ed0ba3e646b7c06df8 --- .../android/systemui/qs/QSSecurityFooter.java | 749 +---------------- .../systemui/qs/QSSecurityFooterUtils.java | 792 ++++++++++++++++++ .../systemui/qs/QSSecurityFooterTest.java | 85 +- 3 files changed, 848 insertions(+), 778 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java index d983ece8f280a..b20d7ba333972 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooter.java @@ -15,122 +15,59 @@ */ 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.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 com.android.systemui.util.ViewController; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; - import javax.inject.Inject; import javax.inject.Named; -/** Helper class for the configuration of the QS security footer button. */ +/** ViewController for the footer actions. */ +// TODO(b/242040009): Remove this class. @QSScope public class QSSecurityFooter extends ViewController - implements OnClickListener, DialogInterface.OnClickListener, - VisibilityChangedDispatcher { + 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; @@ -152,82 +89,21 @@ public 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); 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 @@ -292,16 +168,7 @@ public class QSSecurityFooter extends ViewController // TODO(b/242040009): Remove this. public void showDeviceMonitoringDialog() { - showDeviceMonitoringDialog(mContext, mView); - } - - /** - * Show the device monitoring dialog, and expand it from {@code view} if it's not null. - * - * Important: {@code view} must be associated to the same {@link Context} as the QSFragment. - */ - public void showDeviceMonitoringDialog(Context quickSettingsContext, @Nullable View view) { - createDialog(quickSettingsContext, view); + mQSSecurityFooterUtils.showDeviceMonitoringDialog(mContext, mView); } public void refreshState() { @@ -310,7 +177,7 @@ public class QSSecurityFooter extends ViewController private void handleRefreshState() { SecurityModel securityModel = SecurityModel.create(mSecurityController); - SecurityButtonConfig buttonConfig = getButtonConfig(securityModel); + SecurityButtonConfig buttonConfig = mQSSecurityFooterUtils.getButtonConfig(securityModel); if (buttonConfig == null) { mIsVisible = false; @@ -326,577 +193,6 @@ public class QSSecurityFooter extends ViewController mMainHandler.post(mUpdateDisplayState); } - /** - * 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); - mHandler.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; - } - private final Runnable mUpdatePrimaryIcon = new Runnable() { @Override public void run() { @@ -914,7 +210,7 @@ public 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()); } @@ -961,25 +257,4 @@ public 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..43b53b5cf1913 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java @@ -0,0 +1,792 @@ +/* + * 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, UserTracker userTracker, @Main Handler mainHandler, + ActivityStarter activityStarter, SecurityController securityController, + @Background Looper bgLooper, DialogLaunchAnimator dialogLaunchAnimator) { + mContext = context; + mDpm = mContext.getSystemService(DevicePolicyManager.class); + 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/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java index 5d08c18d08d27..a156a609c0508 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,15 @@ 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(), 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 +523,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 +534,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 +547,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 +563,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 +634,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 +658,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 +667,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()); } @@ -722,7 +725,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()); } @@ -745,7 +748,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()); @@ -756,7 +759,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 @@ -776,7 +779,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(); @@ -819,8 +822,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) { @@ -828,7 +831,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; } } From c9a5847b55de00f8948299e855c26f8ca0e02369 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Fri, 19 Aug 2022 16:49:25 +0200 Subject: [PATCH 05/10] Add BroadcastDispatcher.broadcastFlow Bug: 242040009 Test: atest BroadcastDispatcherTest Change-Id: I688d686950239abab207d13d50cfb896303531d1 --- .../systemui/broadcast/BroadcastDispatcher.kt | 53 +++++++++++++++++++ .../broadcast/BroadcastDispatcherTest.kt | 43 ++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) 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/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) From 6fb22d4b1670ea73425e2b372193f7d0711d2e27 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Fri, 19 Aug 2022 15:00:36 +0200 Subject: [PATCH 06/10] Implementation of the FooterActions following the MAD (1/3) This CL provides most of the implementation of the QS FooterActions following the modern Android architecture. In particular, this CL includes: - repositories in the data layer. - an interactor in the domain layer. - a view model in the ui layer. The ViewBinder is added in ag/19674347, given that it comes together with changes to the existing XML files. Note that after this CL, the new implementation will still not be used as it won't be wired yet. This is done in ag/19674347. The highest value of the tests is in FooterActionsViewModelTest, which focuses on testing the *state* (view model) of the footer actions using the *real implementation* of the repositories, interactor and viewModel all together. To do so, I implemented a FooterActionsUtils class that allows to *easily* create real implementations of those repositories, interactor and view model, without requiring the caller to provide any parameter. I believe that this is even better than introducing new fakes, and it should hopefully lead us towards using more and more of the real implementations in our tests, and less and less of fakes/mocks, making the tests much more useful. Of course, I still had to use fake & mocks for the classes I'm calling to and for which instantiating the actual object is too painful. As you can see, there are no test files for the repositories: given that we already use the real implementations in the ViewModel tests, they are defacto already tested. I still added some tests on *interactions* (not *state*) in FooterActionsInteractorTest. Even though I believe those tests don't provide much value (they are merely a copy/paste of the implementation), I preferred keeping the same coverage as the current tests (some of which are going to be removed in ag/19674347). Note that the business logic contained in this CL was mostly copy/pasted from the current implementation, as I wanted to make sure that this is going to be a pure refactoring that does not change the logic of this feature. Still, I left some TODOs in the code for potential improvements. Bug: 242040009 Test: atest FooterActionsViewModelTest Test: atest FooterActionsInteractorTest Change-Id: Ia0bdf9824e098ad6604709f5db87576437b0a904 --- packages/SystemUI/Android.bp | 1 + .../animation/ActivityLaunchAnimator.kt | 3 +- .../android/systemui/animation/Expandable.kt | 52 +++ packages/SystemUI/compose/gallery/Android.bp | 5 + .../common/shared/model/ContentDescription.kt | 33 ++ .../systemui/common/ui/IconViewBinder.kt | 32 ++ .../qs/footer/dagger/FooterActionsModule.kt | 43 ++ .../data/model/UserSwitcherStatusModel.kt | 32 ++ .../ForegroundServicesRepository.kt | 121 ++++++ .../data/repository/UserSwitcherRepository.kt | 155 +++++++ .../interactor/FooterActionsInteractor.kt | 211 +++++++++ .../viewmodel/FooterActionsButtonViewModel.kt | 36 ++ ...ctionsForegroundServicesButtonViewModel.kt | 28 ++ .../FooterActionsSecurityButtonViewModel.kt | 27 ++ .../ui/viewmodel/FooterActionsViewModel.kt | 304 +++++++++++++ .../data/repository/SecurityRepository.kt | 58 +++ .../repository/SecurityRepositoryModule.kt | 26 ++ .../animation/ActivityLaunchAnimatorTest.kt | 10 - .../interactor/FooterActionsInteractorTest.kt | 212 +++++++++ .../viewmodel/FooterActionsViewModelTest.kt | 408 ++++++++++++++++++ .../qs/footer/FooterActionsTestUtils.kt | 172 ++++++++ .../truth/correspondence/FakeUiEvent.kt | 30 ++ .../systemui/truth/correspondence/LogMaker.kt | 29 ++ 23 files changed, 2016 insertions(+), 12 deletions(-) create mode 100644 packages/SystemUI/animation/src/com/android/systemui/animation/Expandable.kt create mode 100644 packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt create mode 100644 packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt 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/Expandable.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/Expandable.kt new file mode 100644 index 0000000000000..8ce372dbb2780 --- /dev/null +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/Expandable.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.animation + +import android.view.View + +/** A piece of UI that can be expanded into a Dialog or an Activity. */ +interface Expandable { + /** + * Create an [ActivityLaunchAnimator.Controller] that can be used to expand this [Expandable] + * into an Activity, or return `null` if this [Expandable] should not be animated (e.g. if it is + * currently not attached or visible). + * + * @param cujType the CUJ type from the [com.android.internal.jank.InteractionJankMonitor] + * associated to the launch that will use this controller. + */ + fun activityLaunchController(cujType: Int? = null): ActivityLaunchAnimator.Controller? + + // TODO(b/230830644): Introduce DialogLaunchAnimator and a function to expose it here. + + companion object { + /** + * Create an [Expandable] that will animate [view] when expanded. + * + * Note: The background of [view] should be a (rounded) rectangle so that it can be properly + * animated. + */ + fun fromView(view: View): Expandable { + return object : Expandable { + override fun activityLaunchController( + cujType: Int?, + ): ActivityLaunchAnimator.Controller? { + return ActivityLaunchAnimator.Controller.fromView(view, cujType) + } + } + } + } +} diff --git a/packages/SystemUI/compose/gallery/Android.bp b/packages/SystemUI/compose/gallery/Android.bp index b0f5cc1121209..5a7a1e1807a33 100644 --- a/packages/SystemUI/compose/gallery/Android.bp +++ b/packages/SystemUI/compose/gallery/Android.bp @@ -54,6 +54,11 @@ android_library { "testables", "truth-prebuilt", "androidx.test.uiautomator", + "kotlinx_coroutines_test", + ], + + libs: [ + "android.test.mock", ], kotlincflags: ["-Xjvm-default=all"], diff --git a/packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt b/packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt new file mode 100644 index 0000000000000..bebade0cc4845 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/shared/model/ContentDescription.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.common.shared.model + +import android.annotation.StringRes + +/** + * Models a content description, that can either be already [loaded][ContentDescription.Loaded] or + * be a [reference][ContentDescription.Resource] to a resource. + */ +sealed class ContentDescription { + data class Loaded( + val description: String?, + ) : ContentDescription() + + data class Resource( + @StringRes val res: Int, + ) : ContentDescription() +} diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt new file mode 100644 index 0000000000000..0e0d19815d7bd --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.common.ui + +import android.widget.ImageView +import com.android.systemui.common.shared.model.Icon + +object IconViewBinder { + fun bind( + icon: Icon, + view: ImageView, + ) { + when (icon) { + is Icon.Loaded -> view.setImageDrawable(icon.drawable) + is Icon.Resource -> view.setImageResource(icon.res) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt new file mode 100644 index 0000000000000..000c23d2e592c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.dagger + +import com.android.systemui.qs.FgsManagerController +import com.android.systemui.qs.FgsManagerControllerImpl +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepositoryImpl +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepositoryImpl +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractorImpl +import dagger.Binds +import dagger.Module + +/** Dagger module to provide/bind footer actions singletons. */ +@Module +interface FooterActionsModule { + @Binds fun userSwitcherRepository(impl: UserSwitcherRepositoryImpl): UserSwitcherRepository + + @Binds + fun foregroundServicesRepository( + impl: ForegroundServicesRepositoryImpl + ): ForegroundServicesRepository + + @Binds fun footerActionsInteractor(impl: FooterActionsInteractorImpl): FooterActionsInteractor + + @Binds fun fgsManagerControllerImpl(impl: FgsManagerControllerImpl): FgsManagerController +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt new file mode 100644 index 0000000000000..4ca229ab4e615 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/data/model/UserSwitcherStatusModel.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.data.model + +import android.graphics.drawable.Drawable + +/** The current status of the User Switcher. */ +sealed class UserSwitcherStatusModel { + /** The user switcher is disabled. */ + object Disabled : UserSwitcherStatusModel() + + /** The user switcher is enabled. */ + data class Enabled( + val currentUserName: String?, + val currentUserImage: Drawable?, + val isGuestUser: Boolean, + ) : UserSwitcherStatusModel() +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt new file mode 100644 index 0000000000000..37a9c40ffacfa --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/ForegroundServicesRepository.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.data.repository + +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.qs.FgsManagerController +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge + +interface ForegroundServicesRepository { + /** + * The number of packages with a service running in the foreground. + * + * Note that this will be equal to 0 if [FgsManagerController.isAvailable] is false. + */ + val foregroundServicesCount: Flow + + /** + * Whether there were new changes to the foreground packages since a dialog was last shown. + * + * Note that this will be equal to `false` if [FgsManagerController.showFooterDot] is false. + */ + val hasNewChanges: Flow +} + +@SysUISingleton +class ForegroundServicesRepositoryImpl +@Inject +constructor( + fgsManagerController: FgsManagerController, +) : ForegroundServicesRepository { + override val foregroundServicesCount: Flow = + fgsManagerController.isAvailable + .flatMapLatest { isAvailable -> + if (!isAvailable) { + return@flatMapLatest flowOf(0) + } + + conflatedCallbackFlow { + fun updateState(numberOfPackages: Int) { + trySendWithFailureLogging(numberOfPackages, TAG) + } + + val listener = + object : FgsManagerController.OnNumberOfPackagesChangedListener { + override fun onNumberOfPackagesChanged(numberOfPackages: Int) { + updateState(numberOfPackages) + } + } + + fgsManagerController.addOnNumberOfPackagesChangedListener(listener) + updateState(fgsManagerController.numRunningPackages) + awaitClose { + fgsManagerController.removeOnNumberOfPackagesChangedListener(listener) + } + } + } + .distinctUntilChanged() + + override val hasNewChanges: Flow = + fgsManagerController.showFooterDot.flatMapLatest { showFooterDot -> + if (!showFooterDot) { + return@flatMapLatest flowOf(false) + } + + // A flow that emits whenever the FGS dialog is dismissed. + val dialogDismissedEvents = conflatedCallbackFlow { + fun updateState() { + trySendWithFailureLogging( + Unit, + TAG, + ) + } + + val listener = + object : FgsManagerController.OnDialogDismissedListener { + override fun onDialogDismissed() { + updateState() + } + } + + fgsManagerController.addOnDialogDismissedListener(listener) + awaitClose { fgsManagerController.removeOnDialogDismissedListener(listener) } + } + + // Query [fgsManagerController.newChangesSinceDialogWasDismissed] everytime the dialog + // is dismissed or when [foregroundServices] is changing. + merge( + foregroundServicesCount, + dialogDismissedEvents, + ) + .map { fgsManagerController.newChangesSinceDialogWasDismissed } + .distinctUntilChanged() + } + + companion object { + private const val TAG = "ForegroundServicesRepositoryImpl" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt new file mode 100644 index 0000000000000..e969d4c6e08a7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/data/repository/UserSwitcherRepository.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.data.repository + +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.UserManager +import android.provider.Settings.Global.USER_SWITCHER_ENABLED +import com.android.keyguard.KeyguardUpdateMonitor +import com.android.systemui.R +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.qs.SettingObserver +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.settings.UserTracker +import com.android.systemui.statusbar.policy.UserInfoController +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.util.settings.GlobalSettings +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +interface UserSwitcherRepository { + /** The current [UserSwitcherStatusModel]. */ + val userSwitcherStatus: Flow +} + +@SysUISingleton +class UserSwitcherRepositoryImpl +@Inject +constructor( + @Application private val context: Context, + @Background private val bgHandler: Handler, + @Background private val bgDispatcher: CoroutineDispatcher, + private val userManager: UserManager, + private val userTracker: UserTracker, + private val userSwitcherController: UserSwitcherController, + private val userInfoController: UserInfoController, + private val globalSetting: GlobalSettings, +) : UserSwitcherRepository { + private val showUserSwitcherForSingleUser = + context.resources.getBoolean(R.bool.qs_show_user_switcher_for_single_user) + + /** Whether the user switcher is currently enabled. */ + private val isEnabled: Flow = conflatedCallbackFlow { + suspend fun updateState() { + trySendWithFailureLogging(isUserSwitcherEnabled(), TAG) + } + + val observer = + object : + SettingObserver( + globalSetting, + bgHandler, + USER_SWITCHER_ENABLED, + userTracker.userId, + ) { + override fun handleValueChanged(value: Int, observedChange: Boolean) { + if (observedChange) { + launch { updateState() } + } + } + } + + observer.isListening = true + updateState() + awaitClose { observer.isListening = false } + } + + /** The current user name. */ + private val currentUserName: Flow = conflatedCallbackFlow { + suspend fun updateState() { + trySendWithFailureLogging(getCurrentUser(), TAG) + } + + val callback = UserSwitcherController.UserSwitchCallback { launch { updateState() } } + + userSwitcherController.addUserSwitchCallback(callback) + updateState() + awaitClose { userSwitcherController.removeUserSwitchCallback(callback) } + } + + /** The current (icon, isGuestUser) values. */ + // TODO(b/242040009): Could we only use this callback to get the user name and remove + // currentUsername above? + private val currentUserInfo: Flow> = conflatedCallbackFlow { + val listener = + UserInfoController.OnUserInfoChangedListener { _, picture, _ -> + launch { trySendWithFailureLogging(picture to isGuestUser(), TAG) } + } + + // This will automatically call the listener when attached, so no need to update the state + // here. + userInfoController.addCallback(listener) + awaitClose { userInfoController.removeCallback(listener) } + } + + override val userSwitcherStatus: Flow = + isEnabled + .flatMapLatest { enabled -> + if (enabled) { + combine(currentUserName, currentUserInfo) { name, (icon, isGuest) -> + UserSwitcherStatusModel.Enabled(name, icon, isGuest) + } + } else { + flowOf(UserSwitcherStatusModel.Disabled) + } + } + .distinctUntilChanged() + + private suspend fun isUserSwitcherEnabled(): Boolean { + return withContext(bgDispatcher) { + userManager.isUserSwitcherEnabled(showUserSwitcherForSingleUser) + } + } + + private suspend fun getCurrentUser(): String? { + return withContext(bgDispatcher) { userSwitcherController.currentUserName } + } + + private suspend fun isGuestUser(): Boolean { + return withContext(bgDispatcher) { + userManager.isGuestUser(KeyguardUpdateMonitor.getCurrentUser()) + } + } + + companion object { + private const val TAG = "UserSwitcherRepositoryImpl" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt new file mode 100644 index 0000000000000..cf9b41c25388e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractor.kt @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.domain.interactor + +import android.app.admin.DevicePolicyEventLogger +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.UserHandle +import android.provider.Settings +import android.view.View +import com.android.internal.jank.InteractionJankMonitor +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.nano.MetricsProto +import com.android.internal.util.FrameworkStatsLog +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.animation.Expandable +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.qs.FgsManagerController +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig +import com.android.systemui.qs.user.UserSwitchDialogController +import com.android.systemui.security.data.repository.SecurityRepository +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.user.UserSwitcherActivity +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +/** Interactor for the footer actions business logic. */ +interface FooterActionsInteractor { + /** The current [SecurityButtonConfig]. */ + val securityButtonConfig: Flow + + /** The number of packages with a service running in the foreground. */ + val foregroundServicesCount: Flow + + /** Whether there are new packages with a service running in the foreground. */ + val hasNewForegroundServices: Flow + + /** The current [UserSwitcherStatusModel]. */ + val userSwitcherStatus: Flow + + /** + * The flow emitting `Unit` whenever a request to show the device monitoring dialog is fired. + */ + val deviceMonitoringDialogRequests: Flow + + /** + * Show the device monitoring dialog, expanded from [view]. + * + * Important: [view] must be associated to the same [Context] as the [Quick Settings fragment] + * [com.android.systemui.qs.QSFragment]. + */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showDeviceMonitoringDialog(view: View) + + /** + * Show the device monitoring dialog. + * + * Important: [quickSettingsContext] *must* be the [Context] associated to the [Quick Settings + * fragment][com.android.systemui.qs.QSFragment]. + */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showDeviceMonitoringDialog(quickSettingsContext: Context) + + /** Show the foreground services dialog. */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showForegroundServicesDialog(view: View) + + /** Show the power menu dialog. */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showPowerMenuDialog(globalActionsDialogLite: GlobalActionsDialogLite, view: View) + + /** Show the settings. */ + fun showSettings(expandable: Expandable) + + /** Show the user switcher. */ + // TODO(b/230830644): Replace view by Expandable interface. + fun showUserSwitcher(view: View) +} + +@SysUISingleton +class FooterActionsInteractorImpl +@Inject +constructor( + private val activityStarter: ActivityStarter, + private val featureFlags: FeatureFlags, + private val metricsLogger: MetricsLogger, + private val uiEventLogger: UiEventLogger, + private val deviceProvisionedController: DeviceProvisionedController, + private val qsSecurityFooterUtils: QSSecurityFooterUtils, + private val fgsManagerController: FgsManagerController, + private val userSwitchDialogController: UserSwitchDialogController, + securityRepository: SecurityRepository, + foregroundServicesRepository: ForegroundServicesRepository, + userSwitcherRepository: UserSwitcherRepository, + broadcastDispatcher: BroadcastDispatcher, + @Background bgDispatcher: CoroutineDispatcher, +) : FooterActionsInteractor { + override val securityButtonConfig: Flow = + securityRepository.security.map { security -> + withContext(bgDispatcher) { qsSecurityFooterUtils.getButtonConfig(security) } + } + + override val foregroundServicesCount: Flow = + foregroundServicesRepository.foregroundServicesCount + + override val hasNewForegroundServices: Flow = + foregroundServicesRepository.hasNewChanges + + override val userSwitcherStatus: Flow = + userSwitcherRepository.userSwitcherStatus + + override val deviceMonitoringDialogRequests: Flow = + broadcastDispatcher.broadcastFlow( + IntentFilter(DevicePolicyManager.ACTION_SHOW_DEVICE_MONITORING_DIALOG), + UserHandle.ALL, + Context.RECEIVER_EXPORTED, + null, + ) + + override fun showDeviceMonitoringDialog(view: View) { + qsSecurityFooterUtils.showDeviceMonitoringDialog(view.context, view) + DevicePolicyEventLogger.createEvent( + FrameworkStatsLog.DEVICE_POLICY_EVENT__EVENT_ID__DO_USER_INFO_CLICKED + ) + .write() + } + + override fun showDeviceMonitoringDialog(quickSettingsContext: Context) { + qsSecurityFooterUtils.showDeviceMonitoringDialog(quickSettingsContext, /* view= */ null) + } + + override fun showForegroundServicesDialog(view: View) { + fgsManagerController.showDialog(view) + } + + override fun showPowerMenuDialog(globalActionsDialogLite: GlobalActionsDialogLite, view: View) { + uiEventLogger.log(GlobalActionsDialogLite.GlobalActionsEvent.GA_OPEN_QS) + globalActionsDialogLite.showOrHideDialog( + /* keyguardShowing= */ false, + /* isDeviceProvisioned= */ true, + view, + ) + } + + override fun showSettings(expandable: Expandable) { + if (!deviceProvisionedController.isCurrentUserSetup) { + // If user isn't setup just unlock the device and dump them back at SUW. + activityStarter.postQSRunnableDismissingKeyguard {} + return + } + + metricsLogger.action(MetricsProto.MetricsEvent.ACTION_QS_EXPANDED_SETTINGS_LAUNCH) + activityStarter.startActivity( + Intent(Settings.ACTION_SETTINGS), + true /* dismissShade */, + expandable.activityLaunchController( + InteractionJankMonitor.CUJ_SHADE_APP_LAUNCH_FROM_SETTINGS_BUTTON + ), + ) + } + + override fun showUserSwitcher(view: View) { + if (!featureFlags.isEnabled(Flags.FULL_SCREEN_USER_SWITCHER)) { + userSwitchDialogController.showDialog(view) + return + } + + val intent = + Intent(view.context, UserSwitcherActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + } + + activityStarter.startActivity( + intent, + true /* dismissShade */, + ActivityLaunchAnimator.Controller.fromView(view, null), + true /* showOverlockscreenwhenlocked */, + UserHandle.SYSTEM, + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt new file mode 100644 index 0000000000000..4c0879e225c16 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsButtonViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.ui.viewmodel + +import android.annotation.DrawableRes +import android.view.View +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon + +/** + * A ViewModel for a simple footer actions button. This is used for the user switcher, settings and + * power buttons. + */ +data class FooterActionsButtonViewModel( + val icon: Icon, + val iconTint: Int?, + @DrawableRes val background: Int, + val contentDescription: ContentDescription, + // TODO(b/230830644): Replace View by an Expandable interface that can expand in either dialog + // or activity. + val onClick: (View) -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt new file mode 100644 index 0000000000000..98b53cb0ed5a5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsForegroundServicesButtonViewModel.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.ui.viewmodel + +import android.view.View + +/** A ViewModel for the foreground services button. */ +data class FooterActionsForegroundServicesButtonViewModel( + val foregroundServicesCount: Int, + val text: String, + val displayText: Boolean, + val hasNewChanges: Boolean, + val onClick: (View) -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt new file mode 100644 index 0000000000000..98ab129fc9deb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsSecurityButtonViewModel.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.ui.viewmodel + +import android.view.View +import com.android.systemui.common.shared.model.Icon + +/** A ViewModel for the security button. */ +data class FooterActionsSecurityButtonViewModel( + val icon: Icon, + val text: String, + val onClick: ((View) -> Unit)?, +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt new file mode 100644 index 0000000000000..8afe6f216cbb4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.ui.viewmodel + +import android.content.Context +import android.util.Log +import android.view.View +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.android.settingslib.Utils +import com.android.settingslib.drawable.UserIconDrawable +import com.android.systemui.R +import com.android.systemui.animation.Expandable +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.FalsingManager +import com.android.systemui.qs.dagger.QSFlagsModule.PM_LITE_ENABLED +import com.android.systemui.qs.footer.data.model.UserSwitcherStatusModel +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.util.icuMessageFormat +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider +import kotlin.math.max +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** A ViewModel for the footer actions. */ +class FooterActionsViewModel( + @Application private val context: Context, + private val footerActionsInteractor: FooterActionsInteractor, + private val falsingManager: FalsingManager, + private val globalActionsDialogLite: GlobalActionsDialogLite, + showPowerButton: Boolean, +) { + /** + * Whether the UI rendering this ViewModel should be visible. Note that even when this is false, + * the UI should still participate to the layout it is included in (i.e. in the View world it + * should be INVISIBLE, not GONE). + */ + private val _isVisible = MutableStateFlow(true) + val isVisible: StateFlow = _isVisible.asStateFlow() + + /** The alpha the UI rendering this ViewModel should have. */ + private val _alpha = MutableStateFlow(1f) + val alpha: StateFlow = _alpha.asStateFlow() + + /** The alpha the background of the UI rendering this ViewModel should have. */ + private val _backgroundAlpha = MutableStateFlow(1f) + val backgroundAlpha: StateFlow = _backgroundAlpha.asStateFlow() + + /** The model for the security button. */ + val security: Flow = + footerActionsInteractor.securityButtonConfig.map { config -> + val (icon, text, isClickable) = config ?: return@map null + FooterActionsSecurityButtonViewModel( + icon, + text, + if (isClickable) this::onSecurityButtonClicked else null, + ) + } + + /** The model for the foreground services button. */ + val foregroundServices: Flow = + combine( + footerActionsInteractor.foregroundServicesCount, + footerActionsInteractor.hasNewForegroundServices, + security, + ) { foregroundServicesCount, hasNewChanges, securityModel -> + if (foregroundServicesCount <= 0) { + return@combine null + } + + val text = + icuMessageFormat( + context.resources, + R.string.fgs_manager_footer_label, + foregroundServicesCount, + ) + FooterActionsForegroundServicesButtonViewModel( + foregroundServicesCount, + text = text, + displayText = securityModel == null, + hasNewChanges = hasNewChanges, + this::onForegroundServiceButtonClicked, + ) + } + + /** The model for the user switcher button. */ + val userSwitcher: Flow = + footerActionsInteractor.userSwitcherStatus.map { userSwitcherStatus -> + when (userSwitcherStatus) { + UserSwitcherStatusModel.Disabled -> null + is UserSwitcherStatusModel.Enabled -> { + if (userSwitcherStatus.currentUserImage == null) { + Log.e( + TAG, + "Skipped the addition of user switcher button because " + + "currentUserImage is missing", + ) + return@map null + } + + userSwitcherButton(userSwitcherStatus) + } + } + } + + /** The model for the settings button. */ + val settings: FooterActionsButtonViewModel = + FooterActionsButtonViewModel( + Icon.Resource(R.drawable.ic_settings), + iconTint = null, + R.drawable.qs_footer_action_circle, + ContentDescription.Resource(R.string.accessibility_quick_settings_settings), + this::onSettingsButtonClicked, + ) + + /** The model for the power button. */ + val power: FooterActionsButtonViewModel? = + if (showPowerButton) { + FooterActionsButtonViewModel( + Icon.Resource(android.R.drawable.ic_lock_power_off), + iconTint = + Utils.getColorAttrDefaultColor( + context, + com.android.internal.R.attr.textColorOnAccent, + ), + R.drawable.qs_footer_action_circle_color, + ContentDescription.Resource(R.string.accessibility_quick_settings_power_menu), + this::onPowerButtonClicked, + ) + } else { + null + } + + /** Called when the visibility of the UI rendering this model should be changed. */ + fun onVisibilityChangeRequested(visible: Boolean) { + _isVisible.value = visible + } + + /** Called when the expansion of the Quick Settings changed. */ + fun onQuickSettingsExpansionChanged(expansion: Float, isInSplitShade: Boolean) { + if (isInSplitShade) { + // In split shade, we want to fade in the background only at the very end (see + // b/240563302). + val delay = 0.99f + _alpha.value = expansion + _backgroundAlpha.value = max(0f, expansion - delay) / (1f - delay) + } else { + // Only start fading in the footer actions when we are at least 90% expanded. + val delay = 0.9f + _alpha.value = max(0f, expansion - delay) / (1 - delay) + _backgroundAlpha.value = 1f + } + } + + /** + * Observe the device monitoring dialog requests and show the dialog accordingly. This function + * will suspend indefinitely and will need to be cancelled to stop observing. + * + * Important: [quickSettingsContext] must be the [Context] associated to the [Quick Settings + * fragment][com.android.systemui.qs.QSFragment], and the call to this function must be + * cancelled when that fragment is destroyed. + */ + suspend fun observeDeviceMonitoringDialogRequests(quickSettingsContext: Context) { + footerActionsInteractor.deviceMonitoringDialogRequests.collect { + footerActionsInteractor.showDeviceMonitoringDialog(quickSettingsContext) + } + } + + private fun onSecurityButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showDeviceMonitoringDialog(view) + } + + private fun onForegroundServiceButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showForegroundServicesDialog(view) + } + + private fun onUserSwitcherClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showUserSwitcher(view) + } + + // TODO(b/230830644): Replace View by an Expandable interface that can expand in either dialog + // or activity. + private fun onSettingsButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showSettings(Expandable.fromView(view)) + } + + private fun onPowerButtonClicked(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + footerActionsInteractor.showPowerMenuDialog(globalActionsDialogLite, view) + } + + private fun userSwitcherButton( + status: UserSwitcherStatusModel.Enabled + ): FooterActionsButtonViewModel { + val icon = status.currentUserImage!! + val iconTint = + if (status.isGuestUser && icon !is UserIconDrawable) { + Utils.getColorAttrDefaultColor(context, android.R.attr.colorForeground) + } else { + null + } + + return FooterActionsButtonViewModel( + Icon.Loaded(icon), + iconTint, + R.drawable.qs_footer_action_circle, + ContentDescription.Loaded(userSwitcherContentDescription(status.currentUserName)), + this::onUserSwitcherClicked, + ) + } + + private fun userSwitcherContentDescription(currentUser: String?): String? { + return currentUser?.let { user -> + context.getString(R.string.accessibility_quick_settings_user, user) + } + } + + @SysUISingleton + class Factory + @Inject + constructor( + @Application private val context: Context, + private val falsingManager: FalsingManager, + private val footerActionsInteractor: FooterActionsInteractor, + private val globalActionsDialogLiteProvider: Provider, + @Named(PM_LITE_ENABLED) private val showPowerButton: Boolean, + ) { + /** Create a [FooterActionsViewModel] bound to the lifecycle of [lifecycleOwner]. */ + fun create(lifecycleOwner: LifecycleOwner): FooterActionsViewModel { + val globalActionsDialogLite = globalActionsDialogLiteProvider.get() + if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) { + // This should usually not happen, but let's make sure we already destroy + // globalActionsDialogLite. + globalActionsDialogLite.destroy() + } else { + // Destroy globalActionsDialogLite when the lifecycle is destroyed. + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + globalActionsDialogLite.destroy() + } + } + ) + } + + return FooterActionsViewModel( + context, + footerActionsInteractor, + falsingManager, + globalActionsDialogLite, + showPowerButton, + ) + } + } + + companion object { + private const val TAG = "FooterActionsViewModel" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt new file mode 100644 index 0000000000000..8f4402eaa406d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepository.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.security.data.repository + +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.security.data.model.SecurityModel +import com.android.systemui.statusbar.policy.SecurityController +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +interface SecurityRepository { + /** The current [SecurityModel]. */ + val security: Flow +} + +@SysUISingleton +class SecurityRepositoryImpl +@Inject +constructor( + private val securityController: SecurityController, + @Background private val bgDispatcher: CoroutineDispatcher, +) : SecurityRepository { + override val security: Flow = conflatedCallbackFlow { + suspend fun updateState() { + trySendWithFailureLogging(SecurityModel.create(securityController, bgDispatcher), TAG) + } + + val callback = SecurityController.SecurityControllerCallback { launch { updateState() } } + + securityController.addCallback(callback) + updateState() + awaitClose { securityController.removeCallback(callback) } + } + + companion object { + private const val TAG = "SecurityRepositoryImpl" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt new file mode 100644 index 0000000000000..39a57cafaeccc --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/security/data/repository/SecurityRepositoryModule.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.security.data.repository + +import dagger.Binds +import dagger.Module + +/** Dagger module to provide/bind security repositories. */ +@Module +interface SecurityRepositoryModule { + @Binds fun securityRepository(impl: SecurityRepositoryImpl): SecurityRepository +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt index 0f112415df0d4..e2790e47fe06e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/animation/ActivityLaunchAnimatorTest.kt @@ -10,12 +10,10 @@ import android.graphics.Rect import android.os.Looper import android.testing.AndroidTestingRunner import android.testing.TestableLooper.RunWithLooper -import android.util.Log import android.view.IRemoteAnimationFinishedCallback import android.view.RemoteAnimationAdapter import android.view.RemoteAnimationTarget import android.view.SurfaceControl -import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.test.filters.SmallTest @@ -51,7 +49,6 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() { @Mock lateinit var listener: ActivityLaunchAnimator.Listener @Spy private val controller = TestLaunchAnimatorController(launchContainer) @Mock lateinit var iCallback: IRemoteAnimationFinishedCallback - @Mock lateinit var failHandler: Log.TerribleFailureHandler private lateinit var activityLaunchAnimator: ActivityLaunchAnimator @get:Rule val rule = MockitoJUnit.rule() @@ -187,13 +184,6 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() { verify(controller).onLaunchAnimationStart(anyBoolean()) } - @Test - fun controllerFromOrphanViewReturnsNullAndIsATerribleFailure() { - Log.setWtfHandler(failHandler) - assertNull(ActivityLaunchAnimator.Controller.fromView(View(mContext))) - verify(failHandler).onTerribleFailure(any(), any(), anyBoolean()) - } - private fun fakeWindow(): RemoteAnimationTarget { val bounds = Rect(10 /* left */, 20 /* top */, 30 /* right */, 40 /* bottom */) val taskInfo = ActivityManager.RunningTaskInfo() diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt new file mode 100644 index 0000000000000..3c258077c29d6 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/domain/interactor/FooterActionsInteractorTest.kt @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.domain.interactor + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.UserHandle +import android.provider.Settings +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.view.View +import androidx.test.filters.SmallTest +import com.android.internal.logging.nano.MetricsProto +import com.android.internal.logging.testing.FakeMetricsLogger +import com.android.internal.logging.testing.UiEventLoggerFake +import com.android.systemui.SysuiTestCase +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.FooterActionsTestUtils +import com.android.systemui.qs.user.UserSwitchDialogController +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.truth.correspondence.FakeUiEvent +import com.android.systemui.truth.correspondence.LogMaker +import com.android.systemui.user.UserSwitcherActivity +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.argumentCaptor +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.nullable +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +class FooterActionsInteractorTest : SysuiTestCase() { + private lateinit var utils: FooterActionsTestUtils + + @Before + fun setUp() { + utils = FooterActionsTestUtils(context, TestableLooper.get(this)) + } + + @Test + fun showDeviceMonitoringDialog() { + val qsSecurityFooterUtils = mock() + val underTest = utils.footerActionsInteractor(qsSecurityFooterUtils = qsSecurityFooterUtils) + + val quickSettingsContext = mock() + underTest.showDeviceMonitoringDialog(quickSettingsContext) + verify(qsSecurityFooterUtils).showDeviceMonitoringDialog(quickSettingsContext, null) + + val view = mock() + whenever(view.context).thenReturn(quickSettingsContext) + underTest.showDeviceMonitoringDialog(view) + verify(qsSecurityFooterUtils).showDeviceMonitoringDialog(quickSettingsContext, null) + } + + @Test + fun showPowerMenuDialog() { + val uiEventLogger = UiEventLoggerFake() + val underTest = utils.footerActionsInteractor(uiEventLogger = uiEventLogger) + + val globalActionsDialogLite = mock() + val view = mock() + underTest.showPowerMenuDialog(globalActionsDialogLite, view) + + // Event is logged. + val logs = uiEventLogger.logs + assertThat(logs) + .comparingElementsUsing(FakeUiEvent.EVENT_ID) + .containsExactly(GlobalActionsDialogLite.GlobalActionsEvent.GA_OPEN_QS.id) + + // Dialog is shown. + verify(globalActionsDialogLite) + .showOrHideDialog( + /* keyguardShowing= */ false, + /* isDeviceProvisioned= */ true, + view, + ) + } + + @Test + fun showSettings_userSetUp() { + val activityStarter = mock() + val deviceProvisionedController = mock() + val metricsLogger = FakeMetricsLogger() + + // User is set up. + whenever(deviceProvisionedController.isCurrentUserSetup).thenReturn(true) + + val underTest = + utils.footerActionsInteractor( + activityStarter = activityStarter, + deviceProvisionedController = deviceProvisionedController, + metricsLogger = metricsLogger, + ) + + underTest.showSettings(mock()) + + // Event is logged. + assertThat(metricsLogger.logs.toList()) + .comparingElementsUsing(LogMaker.CATEGORY) + .containsExactly(MetricsProto.MetricsEvent.ACTION_QS_EXPANDED_SETTINGS_LAUNCH) + + // Activity is started. + val intentCaptor = argumentCaptor() + verify(activityStarter) + .startActivity( + intentCaptor.capture(), + /* dismissShade= */ eq(true), + nullable() as? ActivityLaunchAnimator.Controller, + ) + assertThat(intentCaptor.value.action).isEqualTo(Settings.ACTION_SETTINGS) + } + + @Test + fun showSettings_userNotSetUp() { + val activityStarter = mock() + val deviceProvisionedController = mock() + + // User is not set up. + whenever(deviceProvisionedController.isCurrentUserSetup).thenReturn(false) + + val underTest = + utils.footerActionsInteractor( + activityStarter = activityStarter, + deviceProvisionedController = deviceProvisionedController, + ) + + underTest.showSettings(mock()) + + // We only unlock the device. + verify(activityStarter).postQSRunnableDismissingKeyguard(any()) + } + + @Test + fun showUserSwitcher_fullScreenDisabled() { + val featureFlags = FakeFeatureFlags().apply { set(Flags.FULL_SCREEN_USER_SWITCHER, false) } + val userSwitchDialogController = mock() + val underTest = + utils.footerActionsInteractor( + featureFlags = featureFlags, + userSwitchDialogController = userSwitchDialogController, + ) + + val view = mock() + underTest.showUserSwitcher(view) + + // Dialog is shown. + verify(userSwitchDialogController).showDialog(view) + } + + @Test + fun showUserSwitcher_fullScreenEnabled() { + val featureFlags = FakeFeatureFlags().apply { set(Flags.FULL_SCREEN_USER_SWITCHER, true) } + val activityStarter = mock() + val underTest = + utils.footerActionsInteractor( + featureFlags = featureFlags, + activityStarter = activityStarter, + ) + + // The clicked view. The context is necessary because it's used to build the intent, that + // we check below. + val view = mock() + whenever(view.context).thenReturn(context) + + underTest.showUserSwitcher(view) + + // Dialog is shown. + val intentCaptor = argumentCaptor() + verify(activityStarter) + .startActivity( + intentCaptor.capture(), + /* dismissShade= */ eq(true), + /* ActivityLaunchAnimator.Controller= */ nullable(), + /* showOverLockscreenWhenLocked= */ eq(true), + eq(UserHandle.SYSTEM), + ) + assertThat(intentCaptor.value.component) + .isEqualTo( + ComponentName( + context, + UserSwitcherActivity::class.java, + ) + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt new file mode 100644 index 0000000000000..e4751d1350351 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModelTest.kt @@ -0,0 +1,408 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer.ui.viewmodel + +import android.graphics.drawable.Drawable +import android.os.UserManager +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.testing.TestableLooper.RunWithLooper +import androidx.test.filters.SmallTest +import com.android.settingslib.Utils +import com.android.settingslib.drawable.UserIconDrawable +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.qs.FakeFgsManagerController +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.FooterActionsTestUtils +import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig +import com.android.systemui.security.data.model.SecurityModel +import com.android.systemui.settings.FakeUserTracker +import com.android.systemui.statusbar.policy.FakeSecurityController +import com.android.systemui.statusbar.policy.FakeUserInfoController +import com.android.systemui.statusbar.policy.FakeUserInfoController.FakeInfo +import com.android.systemui.statusbar.policy.MockUserSwitcherControllerWrapper +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.nullable +import com.android.systemui.util.settings.FakeSettings +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.`when` as whenever + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@RunWithLooper +class FooterActionsViewModelTest : SysuiTestCase() { + private lateinit var utils: FooterActionsTestUtils + + @Before + fun setUp() { + utils = FooterActionsTestUtils(context, TestableLooper.get(this)) + } + + @Test + fun settingsButton() = runBlockingTest { + val underTest = utils.footerActionsViewModel(showPowerButton = false) + val settings = underTest.settings + + assertThat(settings.contentDescription) + .isEqualTo(ContentDescription.Resource(R.string.accessibility_quick_settings_settings)) + assertThat(settings.icon).isEqualTo(Icon.Resource(R.drawable.ic_settings)) + assertThat(settings.background).isEqualTo(R.drawable.qs_footer_action_circle) + assertThat(settings.iconTint).isNull() + } + + @Test + fun powerButton() = runBlockingTest { + // Without power button. + val underTestWithoutPower = utils.footerActionsViewModel(showPowerButton = false) + assertThat(underTestWithoutPower.power).isNull() + + // With power button. + val underTestWithPower = utils.footerActionsViewModel(showPowerButton = true) + val power = underTestWithPower.power + assertThat(power).isNotNull() + assertThat(power!!.contentDescription) + .isEqualTo( + ContentDescription.Resource(R.string.accessibility_quick_settings_power_menu) + ) + assertThat(power.icon).isEqualTo(Icon.Resource(android.R.drawable.ic_lock_power_off)) + assertThat(power.background).isEqualTo(R.drawable.qs_footer_action_circle_color) + assertThat(power.iconTint) + .isEqualTo( + Utils.getColorAttrDefaultColor( + context, + com.android.internal.R.attr.textColorOnAccent, + ), + ) + } + + @Test + fun userSwitcher() = runBlockingTest { + val picture: Drawable = mock() + val userInfoController = FakeUserInfoController(FakeInfo(picture = picture)) + val settings = FakeSettings() + val userId = 42 + val userTracker = FakeUserTracker(userId) + val userSwitcherControllerWrapper = + MockUserSwitcherControllerWrapper(currentUserName = "foo") + + // Mock UserManager. + val userManager = mock() + var isUserSwitcherEnabled = false + var isGuestUser = false + whenever(userManager.isUserSwitcherEnabled(any())).thenAnswer { isUserSwitcherEnabled } + whenever(userManager.isGuestUser(any())).thenAnswer { isGuestUser } + + val underTest = + utils.footerActionsViewModel( + showPowerButton = false, + footerActionsInteractor = + utils.footerActionsInteractor( + userSwitcherRepository = + utils.userSwitcherRepository( + userTracker = userTracker, + settings = settings, + userManager = userManager, + userInfoController = userInfoController, + userSwitcherController = userSwitcherControllerWrapper.controller, + ), + ) + ) + + // Collect the user switcher into currentUserSwitcher. + var currentUserSwitcher: FooterActionsButtonViewModel? = null + val job = launch { underTest.userSwitcher.collect { currentUserSwitcher = it } } + fun currentUserSwitcher(): FooterActionsButtonViewModel? { + // Make sure we finish collecting the current user switcher. This is necessary because + // combined flows launch multiple coroutines in the current scope so we need to make + // sure we process all coroutines triggered by our flow collection before we make + // assertions on the current buttons. + advanceUntilIdle() + return currentUserSwitcher + } + + // The user switcher is disabled. + assertThat(currentUserSwitcher()).isNull() + + // Make the user manager return that the User Switcher is enabled. A change of the setting + // for the current user will be fired to notify us of that change. + isUserSwitcherEnabled = true + + // Update the setting for a random user: nothing should change, given that at this point we + // weren't notified of the change yet. + utils.setUserSwitcherEnabled(settings, true, 3) + assertThat(currentUserSwitcher()).isNull() + + // Update the setting for the observed user: now we will be notified and the button should + // be there. + utils.setUserSwitcherEnabled(settings, true, userId) + val userSwitcher = currentUserSwitcher() + assertThat(userSwitcher).isNotNull() + assertThat(userSwitcher!!.contentDescription) + .isEqualTo(ContentDescription.Loaded("Signed in as foo")) + assertThat(userSwitcher.icon).isEqualTo(Icon.Loaded(picture)) + assertThat(userSwitcher.background).isEqualTo(R.drawable.qs_footer_action_circle) + + // Change the current user name. + userSwitcherControllerWrapper.currentUserName = "bar" + assertThat(currentUserSwitcher()?.contentDescription) + .isEqualTo(ContentDescription.Loaded("Signed in as bar")) + + fun iconTint(): Int? = currentUserSwitcher()!!.iconTint + + // We tint the icon if the current user is not the guest. + assertThat(iconTint()).isNull() + + // Make the UserManager return that the current user is the guest. A change of the user + // info will be fired to notify us of that change. + isGuestUser = true + + // At this point, there was no change of the user info yet so we still didn't pick the + // UserManager change. + assertThat(iconTint()).isNull() + + // Trigger a user info change: there should now be a tint. + userInfoController.updateInfo { userAccount = "doe" } + assertThat(iconTint()) + .isEqualTo( + Utils.getColorAttrDefaultColor( + context, + android.R.attr.colorForeground, + ) + ) + + // Make sure we don't tint the icon if it is a user image (and not the default image), even + // in guest mode. + userInfoController.updateInfo { this.picture = mock() } + assertThat(iconTint()).isNull() + + job.cancel() + } + + @Test + fun security() = runBlockingTest { + val securityController = FakeSecurityController() + val qsSecurityFooterUtils = mock() + + // Mock QSSecurityFooter to map a SecurityModel into a SecurityButtonConfig using the + // logic in securityToConfig. + var securityToConfig: (SecurityModel) -> SecurityButtonConfig? = { null } + whenever(qsSecurityFooterUtils.getButtonConfig(any())).thenAnswer { + securityToConfig(it.arguments.first() as SecurityModel) + } + + val underTest = + utils.footerActionsViewModel( + footerActionsInteractor = + utils.footerActionsInteractor( + qsSecurityFooterUtils = qsSecurityFooterUtils, + securityRepository = + utils.securityRepository( + securityController = securityController, + ), + ), + ) + + // Collect the security model into currentSecurity. + var currentSecurity: FooterActionsSecurityButtonViewModel? = null + val job = launch { underTest.security.collect { currentSecurity = it } } + fun currentSecurity(): FooterActionsSecurityButtonViewModel? { + advanceUntilIdle() + return currentSecurity + } + + // By default, we always return a null SecurityButtonConfig. + assertThat(currentSecurity()).isNull() + + // Map any SecurityModel into a non-null SecurityButtonConfig. + val buttonConfig = + SecurityButtonConfig( + icon = Icon.Resource(0), + text = "foo", + isClickable = true, + ) + securityToConfig = { buttonConfig } + + // There was no change of the security info yet, so the mapper was not called yet. + assertThat(currentSecurity()).isNull() + + // Trigger a SecurityModel change, which will call the mapper and add a button. + securityController.updateState {} + var security = currentSecurity() + assertThat(security).isNotNull() + assertThat(security!!.icon).isEqualTo(buttonConfig.icon) + assertThat(security.text).isEqualTo(buttonConfig.text) + assertThat(security.onClick).isNotNull() + + // If the config.clickable = false, then onClick should be null. + securityToConfig = { buttonConfig.copy(isClickable = false) } + securityController.updateState {} + security = currentSecurity() + assertThat(security).isNotNull() + assertThat(security!!.onClick).isNull() + + job.cancel() + } + + @Test + fun foregroundServices() = runBlockingTest { + val securityController = FakeSecurityController() + val fgsManagerController = + FakeFgsManagerController( + isAvailable = true, + showFooterDot = false, + numRunningPackages = 0, + ) + val qsSecurityFooterUtils = mock() + + // Mock QSSecurityFooter to map a SecurityModel into a SecurityButtonConfig using the + // logic in securityToConfig. + var securityToConfig: (SecurityModel) -> SecurityButtonConfig? = { null } + whenever(qsSecurityFooterUtils.getButtonConfig(any())).thenAnswer { + securityToConfig(it.arguments.first() as SecurityModel) + } + + val underTest = + utils.footerActionsViewModel( + footerActionsInteractor = + utils.footerActionsInteractor( + qsSecurityFooterUtils = qsSecurityFooterUtils, + securityRepository = utils.securityRepository(securityController), + foregroundServicesRepository = + utils.foregroundServicesRepository(fgsManagerController), + ), + ) + + // Collect the security model into currentSecurity. + var currentForegroundServices: FooterActionsForegroundServicesButtonViewModel? = null + val job = launch { underTest.foregroundServices.collect { currentForegroundServices = it } } + fun currentForegroundServices(): FooterActionsForegroundServicesButtonViewModel? { + advanceUntilIdle() + return currentForegroundServices + } + + // We don't show the foreground services button if the number of running packages is not + // > 1. + assertThat(currentForegroundServices()).isNull() + + // We show it at soon as the number of services is at least 1. Given that there is no + // security, it should be displayed with text. + fgsManagerController.numRunningPackages = 1 + val foregroundServices = currentForegroundServices() + assertThat(foregroundServices).isNotNull() + assertThat(foregroundServices!!.foregroundServicesCount).isEqualTo(1) + assertThat(foregroundServices.text).isEqualTo("1 app is active") + assertThat(foregroundServices.displayText).isTrue() + assertThat(foregroundServices.onClick).isNotNull() + + // We handle plurals correctly. + fgsManagerController.numRunningPackages = 3 + assertThat(currentForegroundServices()?.text).isEqualTo("3 apps are active") + + // Showing new changes (the footer dot) is currently disabled. + assertThat(foregroundServices.hasNewChanges).isFalse() + + // Enabling it will show the new changes. + fgsManagerController.showFooterDot.value = true + assertThat(currentForegroundServices()?.hasNewChanges).isTrue() + + // Dismissing the dialog should remove the new changes dot. + fgsManagerController.simulateDialogDismiss() + assertThat(currentForegroundServices()?.hasNewChanges).isFalse() + + // Showing the security button will make this show as a simple button without text. + assertThat(foregroundServices.displayText).isTrue() + securityToConfig = { + SecurityButtonConfig( + icon = Icon.Resource(0), + text = "foo", + isClickable = true, + ) + } + securityController.updateState {} + assertThat(currentForegroundServices()?.displayText).isFalse() + + job.cancel() + } + + @Test + fun observeDeviceMonitoringDialogRequests() = runBlockingTest { + val qsSecurityFooterUtils = mock() + val broadcastDispatcher = mock() + + // Return a fake broadcastFlow that emits 3 fake events when collected. + val broadcastFlow = flowOf(Unit, Unit, Unit) + whenever( + broadcastDispatcher.broadcastFlow( + any(), + nullable(), + anyInt(), + nullable(), + ) + ) + .thenAnswer { broadcastFlow } + + // Increment nDialogRequests whenever a request to show the dialog is made by the + // FooterActionsInteractor. + var nDialogRequests = 0 + whenever(qsSecurityFooterUtils.showDeviceMonitoringDialog(any(), nullable())).then { + nDialogRequests++ + } + + val underTest = + utils.footerActionsViewModel( + footerActionsInteractor = + utils.footerActionsInteractor( + qsSecurityFooterUtils = qsSecurityFooterUtils, + broadcastDispatcher = broadcastDispatcher, + ), + ) + + val job = launch { + underTest.observeDeviceMonitoringDialogRequests(quickSettingsContext = mock()) + } + + advanceUntilIdle() + assertThat(nDialogRequests).isEqualTo(3) + + job.cancel() + } + + @Test + fun isVisible() { + val underTest = utils.footerActionsViewModel() + assertThat(underTest.isVisible.value).isTrue() + + underTest.onVisibilityChangeRequested(visible = false) + assertThat(underTest.isVisible.value).isFalse() + + underTest.onVisibilityChangeRequested(visible = true) + assertThat(underTest.isVisible.value).isTrue() + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt new file mode 100644 index 0000000000000..2a9aeddc9aa8b --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/footer/FooterActionsTestUtils.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.footer + +import android.content.Context +import android.os.Handler +import android.os.UserManager +import android.provider.Settings +import android.testing.TestableLooper +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.testing.FakeMetricsLogger +import com.android.internal.logging.testing.UiEventLoggerFake +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.classifier.FalsingManagerFake +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.globalactions.GlobalActionsDialogLite +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.plugins.FalsingManager +import com.android.systemui.qs.FakeFgsManagerController +import com.android.systemui.qs.FgsManagerController +import com.android.systemui.qs.QSSecurityFooterUtils +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository +import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepositoryImpl +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository +import com.android.systemui.qs.footer.data.repository.UserSwitcherRepositoryImpl +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor +import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractorImpl +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel +import com.android.systemui.qs.user.UserSwitchDialogController +import com.android.systemui.security.data.repository.SecurityRepository +import com.android.systemui.security.data.repository.SecurityRepositoryImpl +import com.android.systemui.settings.FakeUserTracker +import com.android.systemui.settings.UserTracker +import com.android.systemui.statusbar.policy.DeviceProvisionedController +import com.android.systemui.statusbar.policy.FakeSecurityController +import com.android.systemui.statusbar.policy.FakeUserInfoController +import com.android.systemui.statusbar.policy.SecurityController +import com.android.systemui.statusbar.policy.UserInfoController +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.settings.FakeSettings +import com.android.systemui.util.settings.GlobalSettings +import com.android.systemui.util.time.FakeSystemClock +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineDispatcher + +/** + * Util class to create real implementations of the FooterActions repositories, viewModel and + * interactor to be used in tests. + */ +class FooterActionsTestUtils( + private val context: Context, + private val testableLooper: TestableLooper, + private val fakeClock: FakeSystemClock = FakeSystemClock(), +) { + /** Enable or disable the user switcher in the settings. */ + fun setUserSwitcherEnabled(settings: GlobalSettings, enabled: Boolean, userId: Int) { + settings.putBoolForUser(Settings.Global.USER_SWITCHER_ENABLED, enabled, userId) + + // The settings listener is processing messages on the bgHandler (usually backed by a + // testableLooper in tests), so let's make sure we process the callback before continuing. + testableLooper.processAllMessages() + } + + /** Create a [FooterActionsViewModel] to be used in tests. */ + fun footerActionsViewModel( + @Application context: Context = this.context.applicationContext, + footerActionsInteractor: FooterActionsInteractor = footerActionsInteractor(), + falsingManager: FalsingManager = FalsingManagerFake(), + globalActionsDialogLite: GlobalActionsDialogLite = mock(), + showPowerButton: Boolean = true, + ): FooterActionsViewModel { + return FooterActionsViewModel( + context, + footerActionsInteractor, + falsingManager, + globalActionsDialogLite, + showPowerButton, + ) + } + + /** Create a [FooterActionsInteractor] to be used in tests. */ + fun footerActionsInteractor( + activityStarter: ActivityStarter = mock(), + featureFlags: FeatureFlags = FakeFeatureFlags(), + metricsLogger: MetricsLogger = FakeMetricsLogger(), + uiEventLogger: UiEventLogger = UiEventLoggerFake(), + deviceProvisionedController: DeviceProvisionedController = mock(), + qsSecurityFooterUtils: QSSecurityFooterUtils = mock(), + fgsManagerController: FgsManagerController = mock(), + userSwitchDialogController: UserSwitchDialogController = mock(), + securityRepository: SecurityRepository = securityRepository(), + foregroundServicesRepository: ForegroundServicesRepository = foregroundServicesRepository(), + userSwitcherRepository: UserSwitcherRepository = userSwitcherRepository(), + broadcastDispatcher: BroadcastDispatcher = mock(), + bgDispatcher: CoroutineDispatcher = TestCoroutineDispatcher(), + ): FooterActionsInteractor { + return FooterActionsInteractorImpl( + activityStarter, + featureFlags, + metricsLogger, + uiEventLogger, + deviceProvisionedController, + qsSecurityFooterUtils, + fgsManagerController, + userSwitchDialogController, + securityRepository, + foregroundServicesRepository, + userSwitcherRepository, + broadcastDispatcher, + bgDispatcher, + ) + } + + /** Create a [SecurityRepository] to be used in tests. */ + fun securityRepository( + securityController: SecurityController = FakeSecurityController(), + bgDispatcher: CoroutineDispatcher = TestCoroutineDispatcher(), + ): SecurityRepository { + return SecurityRepositoryImpl( + securityController, + bgDispatcher, + ) + } + + /** Create a [SecurityRepository] to be used in tests. */ + fun foregroundServicesRepository( + fgsManagerController: FakeFgsManagerController = FakeFgsManagerController(), + ): ForegroundServicesRepository { + return ForegroundServicesRepositoryImpl(fgsManagerController) + } + + /** Create a [UserSwitcherRepository] to be used in tests. */ + fun userSwitcherRepository( + @Application context: Context = this.context.applicationContext, + bgHandler: Handler = Handler(testableLooper.looper), + bgDispatcher: CoroutineDispatcher = TestCoroutineDispatcher(), + userManager: UserManager = mock(), + userTracker: UserTracker = FakeUserTracker(), + userSwitcherController: UserSwitcherController = mock(), + userInfoController: UserInfoController = FakeUserInfoController(), + settings: GlobalSettings = FakeSettings(), + ): UserSwitcherRepository { + return UserSwitcherRepositoryImpl( + context, + bgHandler, + bgDispatcher, + userManager, + userTracker, + userSwitcherController, + userInfoController, + settings, + ) + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt new file mode 100644 index 0000000000000..48cd345b1f68f --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/FakeUiEvent.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.truth.correspondence + +import com.android.internal.logging.testing.UiEventLoggerFake +import com.android.internal.logging.testing.UiEventLoggerFake.FakeUiEvent +import com.google.common.truth.Correspondence + +/** Instances of [Correspondence] to match a [UiEventLoggerFake.FakeUiEvent] with Truth. */ +object FakeUiEvent { + val EVENT_ID = + Correspondence.transforming( + { it?.eventId }, + "has a eventId of", + ) +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt new file mode 100644 index 0000000000000..3f0a95248d9ce --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/truth/correspondence/LogMaker.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.truth.correspondence + +import android.metrics.LogMaker +import com.google.common.truth.Correspondence + +/** Instances of [Correspondence] to match a [LogMaker] with Truth. */ +object LogMaker { + val CATEGORY = + Correspondence.transforming( + { it?.category }, + "has a category of", + ) +} From 4327ce40f84bd82d89c4f135a2566c8d2c70c191 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Tue, 23 Aug 2022 15:35:59 +0200 Subject: [PATCH 07/10] Wire the new FooterActions implementation behind a flag This CL wires the new footer actions implementation from ag/19678215 behind a new SysUI flag. This flag will be enabled by default in a follow-up CL. Bug: 242040009 Test: atest FooterActionsViewModelTest Test: atest FooterActionsScreenshotTest Change-Id: If23711e400a86ad247666a1532cb17616c01ed85 --- ktfmt_includes.txt | 10 +- .../res-keyguard/layout/fgs_footer.xml | 1 + .../res-keyguard/layout/footer_actions.xml | 3 +- .../layout/footer_actions_icon_button.xml | 28 ++ .../layout/footer_actions_number_button.xml | 39 +++ .../layout/footer_actions_text_button.xml | 66 ++++ .../layout/quick_settings_security_footer.xml | 1 + .../common/ui/ContentDescriptionViewBinder.kt | 34 ++ .../systemui/dagger/SystemUIModule.java | 9 + .../src/com/android/systemui/flags/Flags.java | 2 + .../systemui/qs/FooterActionsController.kt | 1 + .../android/systemui/qs/FooterActionsView.kt | 1 + .../systemui/qs/NewFooterActionsController.kt | 33 ++ .../systemui/qs/QSFgsManagerFooter.java | 1 + .../com/android/systemui/qs/QSFragment.java | 123 ++++++- .../systemui/qs/QSSecurityFooterUtils.java | 9 +- .../systemui/qs/dagger/QSFragmentModule.java | 6 - .../qs/footer/dagger/FooterActionsModule.kt | 4 - .../ui/binder/FooterActionsViewBinder.kt | 321 ++++++++++++++++++ .../ui/viewmodel/FooterActionsViewModel.kt | 88 ++--- .../statusbar/phone/MultiUserSwitch.java | 1 + .../phone/MultiUserSwitchController.java | 1 + .../android/systemui/qs/QSFragmentTest.java | 10 +- .../systemui/qs/QSSecurityFooterTest.java | 5 +- 24 files changed, 728 insertions(+), 69 deletions(-) create mode 100644 packages/SystemUI/res-keyguard/layout/footer_actions_icon_button.xml create mode 100644 packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml create mode 100644 packages/SystemUI/res-keyguard/layout/footer_actions_text_button.xml create mode 100644 packages/SystemUI/src/com/android/systemui/common/ui/ContentDescriptionViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/NewFooterActionsController.kt create mode 100644 packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt 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/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..940b9e1fd4556 --- /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..27a1fa8f4a7b7 --- /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. --> + view.contentDescription = contentDescription.description + is ContentDescription.Resource -> { + view.contentDescription = view.context.resources.getString(contentDescription.res) + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index f32ea353bf506..42e88be2d34b3 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -48,8 +48,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; @@ -121,6 +125,7 @@ import dagger.Provides; DemoModeModule.class, FalsingModule.class, FlagsModule.class, + FooterActionsModule.class, LogModule.class, MediaProjectionModule.class, PeopleHubModule.class, @@ -131,6 +136,7 @@ import dagger.Provides; ScreenshotModule.class, SensorModule.class, MultiUserUtilsModule.class, + SecurityRepositoryModule.class, SettingsUtilModule.class, SmartRepliesInflationModule.class, SmartspaceModule.class, @@ -256,4 +262,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 7299aa597e604..7f28d4a2266c3 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java @@ -147,6 +147,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); + /***************************************/ // 600- status bar public static final ResourceBooleanFlag STATUS_BAR_USER_SWITCHER = 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 372550656d571..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, 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/QSSecurityFooterUtils.java b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java index 43b53b5cf1913..f6322743eaa11 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSSecurityFooterUtils.java @@ -174,11 +174,12 @@ public class QSSecurityFooterUtils implements DialogInterface.OnClickListener { @Inject QSSecurityFooterUtils( - @Application Context context, UserTracker userTracker, @Main Handler mainHandler, - ActivityStarter activityStarter, SecurityController securityController, - @Background Looper bgLooper, DialogLaunchAnimator dialogLaunchAnimator) { + @Application Context context, DevicePolicyManager devicePolicyManager, + UserTracker userTracker, @Main Handler mainHandler, ActivityStarter activityStarter, + SecurityController securityController, @Background Looper bgLooper, + DialogLaunchAnimator dialogLaunchAnimator) { mContext = context; - mDpm = mContext.getSystemService(DevicePolicyManager.class); + mDpm = devicePolicyManager; mUserTracker = userTracker; mMainHandler = mainHandler; mActivityStarter = activityStarter; diff --git a/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java b/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java index 6f6e4b428e949..aa505fb0b6bd4 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java +++ b/packages/SystemUI/src/com/android/systemui/qs/dagger/QSFragmentModule.java @@ -28,8 +28,6 @@ import com.android.systemui.battery.BatteryMeterView; import com.android.systemui.dagger.qualifiers.RootView; import com.android.systemui.plugins.qs.QS; import com.android.systemui.privacy.OngoingPrivacyChip; -import com.android.systemui.qs.FgsManagerController; -import com.android.systemui.qs.FgsManagerControllerImpl; import com.android.systemui.qs.FooterActionsView; import com.android.systemui.qs.QSContainerImpl; import com.android.systemui.qs.QSFooter; @@ -196,8 +194,4 @@ public interface QSFragmentModule { ) { return layoutInflater.inflate(R.layout.fgs_footer, footerActionsView, false); } - - /** */ - @Binds - FgsManagerController bindFgsManagerController(FgsManagerControllerImpl impl); } \ No newline at end of file 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 index 000c23d2e592c..38fe34eb8f9f5 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/dagger/FooterActionsModule.kt @@ -16,8 +16,6 @@ package com.android.systemui.qs.footer.dagger -import com.android.systemui.qs.FgsManagerController -import com.android.systemui.qs.FgsManagerControllerImpl import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepository import com.android.systemui.qs.footer.data.repository.ForegroundServicesRepositoryImpl import com.android.systemui.qs.footer.data.repository.UserSwitcherRepository @@ -38,6 +36,4 @@ interface FooterActionsModule { ): ForegroundServicesRepository @Binds fun footerActionsInteractor(impl: FooterActionsInteractorImpl): FooterActionsInteractor - - @Binds fun fgsManagerControllerImpl(impl: FgsManagerControllerImpl): FgsManagerController } 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..484ae0a9c93bc --- /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.ContentDescriptionViewBinder +import com.android.systemui.common.ui.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/FooterActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt index 8afe6f216cbb4..b556a3e0d66b6 100644 --- 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 @@ -46,6 +46,7 @@ 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. */ @@ -74,60 +75,65 @@ class FooterActionsViewModel( /** 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, - ) - } + 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 - } + 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, + val text = + icuMessageFormat( + context.resources, + R.string.fgs_manager_footer_label, + foregroundServicesCount, + ) + FooterActionsForegroundServicesButtonViewModel( foregroundServicesCount, + text = text, + displayText = securityModel == null, + hasNewChanges = hasNewChanges, + this::onForegroundServiceButtonClicked, ) - 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 - } + 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) + userSwitcherButton(userSwitcherStatus) + } } } - } + .distinctUntilChanged() /** The model for the settings button. */ val settings: FooterActionsButtonViewModel = 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/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 a156a609c0508..233c267c3be07 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSSecurityFooterTest.java @@ -124,8 +124,9 @@ public class QSSecurityFooterTest extends SysuiTestCase { mRootView = (ViewGroup) new LayoutInflaterBuilder(mContext) .replace("ImageView", TestableImageView.class) .build().inflate(R.layout.quick_settings_security_footer, null, false); - mFooterUtils = new QSSecurityFooterUtils(getContext(), mUserTracker, mainHandler, - mActivityStarter, mSecurityController, looper, mDialogLaunchAnimator); + 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); From ad33fb2b757fdfc8186d551979a256ad12484b42 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Wed, 24 Aug 2022 12:33:06 +0200 Subject: [PATCH 08/10] Implement LaunchableView for footer actions Views. Bug: 243636422 Test: Manual Change-Id: I0264b119d60cc70c1d9a23d056343c764242a955 --- .../animation/DialogLaunchAnimator.kt | 35 +++++++--- .../systemui/animation/LaunchableView.kt | 68 ++++++++++++++++++- .../layout/footer_actions_number_button.xml | 4 +- .../layout/footer_actions_text_button.xml | 4 +- .../ContentDescriptionViewBinder.kt | 2 +- .../common/ui/{ => binder}/IconViewBinder.kt | 2 +- .../common/ui/view/LaunchableLinearLayout.kt | 60 ++++++++++++++++ .../ui/binder/FooterActionsViewBinder.kt | 4 +- .../systemui/qs/tileimpl/QSTileViewImpl.kt | 32 +++------ .../statusbar/AlphaOptimizedFrameLayout.java | 33 ++++++++- 10 files changed, 202 insertions(+), 42 deletions(-) rename packages/SystemUI/src/com/android/systemui/common/ui/{ => binder}/ContentDescriptionViewBinder.kt (96%) rename packages/SystemUI/src/com/android/systemui/common/ui/{ => binder}/IconViewBinder.kt (95%) create mode 100644 packages/SystemUI/src/com/android/systemui/common/ui/view/LaunchableLinearLayout.kt 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/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/res-keyguard/layout/footer_actions_number_button.xml b/packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml index 940b9e1fd4556..a7ffe9ca256f4 100644 --- a/packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml +++ b/packages/SystemUI/res-keyguard/layout/footer_actions_number_button.xml @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - - \ No newline at end of file + \ 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 index 27a1fa8f4a7b7..fc18132d4dc38 100644 --- a/packages/SystemUI/res-keyguard/layout/footer_actions_text_button.xml +++ b/packages/SystemUI/res-keyguard/layout/footer_actions_text_button.xml @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/ContentDescriptionViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/binder/ContentDescriptionViewBinder.kt similarity index 96% rename from packages/SystemUI/src/com/android/systemui/common/ui/ContentDescriptionViewBinder.kt rename to packages/SystemUI/src/com/android/systemui/common/ui/binder/ContentDescriptionViewBinder.kt index ee9dfb5f5d2c0..d6433aae9845f 100644 --- a/packages/SystemUI/src/com/android/systemui/common/ui/ContentDescriptionViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/common/ui/binder/ContentDescriptionViewBinder.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.systemui.common.ui +package com.android.systemui.common.ui.binder import android.view.View import com.android.systemui.common.shared.model.ContentDescription diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt b/packages/SystemUI/src/com/android/systemui/common/ui/binder/IconViewBinder.kt similarity index 95% rename from packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt rename to packages/SystemUI/src/com/android/systemui/common/ui/binder/IconViewBinder.kt index 0e0d19815d7bd..aecee2afc9d23 100644 --- a/packages/SystemUI/src/com/android/systemui/common/ui/IconViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/common/ui/binder/IconViewBinder.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.systemui.common.ui +package com.android.systemui.common.ui.binder import android.widget.ImageView import com.android.systemui.common.shared.model.Icon 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/qs/footer/ui/binder/FooterActionsViewBinder.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/binder/FooterActionsViewBinder.kt index 484ae0a9c93bc..8dd506ec87754 100644 --- 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 @@ -31,8 +31,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.android.systemui.R -import com.android.systemui.common.ui.ContentDescriptionViewBinder -import com.android.systemui.common.ui.IconViewBinder +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 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/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); + } } From 00773ec038414c69beccab6407943b1718546aa5 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Wed, 24 Aug 2022 14:24:10 +0200 Subject: [PATCH 09/10] Add screenshot test for the footer actions (2/2) Bug: 243636422 Test: atest FooterActionsScreenshotTest Change-Id: I3339dbaa2c3684f5020e2956aaa27a2b3896fdb6 --- .../com/android/systemui/qs/footer/Fakes.kt | 160 ++++++++++++++++++ .../res-keyguard/layout/footer_actions.xml | 2 +- packages/SystemUI/res/values/dimens.xml | 1 + 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/SystemUI/compose/gallery/src/com/android/systemui/qs/footer/Fakes.kt 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/footer_actions.xml b/packages/SystemUI/res-keyguard/layout/footer_actions.xml index 0a6a68733b115..1ce106ed2156d 100644 --- a/packages/SystemUI/res-keyguard/layout/footer_actions.xml +++ b/packages/SystemUI/res-keyguard/layout/footer_actions.xml @@ -23,7 +23,7 @@ android:layout_width="match_parent" android:layout_height="@dimen/footer_actions_height" android:elevation="@dimen/qs_panel_elevation" - android:paddingTop="8dp" + android:paddingTop="@dimen/qs_footer_actions_top_padding" android:paddingBottom="@dimen/qs_footer_actions_bottom_padding" android:background="@drawable/qs_footer_actions_background" android:gravity="center_vertical|end" diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index f34472158d1bd..a2145fd50c78f 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -380,6 +380,7 @@ 4dp + 8dp 4dp -4dp From ba8fd9daecf8d36db3b7822fb7c50585da0828bf Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Thu, 25 Aug 2022 13:36:51 +0200 Subject: [PATCH 10/10] Enable the new footer actions in team food. Bug: 243636422 Test: Manual Change-Id: I838318277693d4c7b0c0c6e53df9e16ff1572091 --- packages/SystemUI/src/com/android/systemui/flags/Flags.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java index 7f28d4a2266c3..75fd87adb7e18 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java @@ -147,7 +147,7 @@ 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); + public static final UnreleasedFlag NEW_FOOTER_ACTIONS = new UnreleasedFlag(507, true); /***************************************/ // 600- status bar