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