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