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