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).setOnClickListener { 133 bluetoothTileDialogCallback.onAudioSharingButtonClicked(it) 134 } 135 getScrollViewContent(dialog).apply { 136 minimumHeight = 137 resources.getDimensionPixelSize(initialUiProperties.scrollViewMinHeightResId) 138 layoutParams.height = maxOf(cachedContentHeight, minimumHeight) 139 } 140 } 141 142 override fun onStart(dialog: SystemUIDialog) { 143 lastUiUpdateMs = systemClock.elapsedRealtime() 144 } 145 146 override fun onStop(dialog: SystemUIDialog) { 147 mutableContentHeight.tryEmit(getScrollViewContent(dialog).measuredHeight) 148 } 149 150 internal suspend fun animateProgressBar(dialog: SystemUIDialog, animate: Boolean) { 151 withContext(mainDispatcher) { 152 if (animate) { 153 showProgressBar(dialog) 154 } else { 155 delay(PROGRESS_BAR_ANIMATION_DURATION_MS) 156 hideProgressBar(dialog) 157 } 158 } 159 } 160 161 internal suspend fun onDeviceItemUpdated( 162 dialog: SystemUIDialog, 163 deviceItem: List<DeviceItem>, 164 showSeeAll: Boolean, 165 showPairNewDevice: Boolean 166 ) { 167 withContext(mainDispatcher) { 168 val start = systemClock.elapsedRealtime() 169 val itemRow = deviceItem.size + showSeeAll.toInt() + showPairNewDevice.toInt() 170 // If not the first load, add a slight delay for smoother dialog height change 171 if (itemRow != lastItemRow && lastItemRow != -1) { 172 delay(MIN_HEIGHT_CHANGE_INTERVAL_MS - (start - lastUiUpdateMs)) 173 } 174 if (isActive) { 175 deviceItemAdapter.refreshDeviceItemList(deviceItem) { 176 getSeeAllButton(dialog).visibility = if (showSeeAll) VISIBLE else GONE 177 getPairNewDeviceButton(dialog).visibility = 178 if (showPairNewDevice) VISIBLE else GONE 179 // Update the height after data is updated 180 getScrollViewContent(dialog).layoutParams.height = WRAP_CONTENT 181 lastUiUpdateMs = systemClock.elapsedRealtime() 182 lastItemRow = itemRow 183 logger.logDeviceUiUpdate(lastUiUpdateMs - start) 184 } 185 } 186 } 187 } 188 189 internal fun onBluetoothStateUpdated( 190 dialog: SystemUIDialog, 191 isEnabled: Boolean, 192 uiProperties: BluetoothTileDialogViewModel.UiProperties 193 ) { 194 getToggleView(dialog).apply { 195 isChecked = isEnabled 196 setEnabled(true) 197 alpha = ENABLED_ALPHA 198 } 199 getSubtitleTextView(dialog).text = dialog.context.getString(uiProperties.subTitleResId) 200 getAutoOnToggleView(dialog).visibility = uiProperties.autoOnToggleVisibility 201 } 202 203 internal fun onBluetoothAutoOnUpdated( 204 dialog: SystemUIDialog, 205 isEnabled: Boolean, 206 @StringRes infoResId: Int 207 ) { 208 getAutoOnToggle(dialog).isChecked = isEnabled 209 getAutoOnToggleInfoTextView(dialog).text = dialog.context.getString(infoResId) 210 } 211 212 internal fun onAudioSharingButtonUpdated( 213 dialog: SystemUIDialog, 214 visibility: Int, 215 label: String? 216 ) { 217 getAudioSharingButtonView(dialog).apply { 218 this.visibility = visibility 219 label?.let { text = it } 220 } 221 } 222 223 private fun setupToggle(dialog: SystemUIDialog) { 224 val toggleView = getToggleView(dialog) 225 toggleView.setOnCheckedChangeListener { view, isChecked -> 226 mutableBluetoothStateToggle.value = isChecked 227 view.apply { 228 isEnabled = false 229 alpha = DISABLED_ALPHA 230 } 231 logger.logBluetoothState(BluetoothStateStage.USER_TOGGLED, isChecked.toString()) 232 uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_TOGGLE_CLICKED) 233 } 234 235 getAutoOnToggleView(dialog).visibility = initialUiProperties.autoOnToggleVisibility 236 getAutoOnToggle(dialog).setOnCheckedChangeListener { _, isChecked -> 237 mutableBluetoothAutoOnToggle.value = isChecked 238 uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUTO_ON_TOGGLE_CLICKED) 239 } 240 } 241 242 private fun getToggleView(dialog: SystemUIDialog): Switch { 243 return dialog.requireViewById(R.id.bluetooth_toggle) 244 } 245 246 private fun getSubtitleTextView(dialog: SystemUIDialog): TextView { 247 return dialog.requireViewById(R.id.bluetooth_tile_dialog_subtitle) 248 } 249 250 private fun getSeeAllButton(dialog: SystemUIDialog): View { 251 return dialog.requireViewById(R.id.see_all_button) 252 } 253 254 private fun getPairNewDeviceButton(dialog: SystemUIDialog): View { 255 return dialog.requireViewById(R.id.pair_new_device_button) 256 } 257 258 private fun getDeviceListView(dialog: SystemUIDialog): RecyclerView { 259 return dialog.requireViewById(R.id.device_list) 260 } 261 262 private fun getAutoOnToggle(dialog: SystemUIDialog): Switch { 263 return dialog.requireViewById(R.id.bluetooth_auto_on_toggle) 264 } 265 266 private fun getAudioSharingButtonView(dialog: SystemUIDialog): Button { 267 return dialog.requireViewById(R.id.audio_sharing_button) 268 } 269 270 private fun getAutoOnToggleView(dialog: SystemUIDialog): View { 271 return dialog.requireViewById(R.id.bluetooth_auto_on_toggle_layout) 272 } 273 274 private fun getAutoOnToggleInfoTextView(dialog: SystemUIDialog): TextView { 275 return dialog.requireViewById(R.id.bluetooth_auto_on_toggle_info_text) 276 } 277 278 private fun getProgressBarAnimation(dialog: SystemUIDialog): ProgressBar { 279 return dialog.requireViewById(R.id.bluetooth_tile_dialog_progress_animation) 280 } 281 282 private fun getProgressBarBackground(dialog: SystemUIDialog): View { 283 return dialog.requireViewById(R.id.bluetooth_tile_dialog_progress_animation) 284 } 285 286 private fun getScrollViewContent(dialog: SystemUIDialog): View { 287 return dialog.requireViewById(R.id.scroll_view) 288 } 289 290 private fun setupRecyclerView(dialog: SystemUIDialog) { 291 getDeviceListView(dialog).apply { 292 layoutManager = LinearLayoutManager(dialog.context) 293 adapter = deviceItemAdapter 294 } 295 } 296 297 private fun showProgressBar(dialog: SystemUIDialog) { 298 val progressBarAnimation = getProgressBarAnimation(dialog) 299 val progressBarBackground = getProgressBarBackground(dialog) 300 if (progressBarAnimation.visibility != VISIBLE) { 301 progressBarAnimation.visibility = VISIBLE 302 progressBarBackground.visibility = INVISIBLE 303 } 304 } 305 306 private fun hideProgressBar(dialog: SystemUIDialog) { 307 val progressBarAnimation = getProgressBarAnimation(dialog) 308 val progressBarBackground = getProgressBarBackground(dialog) 309 if (progressBarAnimation.visibility != INVISIBLE) { 310 progressBarAnimation.visibility = INVISIBLE 311 progressBarBackground.visibility = VISIBLE 312 } 313 } 314 315 internal inner class Adapter(private val onClickCallback: BluetoothTileDialogCallback) : 316 RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() { 317 318 private val diffUtilCallback = 319 object : DiffUtil.ItemCallback<DeviceItem>() { 320 override fun areItemsTheSame( 321 deviceItem1: DeviceItem, 322 deviceItem2: DeviceItem 323 ): Boolean { 324 return deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice 325 } 326 327 override fun areContentsTheSame( 328 deviceItem1: DeviceItem, 329 deviceItem2: DeviceItem 330 ): Boolean { 331 return deviceItem1.type == deviceItem2.type && 332 deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice && 333 deviceItem1.deviceName == deviceItem2.deviceName && 334 deviceItem1.connectionSummary == deviceItem2.connectionSummary && 335 // Ignored the icon drawable 336 deviceItem1.iconWithDescription?.second == 337 deviceItem2.iconWithDescription?.second && 338 deviceItem1.background == deviceItem2.background && 339 deviceItem1.isEnabled == deviceItem2.isEnabled && 340 deviceItem1.actionAccessibilityLabel == deviceItem2.actionAccessibilityLabel 341 } 342 } 343 344 private val asyncListDiffer = AsyncListDiffer(this, diffUtilCallback) 345 346 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceItemViewHolder { 347 val view = 348 LayoutInflater.from(parent.context) 349 .inflate(R.layout.bluetooth_device_item, parent, false) 350 return DeviceItemViewHolder(view) 351 } 352 353 override fun getItemCount() = asyncListDiffer.currentList.size 354 355 override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) { 356 val item = getItem(position) 357 holder.bind(item, onClickCallback) 358 } 359 360 internal fun getItem(position: Int) = asyncListDiffer.currentList[position] 361 362 internal fun refreshDeviceItemList(updated: List<DeviceItem>, callback: () -> Unit) { 363 asyncListDiffer.submitList(updated, callback) 364 } 365 366 internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { 367 private val container = view.requireViewById<View>(R.id.bluetooth_device_row) 368 private val nameView = view.requireViewById<TextView>(R.id.bluetooth_device_name) 369 private val summaryView = view.requireViewById<TextView>(R.id.bluetooth_device_summary) 370 private val iconView = view.requireViewById<ImageView>(R.id.bluetooth_device_icon) 371 private val iconGear = view.requireViewById<ImageView>(R.id.gear_icon_image) 372 private val gearView = view.requireViewById<View>(R.id.gear_icon) 373 374 internal fun bind( 375 item: DeviceItem, 376 deviceItemOnClickCallback: BluetoothTileDialogCallback 377 ) { 378 container.apply { 379 isEnabled = item.isEnabled 380 background = item.background?.let { context.getDrawable(it) } 381 setOnClickListener { 382 mutableDeviceItemClick.tryEmit(item) 383 uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_CLICKED) 384 } 385 386 // updating icon colors 387 val tintColor = 388 com.android.settingslib.Utils.getColorAttr( 389 context, 390 if (item.isActive) InternalR.attr.materialColorOnPrimaryContainer 391 else InternalR.attr.materialColorOnSurface 392 ) 393 .defaultColor 394 395 // update icons 396 iconView.apply { 397 item.iconWithDescription?.let { 398 setImageDrawable(it.first.apply { mutate()?.setTint(tintColor) }) 399 contentDescription = it.second 400 } 401 } 402 403 iconGear.apply { drawable?.let { it.mutate()?.setTint(tintColor) } } 404 405 // update text styles 406 nameView.setTextAppearance( 407 if (item.isActive) R.style.BluetoothTileDialog_DeviceName_Active 408 else R.style.BluetoothTileDialog_DeviceName 409 ) 410 summaryView.setTextAppearance( 411 if (item.isActive) R.style.BluetoothTileDialog_DeviceSummary_Active 412 else R.style.BluetoothTileDialog_DeviceSummary 413 ) 414 415 accessibilityDelegate = 416 object : AccessibilityDelegate() { 417 override fun onInitializeAccessibilityNodeInfo( 418 host: View, 419 info: AccessibilityNodeInfo 420 ) { 421 super.onInitializeAccessibilityNodeInfo(host, info) 422 info.addAction( 423 AccessibilityAction( 424 AccessibilityAction.ACTION_CLICK.id, 425 item.actionAccessibilityLabel 426 ) 427 ) 428 } 429 } 430 } 431 nameView.text = item.deviceName 432 summaryView.text = item.connectionSummary 433 434 gearView.setOnClickListener { 435 deviceItemOnClickCallback.onDeviceItemGearClicked(item, it) 436 } 437 } 438 } 439 } 440 441 internal companion object { 442 const val MIN_HEIGHT_CHANGE_INTERVAL_MS = 800L 443 const val MAX_DEVICE_ITEM_ENTRY = 3 444 const val ACTION_BLUETOOTH_DEVICE_DETAILS = 445 "com.android.settings.BLUETOOTH_DEVICE_DETAIL_SETTINGS" 446 const val ACTION_PREVIOUSLY_CONNECTED_DEVICE = 447 "com.android.settings.PREVIOUSLY_CONNECTED_DEVICE" 448 const val ACTION_PAIR_NEW_DEVICE = "android.settings.BLUETOOTH_PAIRING_SETTINGS" 449 const val ACTION_AUDIO_SHARING = "com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS" 450 const val DISABLED_ALPHA = 0.3f 451 const val ENABLED_ALPHA = 1f 452 const val PROGRESS_BAR_ANIMATION_DURATION_MS = 1500L 453 454 private fun Boolean.toInt(): Int { 455 return if (this) 1 else 0 456 } 457 } 458 } 459