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