1 /* <lambda>null2 * Copyright (C) 2024 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.bluetooth.qsdialog 18 19 import android.os.Bundle 20 import android.view.LayoutInflater 21 import android.view.View 22 import android.view.View.AccessibilityDelegate 23 import android.view.View.GONE 24 import android.view.View.INVISIBLE 25 import android.view.View.VISIBLE 26 import android.view.ViewGroup 27 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT 28 import android.view.accessibility.AccessibilityNodeInfo 29 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction 30 import android.widget.Button 31 import android.widget.ImageView 32 import android.widget.ProgressBar 33 import android.widget.Switch 34 import android.widget.TextView 35 import androidx.annotation.StringRes 36 import androidx.recyclerview.widget.AsyncListDiffer 37 import androidx.recyclerview.widget.DiffUtil 38 import androidx.recyclerview.widget.LinearLayoutManager 39 import androidx.recyclerview.widget.RecyclerView 40 import com.android.internal.R as InternalR 41 import com.android.internal.logging.UiEventLogger 42 import com.android.systemui.dagger.qualifiers.Main 43 import com.android.systemui.res.R 44 import com.android.systemui.statusbar.phone.SystemUIDialog 45 import com.android.systemui.util.time.SystemClock 46 import dagger.assisted.Assisted 47 import dagger.assisted.AssistedFactory 48 import dagger.assisted.AssistedInject 49 import kotlinx.coroutines.CoroutineDispatcher 50 import kotlinx.coroutines.delay 51 import kotlinx.coroutines.flow.MutableSharedFlow 52 import kotlinx.coroutines.flow.MutableStateFlow 53 import kotlinx.coroutines.flow.asSharedFlow 54 import kotlinx.coroutines.flow.asStateFlow 55 import kotlinx.coroutines.isActive 56 import kotlinx.coroutines.withContext 57 58 /** Dialog for showing active, connected and saved bluetooth devices. */ 59 class BluetoothTileDialogDelegate 60 @AssistedInject 61 internal constructor( 62 @Assisted private val initialUiProperties: BluetoothTileDialogViewModel.UiProperties, 63 @Assisted private val cachedContentHeight: Int, 64 @Assisted private val bluetoothTileDialogCallback: BluetoothTileDialogCallback, 65 @Assisted private val dismissListener: Runnable, 66 @Main private val mainDispatcher: CoroutineDispatcher, 67 private val systemClock: SystemClock, 68 private val uiEventLogger: UiEventLogger, 69 private val logger: BluetoothTileDialogLogger, 70 private val systemuiDialogFactory: SystemUIDialog.Factory, 71 ) : SystemUIDialog.Delegate { 72 73 private val mutableBluetoothStateToggle: MutableStateFlow<Boolean?> = MutableStateFlow(null) 74 internal val bluetoothStateToggle 75 get() = mutableBluetoothStateToggle.asStateFlow() 76 77 private val mutableBluetoothAutoOnToggle: MutableStateFlow<Boolean?> = MutableStateFlow(null) 78 internal val bluetoothAutoOnToggle 79 get() = mutableBluetoothAutoOnToggle.asStateFlow() 80 81 private val mutableDeviceItemClick: MutableSharedFlow<DeviceItem> = 82 MutableSharedFlow(extraBufferCapacity = 1) 83 internal val deviceItemClick 84 get() = mutableDeviceItemClick.asSharedFlow() 85 86 private val mutableContentHeight: MutableSharedFlow<Int> = 87 MutableSharedFlow(extraBufferCapacity = 1) 88 internal val contentHeight 89 get() = mutableContentHeight.asSharedFlow() 90 91 private val deviceItemAdapter: Adapter = Adapter(bluetoothTileDialogCallback) 92 93 private var lastUiUpdateMs: Long = -1 94 95 private var lastItemRow: Int = -1 96 97 @AssistedFactory 98 internal interface Factory { 99 fun create( 100 initialUiProperties: BluetoothTileDialogViewModel.UiProperties, 101 cachedContentHeight: Int, 102 dialogCallback: BluetoothTileDialogCallback, 103 dimissListener: Runnable, 104 ): BluetoothTileDialogDelegate 105 } 106 107 override fun createDialog(): SystemUIDialog { 108 return systemuiDialogFactory.create(this) 109 } 110 111 override fun onCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) { 112 SystemUIDialog.registerDismissListener(dialog, dismissListener) 113 uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_TILE_DIALOG_SHOWN) 114 val context = dialog.context 115 116 LayoutInflater.from(context).inflate(R.layout.bluetooth_tile_dialog, null).apply { 117 accessibilityPaneTitle = context.getText(R.string.accessibility_desc_quick_settings) 118 dialog.setContentView(this) 119 } 120 121 setupToggle(dialog) 122 setupRecyclerView(dialog) 123 124 getSubtitleTextView(dialog).text = context.getString(initialUiProperties.subTitleResId) 125 dialog.requireViewById<View>(R.id.done_button).setOnClickListener { dialog.dismiss() } 126 getSeeAllButton(dialog).setOnClickListener { 127 bluetoothTileDialogCallback.onSeeAllClicked(it) 128 } 129 getPairNewDeviceButton(dialog).setOnClickListener { 130 bluetoothTileDialogCallback.onPairNewDeviceClicked(it) 131 } 132 getAudioSharingButtonView(dialog).apply { 133 setOnClickListener { bluetoothTileDialogCallback.onAudioSharingButtonClicked(it) } 134 accessibilityDelegate = 135 object : AccessibilityDelegate() { 136 override fun onInitializeAccessibilityNodeInfo( 137 host: View, 138 info: AccessibilityNodeInfo, 139 ) { 140 super.onInitializeAccessibilityNodeInfo(host, info) 141 info.addAction( 142 AccessibilityAction( 143 AccessibilityAction.ACTION_CLICK.id, 144 context.getString( 145 R.string 146 .quick_settings_bluetooth_audio_sharing_button_accessibility 147 ), 148 ) 149 ) 150 } 151 } 152 } 153 getScrollViewContent(dialog).apply { 154 minimumHeight = 155 resources.getDimensionPixelSize(initialUiProperties.scrollViewMinHeightResId) 156 layoutParams.height = maxOf(cachedContentHeight, minimumHeight) 157 } 158 } 159 160 override fun onStart(dialog: SystemUIDialog) { 161 lastUiUpdateMs = systemClock.elapsedRealtime() 162 } 163 164 override fun onStop(dialog: SystemUIDialog) { 165 mutableContentHeight.tryEmit(getScrollViewContent(dialog).measuredHeight) 166 } 167 168 internal suspend fun animateProgressBar(dialog: SystemUIDialog, animate: Boolean) { 169 withContext(mainDispatcher) { 170 if (animate) { 171 showProgressBar(dialog) 172 } else { 173 delay(PROGRESS_BAR_ANIMATION_DURATION_MS) 174 hideProgressBar(dialog) 175 } 176 } 177 } 178 179 internal suspend fun onDeviceItemUpdated( 180 dialog: SystemUIDialog, 181 deviceItem: List<DeviceItem>, 182 showSeeAll: Boolean, 183 showPairNewDevice: Boolean, 184 ) { 185 withContext(mainDispatcher) { 186 val start = systemClock.elapsedRealtime() 187 val itemRow = deviceItem.size + showSeeAll.toInt() + showPairNewDevice.toInt() 188 // If not the first load, add a slight delay for smoother dialog height change 189 if (itemRow != lastItemRow && lastItemRow != -1) { 190 delay(MIN_HEIGHT_CHANGE_INTERVAL_MS - (start - lastUiUpdateMs)) 191 } 192 if (isActive) { 193 deviceItemAdapter.refreshDeviceItemList(deviceItem) { 194 getSeeAllButton(dialog).visibility = if (showSeeAll) VISIBLE else GONE 195 getPairNewDeviceButton(dialog).visibility = 196 if (showPairNewDevice) VISIBLE else GONE 197 // Update the height after data is updated 198 getScrollViewContent(dialog).layoutParams.height = WRAP_CONTENT 199 lastUiUpdateMs = systemClock.elapsedRealtime() 200 lastItemRow = itemRow 201 logger.logDeviceUiUpdate(lastUiUpdateMs - start) 202 } 203 } 204 } 205 } 206 207 internal fun onBluetoothStateUpdated( 208 dialog: SystemUIDialog, 209 isEnabled: Boolean, 210 uiProperties: BluetoothTileDialogViewModel.UiProperties, 211 ) { 212 getToggleView(dialog).apply { 213 isChecked = isEnabled 214 setEnabled(true) 215 alpha = ENABLED_ALPHA 216 } 217 getSubtitleTextView(dialog).text = dialog.context.getString(uiProperties.subTitleResId) 218 getAutoOnToggleView(dialog).visibility = uiProperties.autoOnToggleVisibility 219 } 220 221 internal fun onBluetoothAutoOnUpdated( 222 dialog: SystemUIDialog, 223 isEnabled: Boolean, 224 @StringRes infoResId: Int, 225 ) { 226 getAutoOnToggle(dialog).isChecked = isEnabled 227 getAutoOnToggleInfoTextView(dialog).text = dialog.context.getString(infoResId) 228 } 229 230 internal fun onAudioSharingButtonUpdated( 231 dialog: SystemUIDialog, 232 visibility: Int, 233 label: String?, 234 isActive: Boolean, 235 ) { 236 getAudioSharingButtonView(dialog).apply { 237 this.visibility = visibility 238 label?.let { text = it } 239 this.isActivated = isActive 240 } 241 } 242 243 private fun setupToggle(dialog: SystemUIDialog) { 244 val toggleView = getToggleView(dialog) 245 toggleView.setOnCheckedChangeListener { view, isChecked -> 246 mutableBluetoothStateToggle.value = isChecked 247 view.apply { 248 isEnabled = false 249 alpha = DISABLED_ALPHA 250 } 251 logger.logBluetoothState(BluetoothStateStage.USER_TOGGLED, isChecked.toString()) 252 uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_TOGGLE_CLICKED) 253 } 254 255 getAutoOnToggleView(dialog).visibility = initialUiProperties.autoOnToggleVisibility 256 getAutoOnToggle(dialog).setOnCheckedChangeListener { _, isChecked -> 257 mutableBluetoothAutoOnToggle.value = isChecked 258 uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUTO_ON_TOGGLE_CLICKED) 259 } 260 } 261 262 private fun getToggleView(dialog: SystemUIDialog): Switch { 263 return dialog.requireViewById(R.id.bluetooth_toggle) 264 } 265 266 private fun getSubtitleTextView(dialog: SystemUIDialog): TextView { 267 return dialog.requireViewById(R.id.bluetooth_tile_dialog_subtitle) 268 } 269 270 private fun getSeeAllButton(dialog: SystemUIDialog): View { 271 return dialog.requireViewById(R.id.see_all_button) 272 } 273 274 private fun getPairNewDeviceButton(dialog: SystemUIDialog): View { 275 return dialog.requireViewById(R.id.pair_new_device_button) 276 } 277 278 private fun getDeviceListView(dialog: SystemUIDialog): RecyclerView { 279 return dialog.requireViewById(R.id.device_list) 280 } 281 282 private fun getAutoOnToggle(dialog: SystemUIDialog): Switch { 283 return dialog.requireViewById(R.id.bluetooth_auto_on_toggle) 284 } 285 286 private fun getAudioSharingButtonView(dialog: SystemUIDialog): Button { 287 return dialog.requireViewById(R.id.audio_sharing_button) 288 } 289 290 private fun getAutoOnToggleView(dialog: SystemUIDialog): View { 291 return dialog.requireViewById(R.id.bluetooth_auto_on_toggle_layout) 292 } 293 294 private fun getAutoOnToggleInfoTextView(dialog: SystemUIDialog): TextView { 295 return dialog.requireViewById(R.id.bluetooth_auto_on_toggle_info_text) 296 } 297 298 private fun getProgressBarAnimation(dialog: SystemUIDialog): ProgressBar { 299 return dialog.requireViewById(R.id.bluetooth_tile_dialog_progress_animation) 300 } 301 302 private fun getProgressBarBackground(dialog: SystemUIDialog): View { 303 return dialog.requireViewById(R.id.bluetooth_tile_dialog_progress_background) 304 } 305 306 private fun getScrollViewContent(dialog: SystemUIDialog): View { 307 return dialog.requireViewById(R.id.scroll_view) 308 } 309 310 private fun setupRecyclerView(dialog: SystemUIDialog) { 311 getDeviceListView(dialog).apply { 312 layoutManager = LinearLayoutManager(dialog.context) 313 adapter = deviceItemAdapter 314 } 315 } 316 317 private fun showProgressBar(dialog: SystemUIDialog) { 318 val progressBarAnimation = getProgressBarAnimation(dialog) 319 val progressBarBackground = getProgressBarBackground(dialog) 320 if (progressBarAnimation.visibility != VISIBLE) { 321 progressBarAnimation.visibility = VISIBLE 322 progressBarBackground.visibility = INVISIBLE 323 } 324 } 325 326 private fun hideProgressBar(dialog: SystemUIDialog) { 327 val progressBarAnimation = getProgressBarAnimation(dialog) 328 val progressBarBackground = getProgressBarBackground(dialog) 329 if (progressBarAnimation.visibility != INVISIBLE) { 330 progressBarAnimation.visibility = INVISIBLE 331 progressBarBackground.visibility = VISIBLE 332 } 333 } 334 335 internal inner class Adapter(private val onClickCallback: BluetoothTileDialogCallback) : 336 RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() { 337 338 private val diffUtilCallback = 339 object : DiffUtil.ItemCallback<DeviceItem>() { 340 override fun areItemsTheSame( 341 deviceItem1: DeviceItem, 342 deviceItem2: DeviceItem, 343 ): Boolean { 344 return deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice 345 } 346 347 override fun areContentsTheSame( 348 deviceItem1: DeviceItem, 349 deviceItem2: DeviceItem, 350 ): Boolean { 351 return deviceItem1.type == deviceItem2.type && 352 deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice && 353 deviceItem1.deviceName == deviceItem2.deviceName && 354 deviceItem1.connectionSummary == deviceItem2.connectionSummary && 355 // Ignored the icon drawable 356 deviceItem1.iconWithDescription?.second == 357 deviceItem2.iconWithDescription?.second && 358 deviceItem1.background == deviceItem2.background && 359 deviceItem1.isEnabled == deviceItem2.isEnabled && 360 deviceItem1.actionAccessibilityLabel == deviceItem2.actionAccessibilityLabel 361 } 362 } 363 364 private val asyncListDiffer = AsyncListDiffer(this, diffUtilCallback) 365 366 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceItemViewHolder { 367 val view = 368 LayoutInflater.from(parent.context) 369 .inflate(R.layout.bluetooth_device_item, parent, false) 370 return DeviceItemViewHolder(view) 371 } 372 373 override fun getItemCount() = asyncListDiffer.currentList.size 374 375 override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) { 376 val item = getItem(position) 377 holder.bind(item, onClickCallback) 378 } 379 380 internal fun getItem(position: Int) = asyncListDiffer.currentList[position] 381 382 internal fun refreshDeviceItemList(updated: List<DeviceItem>, callback: () -> Unit) { 383 asyncListDiffer.submitList(updated, callback) 384 } 385 386 internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { 387 private val container = view.requireViewById<View>(R.id.bluetooth_device_row) 388 private val nameView = view.requireViewById<TextView>(R.id.bluetooth_device_name) 389 private val summaryView = view.requireViewById<TextView>(R.id.bluetooth_device_summary) 390 private val iconView = view.requireViewById<ImageView>(R.id.bluetooth_device_icon) 391 private val iconGear = view.requireViewById<ImageView>(R.id.gear_icon_image) 392 private val gearView = view.requireViewById<View>(R.id.gear_icon) 393 private val divider = view.requireViewById<View>(R.id.divider) 394 395 internal fun bind( 396 item: DeviceItem, 397 deviceItemOnClickCallback: BluetoothTileDialogCallback, 398 ) { 399 container.apply { 400 isEnabled = item.isEnabled 401 background = item.background?.let { context.getDrawable(it) } 402 setOnClickListener { 403 mutableDeviceItemClick.tryEmit(item) 404 uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_CLICKED) 405 } 406 407 // updating icon colors 408 val tintColor = 409 com.android.settingslib.Utils.getColorAttr( 410 context, 411 if (item.isActive) InternalR.attr.materialColorOnPrimaryContainer 412 else InternalR.attr.materialColorOnSurface, 413 ) 414 .defaultColor 415 416 // update icons 417 iconView.apply { 418 item.iconWithDescription?.let { 419 setImageDrawable(it.first) 420 contentDescription = it.second 421 } 422 } 423 424 iconGear.apply { drawable?.let { it.mutate()?.setTint(tintColor) } } 425 426 divider.setBackgroundColor(tintColor) 427 428 // update text styles 429 nameView.setTextAppearance( 430 if (item.isActive) R.style.TextAppearance_BluetoothTileDialog_Active 431 else R.style.TextAppearance_BluetoothTileDialog 432 ) 433 summaryView.setTextAppearance( 434 if (item.isActive) R.style.TextAppearance_BluetoothTileDialog_Active 435 else R.style.TextAppearance_BluetoothTileDialog 436 ) 437 438 accessibilityDelegate = 439 object : AccessibilityDelegate() { 440 override fun onInitializeAccessibilityNodeInfo( 441 host: View, 442 info: AccessibilityNodeInfo, 443 ) { 444 super.onInitializeAccessibilityNodeInfo(host, info) 445 info.addAction( 446 AccessibilityAction( 447 AccessibilityAction.ACTION_CLICK.id, 448 item.actionAccessibilityLabel, 449 ) 450 ) 451 } 452 } 453 } 454 nameView.text = item.deviceName 455 summaryView.text = item.connectionSummary 456 457 gearView.setOnClickListener { 458 deviceItemOnClickCallback.onDeviceItemGearClicked(item, it) 459 } 460 } 461 } 462 } 463 464 internal companion object { 465 const val MIN_HEIGHT_CHANGE_INTERVAL_MS = 800L 466 const val ACTION_BLUETOOTH_DEVICE_DETAILS = 467 "com.android.settings.BLUETOOTH_DEVICE_DETAIL_SETTINGS" 468 const val ACTION_PREVIOUSLY_CONNECTED_DEVICE = 469 "com.android.settings.PREVIOUSLY_CONNECTED_DEVICE" 470 const val ACTION_PAIR_NEW_DEVICE = "android.settings.BLUETOOTH_PAIRING_SETTINGS" 471 const val ACTION_AUDIO_SHARING = "com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS" 472 const val DISABLED_ALPHA = 0.3f 473 const val ENABLED_ALPHA = 1f 474 const val PROGRESS_BAR_ANIMATION_DURATION_MS = 1500L 475 476 private fun Boolean.toInt(): Int { 477 return if (this) 1 else 0 478 } 479 } 480 } 481