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
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index cd3a722..ff4c748 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -230,6 +230,7 @@
     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 8ddd430..7d4dcf8 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 @@
             @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 0000000..8ce372d
--- /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 b0f5cc1..5a7a1e1 100644
--- a/packages/SystemUI/compose/gallery/Android.bp
+++ b/packages/SystemUI/compose/gallery/Android.bp
@@ -54,6 +54,11 @@
         "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 0000000..bebade0
--- /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 0000000..0e0d198
--- /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 0000000..000c23d
--- /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 0000000..4ca229a
--- /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 0000000..37a9c40
--- /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<Int>
+
+    /**
+     * 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<Boolean>
+}
+
+@SysUISingleton
+class ForegroundServicesRepositoryImpl
+@Inject
+constructor(
+    fgsManagerController: FgsManagerController,
+) : ForegroundServicesRepository {
+    override val foregroundServicesCount: Flow<Int> =
+        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<Boolean> =
+        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 0000000..e969d4c6
--- /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<UserSwitcherStatusModel>
+}
+
+@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<Boolean> = 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<String?> = 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<Pair<Drawable?, Boolean>> = 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<UserSwitcherStatusModel> =
+        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 0000000..cf9b41c
--- /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<SecurityButtonConfig?>
+
+    /** The number of packages with a service running in the foreground. */
+    val foregroundServicesCount: Flow<Int>
+
+    /** Whether there are new packages with a service running in the foreground. */
+    val hasNewForegroundServices: Flow<Boolean>
+
+    /** The current [UserSwitcherStatusModel]. */
+    val userSwitcherStatus: Flow<UserSwitcherStatusModel>
+
+    /**
+     * The flow emitting `Unit` whenever a request to show the device monitoring dialog is fired.
+     */
+    val deviceMonitoringDialogRequests: Flow<Unit>
+
+    /**
+     * 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<SecurityButtonConfig?> =
+        securityRepository.security.map { security ->
+            withContext(bgDispatcher) { qsSecurityFooterUtils.getButtonConfig(security) }
+        }
+
+    override val foregroundServicesCount: Flow<Int> =
+        foregroundServicesRepository.foregroundServicesCount
+
+    override val hasNewForegroundServices: Flow<Boolean> =
+        foregroundServicesRepository.hasNewChanges
+
+    override val userSwitcherStatus: Flow<UserSwitcherStatusModel> =
+        userSwitcherRepository.userSwitcherStatus
+
+    override val deviceMonitoringDialogRequests: Flow<Unit> =
+        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 0000000..4c0879e
--- /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 0000000..98b53cb
--- /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 0000000..98ab129
--- /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 0000000..8afe6f2
--- /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<Boolean> = _isVisible.asStateFlow()
+
+    /** The alpha the UI rendering this ViewModel should have. */
+    private val _alpha = MutableStateFlow(1f)
+    val alpha: StateFlow<Float> = _alpha.asStateFlow()
+
+    /** The alpha the background of the UI rendering this ViewModel should have. */
+    private val _backgroundAlpha = MutableStateFlow(1f)
+    val backgroundAlpha: StateFlow<Float> = _backgroundAlpha.asStateFlow()
+
+    /** The model for the security button. */
+    val security: Flow<FooterActionsSecurityButtonViewModel?> =
+        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<FooterActionsForegroundServicesButtonViewModel?> =
+        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<FooterActionsButtonViewModel?> =
+        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<GlobalActionsDialogLite>,
+        @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 0000000..8f4402e
--- /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<SecurityModel>
+}
+
+@SysUISingleton
+class SecurityRepositoryImpl
+@Inject
+constructor(
+    private val securityController: SecurityController,
+    @Background private val bgDispatcher: CoroutineDispatcher,
+) : SecurityRepository {
+    override val security: Flow<SecurityModel> = 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 0000000..39a57ca
--- /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 0f11241..e2790e4 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.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 @@
     @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 @@
         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 0000000..3c25807
--- /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<QSSecurityFooterUtils>()
+        val underTest = utils.footerActionsInteractor(qsSecurityFooterUtils = qsSecurityFooterUtils)
+
+        val quickSettingsContext = mock<Context>()
+        underTest.showDeviceMonitoringDialog(quickSettingsContext)
+        verify(qsSecurityFooterUtils).showDeviceMonitoringDialog(quickSettingsContext, null)
+
+        val view = mock<View>()
+        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<GlobalActionsDialogLite>()
+        val view = mock<View>()
+        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<ActivityStarter>()
+        val deviceProvisionedController = mock<DeviceProvisionedController>()
+        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<Intent>()
+        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<ActivityStarter>()
+        val deviceProvisionedController = mock<DeviceProvisionedController>()
+
+        // 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<UserSwitchDialogController>()
+        val underTest =
+            utils.footerActionsInteractor(
+                featureFlags = featureFlags,
+                userSwitchDialogController = userSwitchDialogController,
+            )
+
+        val view = mock<View>()
+        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<ActivityStarter>()
+        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<View>()
+        whenever(view.context).thenReturn(context)
+
+        underTest.showUserSwitcher(view)
+
+        // Dialog is shown.
+        val intentCaptor = argumentCaptor<Intent>()
+        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 0000000..e4751d1
--- /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<UserManager>()
+        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<UserIconDrawable>() }
+        assertThat(iconTint()).isNull()
+
+        job.cancel()
+    }
+
+    @Test
+    fun security() = runBlockingTest {
+        val securityController = FakeSecurityController()
+        val qsSecurityFooterUtils = mock<QSSecurityFooterUtils>()
+
+        // 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<QSSecurityFooterUtils>()
+
+        // 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<QSSecurityFooterUtils>()
+        val broadcastDispatcher = mock<BroadcastDispatcher>()
+
+        // 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 0000000..2a9aedd
--- /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 0000000..48cd345
--- /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<FakeUiEvent, Int>(
+            { 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 0000000..3f0a9524
--- /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<LogMaker, Int>(
+            { it?.category },
+            "has a category of",
+        )
+}