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