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