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