Implementation of the FooterActions following the MAD (1/3) am: 6fb22d4b16

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/19678215

Change-Id: I71cb9668445f277fadc69294e60bf2ff63a46cca
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Jordan Demeulenaere
2022-08-26 07:09:54 +00:00
committed by Automerger Merge Worker
23 changed files with 2016 additions and 12 deletions

View File

@@ -230,6 +230,7 @@ android_library {
libs: [
"android.test.runner",
"android.test.base",
"android.test.mock",
],
kotlincflags: ["-Xjvm-default=enable"],
aaptflags: [

View File

@@ -311,8 +311,7 @@ class ActivityLaunchAnimator(
@JvmStatic
fun fromView(view: View, cujType: Int? = null): Controller? {
if (view.parent !is ViewGroup) {
// TODO(b/192194319): Throw instead of just logging.
Log.wtf(
Log.e(
TAG,
"Skipping animation as view $view is not attached to a ViewGroup",
Exception()

View File

@@ -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)
}
}
}
}
}

View File

@@ -54,6 +54,11 @@ android_library {
"testables",
"truth-prebuilt",
"androidx.test.uiautomator",
"kotlinx_coroutines_test",
],
libs: [
"android.test.mock",
],
kotlincflags: ["-Xjvm-default=all"],

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)?,
)

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -10,12 +10,10 @@ import android.graphics.Rect
import android.os.Looper
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper.RunWithLooper
import android.util.Log
import android.view.IRemoteAnimationFinishedCallback
import android.view.RemoteAnimationAdapter
import android.view.RemoteAnimationTarget
import android.view.SurfaceControl
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.test.filters.SmallTest
@@ -51,7 +49,6 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() {
@Mock lateinit var listener: ActivityLaunchAnimator.Listener
@Spy private val controller = TestLaunchAnimatorController(launchContainer)
@Mock lateinit var iCallback: IRemoteAnimationFinishedCallback
@Mock lateinit var failHandler: Log.TerribleFailureHandler
private lateinit var activityLaunchAnimator: ActivityLaunchAnimator
@get:Rule val rule = MockitoJUnit.rule()
@@ -187,13 +184,6 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() {
verify(controller).onLaunchAnimationStart(anyBoolean())
}
@Test
fun controllerFromOrphanViewReturnsNullAndIsATerribleFailure() {
Log.setWtfHandler(failHandler)
assertNull(ActivityLaunchAnimator.Controller.fromView(View(mContext)))
verify(failHandler).onTerribleFailure(any(), any(), anyBoolean())
}
private fun fakeWindow(): RemoteAnimationTarget {
val bounds = Rect(10 /* left */, 20 /* top */, 30 /* right */, 40 /* bottom */)
val taskInfo = ActivityManager.RunningTaskInfo()

View File

@@ -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,
)
)
}
}

View File

@@ -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()
}
}

View File

@@ -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,
)
}
}

View File

@@ -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",
)
}

View File

@@ -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",
)
}