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