• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.controls.management
18 
19 import android.content.ComponentName
20 import android.content.res.Configuration
21 import android.content.res.Resources
22 import android.graphics.Rect
23 import android.os.Bundle
24 import android.service.controls.Control
25 import android.service.controls.DeviceTypes
26 import android.util.TypedValue
27 import android.view.LayoutInflater
28 import android.view.View
29 import android.view.ViewGroup
30 import android.view.accessibility.AccessibilityNodeInfo
31 import android.widget.CheckBox
32 import android.widget.ImageView
33 import android.widget.Switch
34 import android.widget.TextView
35 import androidx.core.view.AccessibilityDelegateCompat
36 import androidx.core.view.ViewCompat
37 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
38 import androidx.recyclerview.widget.RecyclerView
39 import com.android.systemui.R
40 import com.android.systemui.controls.ControlInterface
41 import com.android.systemui.controls.ui.RenderInfo
42 
43 private typealias ModelFavoriteChanger = (String, Boolean) -> Unit
44 
45 /**
46  * Adapter for binding [Control] information to views.
47  *
48  * The model for this adapter is provided by a [ControlModel] that is set using
49  * [changeFavoritesModel]. This allows for updating the model if there's a reload.
50  *
51  * @property elevation elevation of each control view
52  */
53 class ControlAdapter(
54     private val elevation: Float
55 ) : RecyclerView.Adapter<Holder>() {
56 
57     companion object {
58         const val TYPE_ZONE = 0
59         const val TYPE_CONTROL = 1
60         const val TYPE_DIVIDER = 2
61 
62         /**
63          * For low-dp width screens that also employ an increased font scale, adjust the
64          * number of columns. This helps prevent text truncation on these devices.
65          *
66          */
67         @JvmStatic
68         fun findMaxColumns(res: Resources): Int {
69             var maxColumns = res.getInteger(R.integer.controls_max_columns)
70             val maxColumnsAdjustWidth =
71                     res.getInteger(R.integer.controls_max_columns_adjust_below_width_dp)
72 
73             val outValue = TypedValue()
74             res.getValue(R.dimen.controls_max_columns_adjust_above_font_scale, outValue, true)
75             val maxColumnsAdjustFontScale = outValue.getFloat()
76 
77             val config = res.configuration
78             val isPortrait = config.orientation == Configuration.ORIENTATION_PORTRAIT
79             if (isPortrait &&
80                     config.screenWidthDp != Configuration.SCREEN_WIDTH_DP_UNDEFINED &&
81                     config.screenWidthDp <= maxColumnsAdjustWidth &&
82                     config.fontScale >= maxColumnsAdjustFontScale) {
83                 maxColumns--
84             }
85 
86             return maxColumns
87         }
88     }
89 
90     private var model: ControlsModel? = null
91 
92     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
93         val layoutInflater = LayoutInflater.from(parent.context)
94         return when (viewType) {
95             TYPE_CONTROL -> {
96                 ControlHolder(
97                     layoutInflater.inflate(R.layout.controls_base_item, parent, false).apply {
98                         (layoutParams as ViewGroup.MarginLayoutParams).apply {
99                             width = ViewGroup.LayoutParams.MATCH_PARENT
100                             // Reset margins as they will be set through the decoration
101                             topMargin = 0
102                             bottomMargin = 0
103                             leftMargin = 0
104                             rightMargin = 0
105                         }
106                         elevation = this@ControlAdapter.elevation
107                         background = parent.context.getDrawable(
108                                 R.drawable.control_background_ripple)
109                     },
110                     model?.moveHelper // Indicates that position information is needed
111                 ) { id, favorite ->
112                     model?.changeFavoriteStatus(id, favorite)
113                 }
114             }
115             TYPE_ZONE -> {
116                 ZoneHolder(layoutInflater.inflate(R.layout.controls_zone_header, parent, false))
117             }
118             TYPE_DIVIDER -> {
119                 DividerHolder(layoutInflater.inflate(
120                         R.layout.controls_horizontal_divider_with_empty, parent, false))
121             }
122             else -> throw IllegalStateException("Wrong viewType: $viewType")
123         }
124     }
125 
126     fun changeModel(model: ControlsModel) {
127         this.model = model
128         notifyDataSetChanged()
129     }
130 
131     override fun getItemCount() = model?.elements?.size ?: 0
132 
133     override fun onBindViewHolder(holder: Holder, index: Int) {
134         model?.let {
135             holder.bindData(it.elements[index])
136         }
137     }
138 
139     override fun onBindViewHolder(holder: Holder, position: Int, payloads: MutableList<Any>) {
140         if (payloads.isEmpty()) {
141             super.onBindViewHolder(holder, position, payloads)
142         } else {
143             model?.let {
144                 val el = it.elements[position]
145                 if (el is ControlInterface) {
146                     holder.updateFavorite(el.favorite)
147                 }
148             }
149         }
150     }
151 
152     override fun getItemViewType(position: Int): Int {
153         model?.let {
154             return when (it.elements.get(position)) {
155                 is ZoneNameWrapper -> TYPE_ZONE
156                 is ControlStatusWrapper -> TYPE_CONTROL
157                 is ControlInfoWrapper -> TYPE_CONTROL
158                 is DividerWrapper -> TYPE_DIVIDER
159             }
160         } ?: throw IllegalStateException("Getting item type for null model")
161     }
162 }
163 
164 /**
165  * Holder for binding views in the [RecyclerView]-
166  * @param view the [View] for this [Holder]
167  */
168 sealed class Holder(view: View) : RecyclerView.ViewHolder(view) {
169 
170     /**
171      * Bind the data from the model into the view
172      */
bindDatanull173     abstract fun bindData(wrapper: ElementWrapper)
174 
175     open fun updateFavorite(favorite: Boolean) {}
176 }
177 
178 /**
179  * Holder for using with [DividerWrapper] to display a divider between zones.
180  *
181  * The divider can be shown or hidden. It also has a view the height of a control, that can
182  * be toggled visible or gone.
183  */
184 private class DividerHolder(view: View) : Holder(view) {
185     private val frame: View = itemView.requireViewById(R.id.frame)
186     private val divider: View = itemView.requireViewById(R.id.divider)
bindDatanull187     override fun bindData(wrapper: ElementWrapper) {
188         wrapper as DividerWrapper
189         frame.visibility = if (wrapper.showNone) View.VISIBLE else View.GONE
190         divider.visibility = if (wrapper.showDivider) View.VISIBLE else View.GONE
191     }
192 }
193 
194 /**
195  * Holder for using with [ZoneNameWrapper] to display names of zones.
196  */
197 private class ZoneHolder(view: View) : Holder(view) {
198     private val zone: TextView = itemView as TextView
199 
bindDatanull200     override fun bindData(wrapper: ElementWrapper) {
201         wrapper as ZoneNameWrapper
202         zone.text = wrapper.zoneName
203     }
204 }
205 
206 /**
207  * Holder for using with [ControlStatusWrapper] to display names of zones.
208  * @param moveHelper a helper interface to facilitate a11y rearranging. Null indicates no
209  *                   rearranging
210  * @param favoriteCallback this callback will be called whenever the favorite state of the
211  *                         [Control] this view represents changes.
212  */
213 internal class ControlHolder(
214     view: View,
215     val moveHelper: ControlsModel.MoveHelper?,
216     val favoriteCallback: ModelFavoriteChanger
217 ) : Holder(view) {
218     private val favoriteStateDescription =
219         itemView.context.getString(R.string.accessibility_control_favorite)
220     private val notFavoriteStateDescription =
221         itemView.context.getString(R.string.accessibility_control_not_favorite)
222 
223     private val icon: ImageView = itemView.requireViewById(R.id.icon)
224     private val title: TextView = itemView.requireViewById(R.id.title)
225     private val subtitle: TextView = itemView.requireViewById(R.id.subtitle)
226     private val removed: TextView = itemView.requireViewById(R.id.status)
<lambda>null227     private val favorite: CheckBox = itemView.requireViewById<CheckBox>(R.id.favorite).apply {
228         visibility = View.VISIBLE
229     }
230 
231     private val accessibilityDelegate = ControlHolderAccessibilityDelegate(
232         this::stateDescription,
233         this::getLayoutPosition,
234         moveHelper
235     )
236 
237     init {
238         ViewCompat.setAccessibilityDelegate(itemView, accessibilityDelegate)
239     }
240 
241     // Determine the stateDescription based on favorite state and maybe position
stateDescriptionnull242     private fun stateDescription(favorite: Boolean): CharSequence? {
243         if (!favorite) {
244             return notFavoriteStateDescription
245         } else if (moveHelper == null) {
246             return favoriteStateDescription
247         } else {
248             val position = layoutPosition + 1
249             return itemView.context.getString(
250                 R.string.accessibility_control_favorite_position, position)
251         }
252     }
253 
bindDatanull254     override fun bindData(wrapper: ElementWrapper) {
255         wrapper as ControlInterface
256         val renderInfo = getRenderInfo(wrapper.component, wrapper.deviceType)
257         title.text = wrapper.title
258         subtitle.text = wrapper.subtitle
259         updateFavorite(wrapper.favorite)
260         removed.text = if (wrapper.removed) {
261             itemView.context.getText(R.string.controls_removed)
262         } else {
263             ""
264         }
265         itemView.setOnClickListener {
266             updateFavorite(!favorite.isChecked)
267             favoriteCallback(wrapper.controlId, favorite.isChecked)
268         }
269         applyRenderInfo(renderInfo, wrapper)
270     }
271 
updateFavoritenull272     override fun updateFavorite(favorite: Boolean) {
273         this.favorite.isChecked = favorite
274         accessibilityDelegate.isFavorite = favorite
275         itemView.stateDescription = stateDescription(favorite)
276     }
277 
getRenderInfonull278     private fun getRenderInfo(
279         component: ComponentName,
280         @DeviceTypes.DeviceType deviceType: Int
281     ): RenderInfo {
282         return RenderInfo.lookup(itemView.context, component, deviceType)
283     }
284 
applyRenderInfonull285     private fun applyRenderInfo(ri: RenderInfo, ci: ControlInterface) {
286         val context = itemView.context
287         val fg = context.getResources().getColorStateList(ri.foreground, context.getTheme())
288 
289         icon.imageTintList = null
290         ci.customIcon?.let {
291             icon.setImageIcon(it)
292         } ?: run {
293             icon.setImageDrawable(ri.icon)
294 
295             // Do not color app icons
296             if (ci.deviceType != DeviceTypes.TYPE_ROUTINE) {
297                 icon.setImageTintList(fg)
298             }
299         }
300     }
301 }
302 
303 /**
304  * Accessibility delegate for [ControlHolder].
305  *
306  * Provides the following functionality:
307  * * Sets the state description indicating whether the controls is Favorited or Unfavorited
308  * * Adds the position to the state description if necessary.
309  * * Adds context action for moving (rearranging) a control.
310  *
311  * @param stateRetriever function to determine the state description based on the favorite state
312  * @param positionRetriever function to obtain the position of this control. It only has to be
313  *                          correct in controls that are currently favorites (and therefore can
314  *                          be moved).
315  * @param moveHelper helper interface to determine if a control can be moved and actually move it.
316  */
317 private class ControlHolderAccessibilityDelegate(
318     val stateRetriever: (Boolean) -> CharSequence?,
319     val positionRetriever: () -> Int,
320     val moveHelper: ControlsModel.MoveHelper?
321 ) : AccessibilityDelegateCompat() {
322 
323     var isFavorite = false
324 
325     companion object {
326         private val MOVE_BEFORE_ID = R.id.accessibility_action_controls_move_before
327         private val MOVE_AFTER_ID = R.id.accessibility_action_controls_move_after
328     }
329 
onInitializeAccessibilityNodeInfonull330     override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
331         super.onInitializeAccessibilityNodeInfo(host, info)
332 
333         info.isContextClickable = false
334         addClickAction(host, info)
335         maybeAddMoveBeforeAction(host, info)
336         maybeAddMoveAfterAction(host, info)
337 
338         // Determine the stateDescription based on the holder information
339         info.stateDescription = stateRetriever(isFavorite)
340         // Remove the information at the end indicating row and column.
341         info.setCollectionItemInfo(null)
342 
343         info.className = Switch::class.java.name
344     }
345 
performAccessibilityActionnull346     override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean {
347         if (super.performAccessibilityAction(host, action, args)) {
348             return true
349         }
350         return when (action) {
351             MOVE_BEFORE_ID -> {
352                 moveHelper?.moveBefore(positionRetriever())
353                 true
354             }
355             MOVE_AFTER_ID -> {
356                 moveHelper?.moveAfter(positionRetriever())
357                 true
358             }
359             else -> false
360         }
361     }
362 
addClickActionnull363     private fun addClickAction(host: View, info: AccessibilityNodeInfoCompat) {
364         // Change the text for the double-tap action
365         val clickActionString = if (isFavorite) {
366             host.context.getString(R.string.accessibility_control_change_unfavorite)
367         } else {
368             host.context.getString(R.string.accessibility_control_change_favorite)
369         }
370         val click = AccessibilityNodeInfoCompat.AccessibilityActionCompat(
371             AccessibilityNodeInfo.ACTION_CLICK,
372             // “favorite/unfavorite
373             clickActionString)
374         info.addAction(click)
375     }
376 
maybeAddMoveBeforeActionnull377     private fun maybeAddMoveBeforeAction(host: View, info: AccessibilityNodeInfoCompat) {
378         if (moveHelper?.canMoveBefore(positionRetriever()) ?: false) {
379             val newPosition = positionRetriever() + 1 - 1
380             val moveBefore = AccessibilityNodeInfoCompat.AccessibilityActionCompat(
381                 MOVE_BEFORE_ID,
382                 host.context.getString(R.string.accessibility_control_move, newPosition)
383             )
384             info.addAction(moveBefore)
385             info.isContextClickable = true
386         }
387     }
388 
maybeAddMoveAfterActionnull389     private fun maybeAddMoveAfterAction(host: View, info: AccessibilityNodeInfoCompat) {
390         if (moveHelper?.canMoveAfter(positionRetriever()) ?: false) {
391             val newPosition = positionRetriever() + 1 + 1
392             val moveAfter = AccessibilityNodeInfoCompat.AccessibilityActionCompat(
393                 MOVE_AFTER_ID,
394                 host.context.getString(R.string.accessibility_control_move, newPosition)
395             )
396             info.addAction(moveAfter)
397             info.isContextClickable = true
398         }
399     }
400 }
401 
402 class MarginItemDecorator(
403     private val topMargin: Int,
404     private val sideMargins: Int
405 ) : RecyclerView.ItemDecoration() {
406 
getItemOffsetsnull407     override fun getItemOffsets(
408         outRect: Rect,
409         view: View,
410         parent: RecyclerView,
411         state: RecyclerView.State
412     ) {
413         val position = parent.getChildAdapterPosition(view)
414         if (position == RecyclerView.NO_POSITION) return
415         val type = parent.adapter?.getItemViewType(position)
416         if (type == ControlAdapter.TYPE_CONTROL) {
417             outRect.apply {
418                 top = topMargin * 2 // Use double margin, as we are not setting bottom
419                 left = sideMargins
420                 right = sideMargins
421                 bottom = 0
422             }
423         } else if (type == ControlAdapter.TYPE_ZONE && position == 0) {
424             // add negative padding to the first zone to counteract the margin
425             val margin = (view.layoutParams as ViewGroup.MarginLayoutParams).topMargin
426             outRect.apply {
427                 top = -margin
428                 left = 0
429                 right = 0
430                 bottom = 0
431             }
432         }
433     }
434 }
435