Controls UI - Support detail panels

Allow apps to specify whether to show full screen or within detail
panels. Default camers and thermostats to use the panel by default
when the following flag is enabled.

Enable by:
adb shell settings put secure systemui.controls_use_panel 1

Change offset from the top in Px:
adb shell settings put secure systemui.controls_panel_top_offset XXX

Bug: 152528130
Test: manual, use camera, thermostat devices

Change-Id: Ia1b12afcf4de2a0bcf7957e6425b282b1e220f46
This commit is contained in:
Matt Pietal
2020-03-30 08:09:18 -04:00
parent 0081f25310
commit dc78c84748
12 changed files with 312 additions and 73 deletions

View File

@@ -0,0 +1,26 @@
<?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
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@*android:anim/accelerate_decelerate_interpolator"
android:zAdjustment="top">
<translate android:fromYDelta="100%"
android:toYDelta="0"
android:startOffset="@android:integer/config_shortAnimTime"
android:duration="@*android:integer/config_mediumAnimTime"/>
</set>

View File

@@ -0,0 +1,25 @@
<?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
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@*android:anim/accelerate_interpolator"
android:zAdjustment="top">
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromYDelta="0"
android:toYDelta="100%"
android:duration="@*android:integer/config_shortAnimTime" />
</set>

View File

@@ -0,0 +1,22 @@
<?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.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?android:attr/colorPrimaryDark" />
<corners
android:topLeftRadius="?android:attr/dialogCornerRadius"
android:topRightRadius="?android:attr/dialogCornerRadius" />
</shape>

View File

@@ -15,9 +15,76 @@
limitations under the License.
-->
<FrameLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/controls_activity_view"
android:id="@+id/control_detail_root"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
android:layout_marginTop="@dimen/controls_activity_view_top_offset"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="10dp">
<ImageView
android:id="@+id/control_detail_close"
android:contentDescription="@string/accessibility_desc_close"
android:src="@drawable/ic_close"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:tint="@color/control_primary_text"
android:layout_width="48dp"
android:layout_height="48dp"
android:padding="12dp" />
<Space
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="1dp" />
<ImageView
android:id="@+id/control_detail_open_in_app"
android:src="@drawable/ic_open_in_new"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:tint="@color/control_primary_text"
android:layout_width="48dp"
android:layout_height="48dp"
android:padding="12dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/controls_activity_view_top_padding"
android:paddingLeft="@dimen/controls_activity_view_side_padding"
android:paddingRight="@dimen/controls_activity_view_side_padding"
android:background="@drawable/rounded_bg_top"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.ControlDialog"
android:clickable="false"
android:focusable="false"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/subtitle"
android:layout_marginTop="6dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.ControlDialog"
android:clickable="false"
android:focusable="false"
android:maxLines="1"
android:ellipsize="end" />
<FrameLayout
android:id="@+id/controls_activity_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="10dp"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>

View File

@@ -1250,6 +1250,12 @@
<dimen name="control_base_item_margin">2dp</dimen>
<dimen name="control_status_padding">3dp</dimen>
<!-- Home Controls activity view detail panel-->
<dimen name="controls_activity_view_top_padding">25dp</dimen>
<dimen name="controls_activity_view_side_padding">12dp</dimen>
<dimen name="controls_activity_view_top_offset">200dp</dimen>
<dimen name="controls_activity_view_text_size">17sp</dimen>
<!-- Home Controls management screens -->
<dimen name="controls_management_top_padding">48dp</dimen>
<dimen name="controls_management_side_padding">8dp</dimen>

View File

@@ -671,6 +671,19 @@
<item name="android:fontFamily">@*android:string/config_bodyFontFamily</item>
</style>
<style name="Theme.SystemUI.Dialog.Control.DetailPanel" parent="@android:style/Theme.DeviceDefault.Dialog.NoActionBar">
<item name="android:windowAnimationStyle">@style/Animation.Bottomsheet</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowIsFloating">false</item>
<item name="android:windowBackground">@null</item>
<item name="android:backgroundDimEnabled">true</item>
</style>
<style name="Animation.Bottomsheet">
<item name="android:windowEnterAnimation">@anim/bottomsheet_in</item>
<item name="android:windowExitAnimation">@anim/bottomsheet_out</item>
</style>
<style name="Control" />
<style name="Control.MenuItem">
@@ -713,6 +726,11 @@
<item name="android:textSize">@dimen/control_text_size</item>
<item name="android:textColor">@color/control_secondary_text</item>
</style>
<style name="TextAppearance.ControlDialog">
<item name="android:fontFamily">@*android:string/config_headlineFontFamilyMedium</item>
<item name="android:textSize">@dimen/controls_activity_view_text_size</item>
<item name="android:textColor">@color/control_primary_text</item>
</style>
<style name="Control.ListPopupWindow" parent="@*android:style/Widget.DeviceDefault.ListPopupWindow">
<item name="android:overlapAnchor">true</item>

View File

@@ -16,19 +16,27 @@
package com.android.systemui.controls.ui
import android.app.Dialog
import android.app.PendingIntent
import android.content.Intent
import android.provider.Settings
import android.service.controls.Control
import android.service.controls.actions.BooleanAction
import android.service.controls.actions.CommandAction
import android.util.Log
import android.view.HapticFeedbackConstants
import com.android.systemui.R
object ControlActionCoordinator {
public const val MIN_LEVEL = 0
public const val MAX_LEVEL = 10000
private var useDetailDialog: Boolean? = null
private var dialog: Dialog? = null
fun closeDialog() {
dialog?.dismiss()
dialog = null
}
fun toggle(cvh: ControlViewHolder, templateId: String, isChecked: Boolean) {
cvh.action(BooleanAction(templateId, !isChecked))
@@ -37,31 +45,39 @@ object ControlActionCoordinator {
cvh.clipLayer.setLevel(nextLevel)
}
fun touch(cvh: ControlViewHolder, templateId: String) {
cvh.action(CommandAction(templateId))
fun touch(cvh: ControlViewHolder, templateId: String, control: Control) {
if (cvh.usePanel()) {
showDialog(cvh, control.getAppIntent().getIntent())
} else {
cvh.action(CommandAction(templateId))
}
}
/**
* Allow apps to specify whether they would like to appear in a detail panel or within
* the full activity by setting the {@link Control#EXTRA_USE_PANEL} flag. In order for
* activities to determine how they are being launched, they should inspect the
* {@link Control#EXTRA_USE_PANEL} flag for a value of true.
*/
fun longPress(cvh: ControlViewHolder) {
// Long press snould only be called when there is valid control state, otherwise ignore
cvh.cws.control?.let {
if (useDetailDialog == null) {
useDetailDialog = Settings.Secure.getInt(cvh.context.getContentResolver(),
"systemui.controls_use_detail_panel", 0) != 0
}
try {
it.getAppIntent().send()
cvh.layout.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
if (useDetailDialog!!) {
DetailDialog(cvh.context, it.getAppIntent()).show()
} else {
it.getAppIntent().send()
val closeDialog = Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)
cvh.context.sendBroadcast(closeDialog)
}
cvh.context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
} catch (e: PendingIntent.CanceledException) {
Log.e(ControlsUiController.TAG, "Error sending pending intent", e)
cvh.setTransientStatus("Error opening application")
cvh.setTransientStatus(
cvh.context.resources.getString(R.string.controls_error_failed))
}
}
}
private fun showDialog(cvh: ControlViewHolder, intent: Intent) {
dialog = DetailDialog(cvh, intent).also {
it.setOnDismissListener { _ -> dialog = null }
it.show()
}
}
}

View File

@@ -39,16 +39,29 @@ import com.android.systemui.R
import kotlin.reflect.KClass
private const val UPDATE_DELAY_IN_MILLIS = 3000L
private const val ALPHA_ENABLED = (255.0 * 0.2).toInt()
private const val ALPHA_DISABLED = 255
/**
* Wraps the widgets that make up the UI representation of a {@link Control}. Updates to the view
* are signaled via calls to {@link #bindData}. Similar to the ViewHolder concept used in
* RecyclerViews.
*/
class ControlViewHolder(
val layout: ViewGroup,
val controlsController: ControlsController,
val uiExecutor: DelayableExecutor,
val bgExecutor: DelayableExecutor
val bgExecutor: DelayableExecutor,
val usePanels: Boolean
) {
companion object {
private const val UPDATE_DELAY_IN_MILLIS = 3000L
private const val ALPHA_ENABLED = (255.0 * 0.2).toInt()
private const val ALPHA_DISABLED = 255
private val FORCE_PANEL_DEVICES = setOf(
DeviceTypes.TYPE_THERMOSTAT,
DeviceTypes.TYPE_CAMERA
)
}
val icon: ImageView = layout.requireViewById(R.id.icon)
val status: TextView = layout.requireViewById(R.id.status)
val title: TextView = layout.requireViewById(R.id.title)
@@ -59,6 +72,8 @@ class ControlViewHolder(
var cancelUpdate: Runnable? = null
var behavior: Behavior? = null
var lastAction: ControlAction? = null
val deviceType: Int
get() = cws.control?.let { it.getDeviceType() } ?: cws.ci.deviceType
init {
val ld = layout.getBackground() as LayerDrawable
@@ -76,7 +91,7 @@ class ControlViewHolder(
val (controlStatus, template) = cws.control?.let {
title.setText(it.getTitle())
subtitle.setText(it.getSubtitle())
Pair(it.getStatus(), it.getControlTemplate())
Pair(it.status, it.controlTemplate)
} ?: run {
title.setText(cws.ci.controlTitle)
subtitle.setText(cws.ci.controlSubtitle)
@@ -91,7 +106,7 @@ class ControlViewHolder(
})
}
val clazz = findBehavior(controlStatus, template)
val clazz = findBehavior(controlStatus, template, deviceType)
if (behavior == null || behavior!!::class != clazz) {
// Behavior changes can signal a change in template from the app or
// first time setup
@@ -126,9 +141,17 @@ class ControlViewHolder(
controlsController.action(cws.componentName, cws.ci, action)
}
private fun findBehavior(status: Int, template: ControlTemplate): KClass<out Behavior> {
fun usePanel(): Boolean =
usePanels && deviceType in ControlViewHolder.FORCE_PANEL_DEVICES
private fun findBehavior(
status: Int,
template: ControlTemplate,
deviceType: Int
): KClass<out Behavior> {
return when {
status == Control.STATUS_UNKNOWN -> UnknownBehavior::class
deviceType == DeviceTypes.TYPE_CAMERA -> TouchBehavior::class
template is ToggleTemplate -> ToggleBehavior::class
template is StatelessTemplate -> TouchBehavior::class
template is ToggleRangeTemplate -> ToggleRangeBehavior::class
@@ -140,7 +163,6 @@ class ControlViewHolder(
internal fun applyRenderInfo(enabled: Boolean, offset: Int = 0) {
setEnabled(enabled)
val deviceType = cws.control?.let { it.getDeviceType() } ?: cws.ci.deviceType
val ri = RenderInfo.lookup(context, cws.componentName, deviceType, enabled, offset)
val fg = context.getResources().getColorStateList(ri.foreground, context.getTheme())

View File

@@ -30,6 +30,7 @@ import android.content.res.Configuration
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.os.Process
import android.provider.Settings
import android.service.controls.Control
import android.service.controls.actions.ControlAction
import android.util.TypedValue
@@ -85,6 +86,7 @@ class ControlsUiControllerImpl @Inject constructor (
private const val PREF_COMPONENT = "controls_component"
private const val PREF_STRUCTURE = "controls_structure"
private const val USE_PANELS = "systemui.controls_use_panel"
private const val FADE_IN_MILLIS = 225L
private val EMPTY_COMPONENT = ComponentName("", "")
@@ -407,6 +409,9 @@ class ControlsUiControllerImpl @Inject constructor (
val maxColumns = findMaxColumns()
// use flag only temporarily for testing
val usePanels = Settings.Secure.getInt(context.contentResolver, USE_PANELS, 0) == 1
val listView = parent.requireViewById(R.id.global_actions_controls_list) as ViewGroup
var lastRow: ViewGroup = createRow(inflater, listView)
selectedStructure.controls.forEach {
@@ -420,7 +425,8 @@ class ControlsUiControllerImpl @Inject constructor (
baseLayout,
controlsController.get(),
uiExecutor,
bgExecutor
bgExecutor,
usePanels
)
val key = ControlKey(selectedStructure.componentName, it.controlId)
cvh.bindData(controlsById.getValue(key))
@@ -500,6 +506,7 @@ class ControlsUiControllerImpl @Inject constructor (
hidden = true
popup?.dismiss()
activeDialog?.dismiss()
ControlActionCoordinator.closeDialog()
controlsController.get().unsubscribe()

View File

@@ -17,18 +17,16 @@
package com.android.systemui.controls.ui
import android.app.ActivityView
import android.app.ActivityOptions
import android.app.Dialog
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.Window
import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
import android.widget.ImageView
import android.widget.TextView
import com.android.systemui.R
@@ -38,20 +36,26 @@ import com.android.systemui.R
* The activity being launched is specified by {@link android.service.controls.Control#getAppIntent}.
*/
class DetailDialog(
val parentContext: Context,
val intent: PendingIntent
) : Dialog(parentContext) {
val cvh: ControlViewHolder,
val intent: Intent
) : Dialog(cvh.context, R.style.Theme_SystemUI_Dialog_Control_DetailPanel) {
var activityView: ActivityView
companion object {
private const val ALPHA = (0.8f * 255).toInt()
private const val PANEL_TOP_OFFSET = "systemui.controls_panel_top_offset"
}
lateinit var activityView: ActivityView
val stateCallback: ActivityView.StateCallback = object : ActivityView.StateCallback() {
override fun onActivityViewReady(view: ActivityView) {
val fillInIntent = Intent()
val launchIntent = Intent(intent)
// Apply flags to make behaviour match documentLaunchMode=always.
fillInIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fillInIntent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
view.startActivity(intent, fillInIntent, ActivityOptions.makeBasic())
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
launchIntent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
view.startActivity(launchIntent)
}
override fun onActivityViewDestroyed(view: ActivityView) {}
@@ -61,28 +65,8 @@ class DetailDialog(
override fun onTaskRemovalStarted(taskId: Int) {}
}
@Suppress("DEPRECATION")
private fun Window.setWindowParams() {
requestFeature(Window.FEATURE_NO_TITLE)
// Inflate the decor view, so the attributes below are not overwritten by the theme.
decorView
attributes.systemUiVisibility =
(attributes.systemUiVisibility
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
setLayout(MATCH_PARENT, MATCH_PARENT)
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED)
setType(WindowManager.LayoutParams.TYPE_VOLUME_OVERLAY)
getAttributes().setFitInsetsTypes(0 /* types */)
}
init {
getWindow()?.setWindowParams()
window.setType(WindowManager.LayoutParams.TYPE_VOLUME_OVERLAY)
setContentView(R.layout.controls_detail_dialog)
@@ -90,19 +74,61 @@ class DetailDialog(
requireViewById<ViewGroup>(R.id.controls_activity_view).apply {
addView(activityView)
}
requireViewById<ImageView>(R.id.control_detail_close).apply {
setOnClickListener { _: View -> dismiss() }
}
requireViewById<TextView>(R.id.title).apply {
setText(cvh.title.text)
}
requireViewById<TextView>(R.id.subtitle).apply {
setText(cvh.subtitle.text)
}
requireViewById<ImageView>(R.id.control_detail_open_in_app).apply {
setOnClickListener { v: View ->
dismiss()
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
v.context.startActivity(intent)
}
}
// consume all insets to achieve slide under effect
window.getDecorView().setOnApplyWindowInsetsListener {
v: View, insets: WindowInsets ->
activityView.apply {
val l = getPaddingLeft()
val t = getPaddingTop()
val r = getPaddingRight()
setPadding(l, t, r, insets.getSystemWindowInsets().bottom)
}
insets.consumeSystemWindowInsets()
}
requireViewById<ViewGroup>(R.id.control_detail_root).apply {
// use flag only temporarily for testing
val resolver = cvh.context.contentResolver
val defaultOffsetInPx = cvh.context.resources
.getDimensionPixelSize(R.dimen.controls_activity_view_top_offset)
val offsetInPx = Settings.Secure.getInt(resolver, PANEL_TOP_OFFSET, defaultOffsetInPx)
val lp = getLayoutParams() as ViewGroup.MarginLayoutParams
lp.topMargin = offsetInPx
setLayoutParams(lp)
}
}
override fun show() {
val attrs = getWindow()?.attributes
attrs?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
getWindow()?.attributes = attrs
activityView.setCallback(stateCallback)
super.show()
}
override fun dismiss() {
if (!isShowing()) return
activityView.release()
super.dismiss()

View File

@@ -33,6 +33,10 @@ class TemperatureControlBehavior : Behavior {
override fun initialize(cvh: ControlViewHolder) {
this.cvh = cvh
cvh.layout.setOnClickListener { _ ->
ControlActionCoordinator.touch(cvh, template.getTemplateId(), control)
}
}
override fun bind(cws: ControlWithState) {

View File

@@ -20,7 +20,7 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.service.controls.Control
import android.service.controls.templates.StatelessTemplate
import android.service.controls.templates.ControlTemplate
import com.android.systemui.R
import com.android.systemui.controls.ui.ControlActionCoordinator.MIN_LEVEL
@@ -31,7 +31,7 @@ import com.android.systemui.controls.ui.ControlActionCoordinator.MIN_LEVEL
*/
class TouchBehavior : Behavior {
lateinit var clipLayer: Drawable
lateinit var template: StatelessTemplate
lateinit var template: ControlTemplate
lateinit var control: Control
lateinit var cvh: ControlViewHolder
@@ -40,14 +40,14 @@ class TouchBehavior : Behavior {
cvh.applyRenderInfo(false)
cvh.layout.setOnClickListener(View.OnClickListener() {
ControlActionCoordinator.touch(cvh, template.getTemplateId())
ControlActionCoordinator.touch(cvh, template.getTemplateId(), control)
})
}
override fun bind(cws: ControlWithState) {
this.control = cws.control!!
cvh.status.setText(control.getStatusText())
template = control.getControlTemplate() as StatelessTemplate
template = control.getControlTemplate()
val ld = cvh.layout.getBackground() as LayerDrawable
clipLayer = ld.findDrawableByLayerId(R.id.clip_layer)