Add dialog for recommended controls

The dialog can only be requested if the package of the controls provider is
currently in the foreground.

This is accomplished by querying Activity Manager about the
UidImportance of that package. Added
android.permission.PACKAGE_USAGE_STATS to SystemUI for this.

Test: atest
Test: manual
Fixes: 149410221

Change-Id: Ifdf479d8dbc70502da95d362e3bfd60ad3c561fb
This commit is contained in:
Fabian Kozynski
2020-02-13 13:02:33 -05:00
parent 780782b750
commit 04e7bdef04
14 changed files with 546 additions and 5 deletions

View File

@@ -39,6 +39,7 @@
<permission name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>
<permission name="android.permission.OBSERVE_NETWORK_POLICY"/>
<permission name="android.permission.OVERRIDE_WIFI_CONFIG"/>
<permission name="android.permission.PACKAGE_USAGE_STATS" />
<permission name="android.permission.READ_DREAM_STATE"/>
<permission name="android.permission.READ_FRAME_BUFFER"/>
<permission name="android.permission.READ_NETWORK_USAGE_HISTORY"/>

View File

@@ -180,6 +180,8 @@
<!-- Adding Controls to SystemUI -->
<uses-permission android:name="android.permission.BIND_CONTROLS" />
<!-- Check foreground controls applications -->
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
<!-- Quick Settings tile: Night Mode / Dark Theme -->
<uses-permission android:name="android.permission.MODIFY_DAY_NIGHT_MODE" />
@@ -686,6 +688,25 @@
android:visibleToInstantApps="true">
</activity>
<receiver android:name=".controls.management.ControlsRequestReceiver">
<intent-filter>
<action android:name="android.service.controls.action.ADD_CONTROL" />
</intent-filter>
</receiver>
<!-- started from ControlsFavoritingActivity -->
<activity
android:name=".controls.management.ControlsRequestDialog"
android:exported="true"
android:theme="@style/Theme.ControlsRequestDialog"
android:finishOnCloseSystemDialogs="true"
android:showForAllUsers="true"
android:clearTaskOnLaunch="true"
android:launchMode="singleTask"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden"
android:excludeFromRecents="true"
android:visibleToInstantApps="true"/>
<!-- Doze with notifications, run in main sysui process for every user -->
<service
android:name=".doze.DozeService"

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2020 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.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/controls_dialog_padding"
android:layout_margin="@dimen/controls_dialog_padding"
>
<include
android:id="@+id/control"
layout="@layout/controls_base_item"
android:layout_width="@dimen/controls_dialog_control_width"
android:layout_height="@dimen/control_height"
android:layout_gravity="center_horizontal"
android:layout_marginTop="@dimen/controls_dialog_padding"
android:layout_marginBottom="@dimen/controls_dialog_padding"
/>
</FrameLayout>

View File

@@ -1249,6 +1249,10 @@
<dimen name="controls_app_divider_side_margin">32dp</dimen>
<dimen name="controls_card_margin">2dp</dimen>
<item name="control_card_elevation" type="dimen" format="float">15</item>
<dimen name="controls_dialog_padding">8dp</dimen>
<dimen name="controls_dialog_control_width">200dp</dimen>
<!-- Screen Record -->
<dimen name="screenrecord_dialog_padding">18dp</dimen>

View File

@@ -2639,4 +2639,11 @@
<string name="controls_favorite_load_error">The list of all controls could not be loaded.</string>
<!-- Controls management controls screen header for Other zone [CHAR LIMIT=60] -->
<string name="controls_favorite_other_zone_header">Other</string>
<!-- Controls dialog title [CHAR LIMIT=30] -->
<string name="controls_dialog_title">Add to Quick Controls</string>
<!-- Controls dialog add to favorites [CHAR LIMIT=30] -->
<string name="controls_dialog_ok">Add to favorites</string>
<!-- Controls dialog message [CHAR LIMIT=NONE] -->
<string name="controls_dialog_message"><xliff:g id="app" example="System UI">%s</xliff:g> suggested this control to add to your favorites.</string>
</resources>

View File

@@ -697,4 +697,7 @@
<!-- used to override dark/light theming -->
<item name="*android:colorPopupBackground">@color/control_list_popup_background</item>
</style>
<style name="Theme.ControlsRequestDialog" parent="@style/Theme.SystemUI.MediaProjectionAlertDialog"/>
</resources>

View File

@@ -123,6 +123,18 @@ interface ControlsController : UserAwareController {
*/
fun getFavoritesForComponent(componentName: ComponentName): List<StructureInfo>
/**
* Adds a single favorite to a given component and structure
* @param componentName the name of the service that provides the [Control]
* @param structureName the name of the structure that holds the [Control]
* @param controlInfo persistent information about the [Control] to be added.
*/
fun addFavorite(
componentName: ComponentName,
structureName: CharSequence,
controlInfo: ControlInfo
)
/**
* Replaces the favorites for the given structure.
*

View File

@@ -300,6 +300,19 @@ class ControlsControllerImpl @Inject constructor (
bindingController.unsubscribe()
}
override fun addFavorite(
componentName: ComponentName,
structureName: CharSequence,
controlInfo: ControlInfo
) {
if (!confirmAvailability()) return
executor.execute {
if (Favorites.addFavorite(componentName, structureName, controlInfo)) {
persistenceWrapper.storeFavorites(Favorites.getAllStructures())
}
}
}
override fun replaceFavoritesForStructure(structureInfo: StructureInfo) {
if (!confirmAvailability()) return
executor.execute {
@@ -437,6 +450,24 @@ private object Favorites {
favMap = newFavMap
}
fun addFavorite(
componentName: ComponentName,
structureName: CharSequence,
controlInfo: ControlInfo
): Boolean {
// Check if control is in favorites
if (getControlsForComponent(componentName)
.any { it.controlId == controlInfo.controlId }) {
return false
}
val structureInfo = favMap.get(componentName)
?.firstOrNull { it.structure == structureName }
?: StructureInfo(componentName, structureName, emptyList())
val newStructureInfo = structureInfo.copy(controls = structureInfo.controls + controlInfo)
replaceControls(newStructureInfo)
return true
}
fun replaceControls(updatedStructure: StructureInfo) {
val newFavMap = favMap.toMutableMap()
val structures = mutableListOf<StructureInfo>()
@@ -456,8 +487,8 @@ private object Favorites {
structures.add(updatedStructure)
}
newFavMap.put(componentName, structures.toList())
favMap = newFavMap.toMap()
newFavMap.put(componentName, structures)
favMap = newFavMap
}
fun clear() {

View File

@@ -26,6 +26,7 @@ import com.android.systemui.controls.management.ControlsFavoritingActivity
import com.android.systemui.controls.management.ControlsListingController
import com.android.systemui.controls.management.ControlsListingControllerImpl
import com.android.systemui.controls.management.ControlsProviderSelectorActivity
import com.android.systemui.controls.management.ControlsRequestDialog
import com.android.systemui.controls.ui.ControlsUiController
import com.android.systemui.controls.ui.ControlsUiControllerImpl
import dagger.Binds
@@ -69,4 +70,11 @@ abstract class ControlsModule {
abstract fun provideControlsFavoritingActivity(
activity: ControlsFavoritingActivity
): Activity
@Binds
@IntoMap
@ClassKey(ControlsRequestDialog::class)
abstract fun provideControlsRequestDialog(
activity: ControlsRequestDialog
): Activity
}

View File

@@ -42,7 +42,8 @@ private typealias ModelFavoriteChanger = (String, Boolean) -> Unit
* @param onlyFavorites set to true to only display favorites instead of all controls
*/
class ControlAdapter(
private val layoutInflater: LayoutInflater
private val layoutInflater: LayoutInflater,
private val elevation: Float
) : RecyclerView.Adapter<Holder>() {
companion object {
@@ -66,7 +67,7 @@ class ControlAdapter(
layoutParams.apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
}
elevation = 15f
elevation = this@ControlAdapter.elevation
}
) { id, favorite ->
model?.changeFavoriteStatus(id, favorite)

View File

@@ -142,8 +142,9 @@ class ControlsFavoritingActivity @Inject constructor(
val margin = resources.getDimensionPixelSize(R.dimen.controls_card_margin)
val itemDecorator = MarginItemDecorator(margin, margin)
val layoutInflater = LayoutInflater.from(applicationContext)
val elevation = resources.getFloat(R.dimen.control_card_elevation)
adapterAll = ControlAdapter(layoutInflater)
adapterAll = ControlAdapter(layoutInflater, elevation)
recyclerViewAll = requireViewById<RecyclerView>(R.id.listAll).apply {
adapter = adapterAll
layoutManager = GridLayoutManager(applicationContext, 2).apply {

View File

@@ -0,0 +1,179 @@
/*
* Copyright (C) 2020 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.controls.management
import android.app.AlertDialog
import android.app.Dialog
import android.content.ComponentName
import android.content.DialogInterface
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Bundle
import android.os.UserHandle
import android.service.controls.Control
import android.service.controls.ControlsProviderService
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.android.systemui.R
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.controls.ControlsServiceInfo
import com.android.systemui.controls.controller.ControlInfo
import com.android.systemui.controls.controller.ControlsController
import com.android.systemui.controls.ui.RenderInfo
import com.android.systemui.settings.CurrentUserTracker
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.util.LifecycleActivity
import javax.inject.Inject
class ControlsRequestDialog @Inject constructor(
private val controller: ControlsController,
private val broadcastDispatcher: BroadcastDispatcher,
private val controlsListingController: ControlsListingController
) : LifecycleActivity(), DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
companion object {
private const val TAG = "ControlsRequestDialog"
}
private lateinit var component: ComponentName
private lateinit var control: Control
private var dialog: Dialog? = null
private val callback = object : ControlsListingController.ControlsListingCallback {
override fun onServicesUpdated(candidates: List<ControlsServiceInfo>) {}
}
private val currentUserTracker = object : CurrentUserTracker(broadcastDispatcher) {
private val startingUser = controller.currentUserId
override fun onUserSwitched(newUserId: Int) {
if (newUserId != startingUser) {
stopTracking()
finish()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!controller.available) {
Log.w(TAG, "Quick Controls not available for this user ")
finish()
}
currentUserTracker.startTracking()
controlsListingController.addCallback(callback)
val requestUser = intent.getIntExtra(Intent.EXTRA_USER_ID, UserHandle.USER_NULL)
val currentUser = controller.currentUserId
if (requestUser != currentUser) {
Log.w(TAG, "Current user ($currentUser) different from request user ($requestUser)")
finish()
}
component = intent.getParcelableExtra(Intent.EXTRA_COMPONENT_NAME) ?: run {
Log.e(TAG, "Request did not contain componentName")
finish()
return
}
control = intent.getParcelableExtra(ControlsProviderService.EXTRA_CONTROL) ?: run {
Log.e(TAG, "Request did not contain control")
finish()
return
}
}
override fun onResume() {
super.onResume()
val label = verifyComponentAndGetLabel()
if (label == null) {
Log.e(TAG, "The component specified (${component.flattenToString()} " +
"is not a valid ControlsProviderService")
finish()
return
}
if (isCurrentFavorite()) {
Log.w(TAG, "The control ${control.title} is already a favorite")
finish()
}
dialog = createDialog(label)
dialog?.show()
}
override fun onDestroy() {
dialog?.dismiss()
currentUserTracker.stopTracking()
controlsListingController.removeCallback(callback)
super.onDestroy()
}
private fun verifyComponentAndGetLabel(): CharSequence? {
return controlsListingController.getAppLabel(component)
}
private fun isCurrentFavorite(): Boolean {
val favorites = controller.getFavoritesForComponent(component)
return favorites.any { it.controls.any { it.controlId == control.controlId } }
}
fun createDialog(label: CharSequence): Dialog {
val renderInfo = RenderInfo.lookup(control.deviceType, true)
val frame = LayoutInflater.from(this).inflate(R.layout.controls_dialog, null).apply {
requireViewById<ImageView>(R.id.icon).apply {
setImageIcon(Icon.createWithResource(context, renderInfo.iconResourceId))
setImageTintList(
context.resources.getColorStateList(renderInfo.foreground, context.theme))
}
requireViewById<TextView>(R.id.title).text = control.title
requireViewById<TextView>(R.id.subtitle).text = control.subtitle
requireViewById<View>(R.id.control).elevation =
resources.getFloat(R.dimen.control_card_elevation)
}
val dialog = AlertDialog.Builder(this)
.setTitle(getString(R.string.controls_dialog_title))
.setMessage(getString(R.string.controls_dialog_message, label))
.setPositiveButton(R.string.controls_dialog_ok, this)
.setNegativeButton(android.R.string.cancel, this)
.setOnCancelListener(this)
.setView(frame)
.create()
SystemUIDialog.registerDismissListener(dialog)
dialog.setCanceledOnTouchOutside(true)
return dialog
}
override fun onCancel(dialog: DialogInterface?) {
finish()
}
override fun onClick(dialog: DialogInterface?, which: Int) {
if (which == Dialog.BUTTON_POSITIVE) {
controller.addFavorite(componentName, control.structure ?: "",
ControlInfo(control.controlId, control.title, control.deviceType))
}
finish()
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2020 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.controls.management
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.UserHandle
import android.service.controls.Control
import android.service.controls.ControlsProviderService
import android.util.Log
/**
* Proxy to launch in user 0
*/
class ControlsRequestReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "ControlsRequestReceiver"
fun isPackageInForeground(context: Context, packageName: String): Boolean {
val uid = try {
context.packageManager.getPackageUid(packageName, 0)
} catch (_: PackageManager.NameNotFoundException) {
Log.w(TAG, "Package $packageName not found")
return false
}
val am = context.getSystemService(ActivityManager::class.java)
if ((am?.getUidImportance(uid) ?: IMPORTANCE_GONE) != IMPORTANCE_FOREGROUND) {
Log.w(TAG, "Uid $uid not in foreground")
return false
}
return true
}
}
override fun onReceive(context: Context, intent: Intent) {
val packageName = intent.getParcelableExtra<ComponentName>(Intent.EXTRA_COMPONENT_NAME)
?.packageName
if (packageName == null || !isPackageInForeground(context, packageName)) {
return
}
val activityIntent = Intent(context, ControlsRequestDialog::class.java).apply {
Intent.EXTRA_COMPONENT_NAME.let {
putExtra(it, intent.getParcelableExtra<ComponentName>(it))
}
ControlsProviderService.EXTRA_CONTROL.let {
putExtra(it, intent.getParcelableExtra<Control>(it))
}
}
activityIntent.putExtra(Intent.EXTRA_USER_ID, context.userId)
context.startActivityAsUser(activityIntent, UserHandle.SYSTEM)
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright (C) 2020 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.controls.management
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE
import android.content.ComponentName
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.os.UserHandle
import android.service.controls.Control
import android.service.controls.ControlsProviderService
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyString
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
@SmallTest
@RunWith(AndroidTestingRunner::class)
class ControlsRequestReceiverTest : SysuiTestCase() {
@Mock
private lateinit var packageManager: PackageManager
@Mock
private lateinit var activityManager: ActivityManager
@Mock
private lateinit var control: Control
private val componentName = ComponentName("test_pkg", "test_cls")
private lateinit var receiver: ControlsRequestReceiver
private lateinit var wrapper: MyWrapper
private lateinit var intent: Intent
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
mContext.setMockPackageManager(packageManager)
mContext.addMockSystemService(ActivityManager::class.java, activityManager)
receiver = ControlsRequestReceiver()
wrapper = MyWrapper(context)
intent = Intent(ControlsProviderService.ACTION_ADD_CONTROL).apply {
putExtra(Intent.EXTRA_COMPONENT_NAME, componentName)
putExtra(ControlsProviderService.EXTRA_CONTROL, control)
}
}
@Test
fun testPackageVerification_nonExistentPackage() {
`when`(packageManager.getPackageUid(anyString(), anyInt()))
.thenThrow(PackageManager.NameNotFoundException::class.java)
assertFalse(ControlsRequestReceiver.isPackageInForeground(mContext, "TEST"))
}
@Test
fun testPackageVerification_uidNotInForeground() {
`when`(packageManager.getPackageUid(anyString(), anyInt())).thenReturn(12345)
`when`(activityManager.getUidImportance(anyInt())).thenReturn(IMPORTANCE_GONE)
assertFalse(ControlsRequestReceiver.isPackageInForeground(mContext, "TEST"))
}
@Test
fun testPackageVerification_OK() {
`when`(packageManager.getPackageUid(anyString(), anyInt())).thenReturn(12345)
`when`(activityManager.getUidImportance(anyInt())).thenReturn(IMPORTANCE_GONE)
`when`(activityManager.getUidImportance(12345)).thenReturn(IMPORTANCE_FOREGROUND)
assertTrue(ControlsRequestReceiver.isPackageInForeground(mContext, "TEST"))
}
@Test
fun testOnReceive_packageNotVerified_nameNotFound() {
`when`(packageManager.getPackageUid(eq(componentName.packageName), anyInt()))
.thenThrow(PackageManager.NameNotFoundException::class.java)
receiver.onReceive(wrapper, intent)
assertNull(wrapper.intent)
}
@Test
fun testOnReceive_packageNotVerified_notForeground() {
`when`(packageManager.getPackageUid(eq(componentName.packageName), anyInt()))
.thenReturn(12345)
`when`(activityManager.getUidImportance(anyInt())).thenReturn(IMPORTANCE_GONE)
receiver.onReceive(wrapper, intent)
assertNull(wrapper.intent)
}
@Test
fun testOnReceive_OK() {
`when`(packageManager.getPackageUid(eq(componentName.packageName), anyInt()))
.thenReturn(12345)
`when`(activityManager.getUidImportance(eq(12345))).thenReturn(IMPORTANCE_FOREGROUND)
receiver.onReceive(wrapper, intent)
wrapper.intent?.let {
assertEquals(ComponentName(wrapper, ControlsRequestDialog::class.java), it.component)
assertEquals(control, it.getParcelableExtra(ControlsProviderService.EXTRA_CONTROL))
assertEquals(componentName, it.getParcelableExtra(Intent.EXTRA_COMPONENT_NAME))
} ?: run { fail("Null start intent") }
}
class MyWrapper(context: Context) : ContextWrapper(context) {
var intent: Intent? = null
override fun startActivityAsUser(intent: Intent, user: UserHandle) {
// Always launch activity as system
assertTrue(user == UserHandle.SYSTEM)
this.intent = intent
}
override fun startActivity(intent: Intent) {
this.intent = intent
}
}
}