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