1 /*
2  * 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.content.Intent
20 import android.content.SharedPreferences
21 import android.os.Bundle
22 import android.view.View
23 import android.view.View.GONE
24 import android.view.View.VISIBLE
25 import android.view.ViewGroup
26 import androidx.annotation.DimenRes
27 import androidx.annotation.StringRes
28 import androidx.annotation.VisibleForTesting
29 import com.android.internal.jank.InteractionJankMonitor
30 import com.android.internal.logging.UiEventLogger
31 import com.android.settingslib.bluetooth.BluetoothUtils
32 import com.android.systemui.Prefs
33 import com.android.systemui.animation.DialogCuj
34 import com.android.systemui.animation.DialogTransitionAnimator
35 import com.android.systemui.animation.Expandable
36 import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_AUDIO_SHARING
37 import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_BLUETOOTH_DEVICE_DETAILS
38 import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_PAIR_NEW_DEVICE
39 import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_PREVIOUSLY_CONNECTED_DEVICE
40 import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.MAX_DEVICE_ITEM_ENTRY
41 import com.android.systemui.dagger.SysUISingleton
42 import com.android.systemui.dagger.qualifiers.Application
43 import com.android.systemui.dagger.qualifiers.Background
44 import com.android.systemui.dagger.qualifiers.Main
45 import com.android.systemui.plugins.ActivityStarter
46 import com.android.systemui.res.R
47 import javax.inject.Inject
48 import kotlinx.coroutines.CoroutineDispatcher
49 import kotlinx.coroutines.CoroutineScope
50 import kotlinx.coroutines.Job
51 import kotlinx.coroutines.channels.awaitClose
52 import kotlinx.coroutines.channels.produce
53 import kotlinx.coroutines.flow.filterNotNull
54 import kotlinx.coroutines.flow.launchIn
55 import kotlinx.coroutines.flow.onEach
56 import kotlinx.coroutines.launch
57 import kotlinx.coroutines.withContext
58 
59 /** ViewModel for Bluetooth Dialog after clicking on the Bluetooth QS tile. */
60 @SysUISingleton
61 internal class BluetoothTileDialogViewModel
62 @Inject
63 constructor(
64     private val deviceItemInteractor: DeviceItemInteractor,
65     private val deviceItemActionInteractor: DeviceItemActionInteractor,
66     private val bluetoothStateInteractor: BluetoothStateInteractor,
67     private val bluetoothAutoOnInteractor: BluetoothAutoOnInteractor,
68     private val audioSharingInteractor: AudioSharingInteractor,
69     private val dialogTransitionAnimator: DialogTransitionAnimator,
70     private val activityStarter: ActivityStarter,
71     private val uiEventLogger: UiEventLogger,
72     @Application private val coroutineScope: CoroutineScope,
73     @Main private val mainDispatcher: CoroutineDispatcher,
74     @Background private val backgroundDispatcher: CoroutineDispatcher,
75     @Main private val sharedPreferences: SharedPreferences,
76     private val bluetoothDialogDelegateFactory: BluetoothTileDialogDelegate.Factory,
77 ) : BluetoothTileDialogCallback {
78 
79     private var job: Job? = null
80 
81     /**
82      * Shows the dialog.
83      *
84      * @param view The view from which the dialog is shown.
85      */
86     @kotlinx.coroutines.ExperimentalCoroutinesApi
showDialognull87     fun showDialog(expandable: Expandable?) {
88         cancelJob()
89 
90         job =
91             coroutineScope.launch(mainDispatcher) {
92                 var updateDeviceItemJob: Job?
93                 var updateDialogUiJob: Job? = null
94                 val dialogDelegate = createBluetoothTileDialog()
95                 val dialog = dialogDelegate.createDialog()
96                 val context = dialog.context
97 
98                 val controller =
99                     expandable?.dialogTransitionController(
100                         DialogCuj(
101                             InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
102                             INTERACTION_JANK_TAG
103                         )
104                     )
105                 controller?.let {
106                     dialogTransitionAnimator.show(dialog, it, animateBackgroundBoundsChange = true)
107                 }
108                     ?: dialog.show()
109 
110                 updateDeviceItemJob = launch {
111                     deviceItemInteractor.updateDeviceItems(context, DeviceFetchTrigger.FIRST_LOAD)
112                 }
113 
114                 // deviceItemUpdate is emitted when device item list is done fetching, update UI and
115                 // stop the progress bar.
116                 deviceItemInteractor.deviceItemUpdate
117                     .onEach {
118                         updateDialogUiJob?.cancel()
119                         updateDialogUiJob = launch {
120                             dialogDelegate.apply {
121                                 onDeviceItemUpdated(
122                                     dialog,
123                                     it.take(MAX_DEVICE_ITEM_ENTRY),
124                                     showSeeAll = it.size > MAX_DEVICE_ITEM_ENTRY,
125                                     showPairNewDevice =
126                                         bluetoothStateInteractor.isBluetoothEnabled()
127                                 )
128                                 animateProgressBar(dialog, false)
129                             }
130                         }
131                     }
132                     .launchIn(this)
133 
134                 // deviceItemUpdateRequest is emitted when a bluetooth callback is called, re-fetch
135                 // the device item list and animiate the progress bar.
136                 deviceItemInteractor.deviceItemUpdateRequest
137                     .onEach {
138                         dialogDelegate.animateProgressBar(dialog, true)
139                         updateDeviceItemJob?.cancel()
140                         updateDeviceItemJob = launch {
141                             deviceItemInteractor.updateDeviceItems(
142                                 context,
143                                 DeviceFetchTrigger.BLUETOOTH_CALLBACK_RECEIVED
144                             )
145                         }
146                     }
147                     .launchIn(this)
148 
149                 if (BluetoothUtils.isAudioSharingEnabled()) {
150                     audioSharingInteractor.audioSharingButtonStateUpdate
151                         .onEach {
152                             if (it is AudioSharingButtonState.Visible) {
153                                 dialogDelegate.onAudioSharingButtonUpdated(
154                                     dialog,
155                                     VISIBLE,
156                                     context.getString(it.resId)
157                                 )
158                             } else {
159                                 dialogDelegate.onAudioSharingButtonUpdated(dialog, GONE, null)
160                             }
161                         }
162                         .launchIn(this)
163                 }
164 
165                 // bluetoothStateUpdate is emitted when bluetooth on/off state is changed, re-fetch
166                 // the device item list.
167                 bluetoothStateInteractor.bluetoothStateUpdate
168                     .onEach {
169                         dialogDelegate.onBluetoothStateUpdated(
170                             dialog,
171                             it,
172                             UiProperties.build(it, isAutoOnToggleFeatureAvailable())
173                         )
174                         updateDeviceItemJob?.cancel()
175                         updateDeviceItemJob = launch {
176                             deviceItemInteractor.updateDeviceItems(
177                                 context,
178                                 DeviceFetchTrigger.BLUETOOTH_STATE_CHANGE_RECEIVED
179                             )
180                         }
181                     }
182                     .launchIn(this)
183 
184                 // bluetoothStateToggle is emitted when user toggles the bluetooth state switch,
185                 // send the new value to the bluetoothStateInteractor and animate the progress bar.
186                 dialogDelegate.bluetoothStateToggle
187                     .filterNotNull()
188                     .onEach {
189                         dialogDelegate.animateProgressBar(dialog, true)
190                         bluetoothStateInteractor.setBluetoothEnabled(it)
191                     }
192                     .launchIn(this)
193 
194                 // deviceItemClick is emitted when user clicked on a device item.
195                 dialogDelegate.deviceItemClick
196                     .onEach { deviceItemActionInteractor.onClick(it, dialog) }
197                     .launchIn(this)
198 
199                 // contentHeight is emitted when the dialog is dismissed.
200                 dialogDelegate.contentHeight
201                     .onEach {
202                         withContext(backgroundDispatcher) {
203                             sharedPreferences.edit().putInt(CONTENT_HEIGHT_PREF_KEY, it).apply()
204                         }
205                     }
206                     .launchIn(this)
207 
208                 if (isAutoOnToggleFeatureAvailable()) {
209                     // bluetoothAutoOnUpdate is emitted when bluetooth auto on on/off state is
210                     // changed.
211                     bluetoothAutoOnInteractor.isEnabled
212                         .onEach {
213                             dialogDelegate.onBluetoothAutoOnUpdated(
214                                 dialog,
215                                 it,
216                                 if (it) R.string.turn_on_bluetooth_auto_info_enabled
217                                 else R.string.turn_on_bluetooth_auto_info_disabled
218                             )
219                         }
220                         .launchIn(this)
221 
222                     // bluetoothAutoOnToggle is emitted when user toggles the bluetooth auto on
223                     // switch, send the new value to the bluetoothAutoOnInteractor.
224                     dialogDelegate.bluetoothAutoOnToggle
225                         .filterNotNull()
226                         .onEach { bluetoothAutoOnInteractor.setEnabled(it) }
227                         .launchIn(this)
228                 }
229 
230                 produce<Unit> { awaitClose { dialog.cancel() } }
231             }
232     }
233 
createBluetoothTileDialognull234     private suspend fun createBluetoothTileDialog(): BluetoothTileDialogDelegate {
235         val cachedContentHeight =
236             withContext(backgroundDispatcher) {
237                 sharedPreferences.getInt(
238                     CONTENT_HEIGHT_PREF_KEY,
239                     ViewGroup.LayoutParams.WRAP_CONTENT
240                 )
241             }
242 
243         return bluetoothDialogDelegateFactory.create(
244             UiProperties.build(
245                 bluetoothStateInteractor.isBluetoothEnabled(),
246                 isAutoOnToggleFeatureAvailable()
247             ),
248             cachedContentHeight,
249             this@BluetoothTileDialogViewModel,
250             { cancelJob() }
251         )
252     }
253 
onDeviceItemGearClickednull254     override fun onDeviceItemGearClicked(deviceItem: DeviceItem, view: View) {
255         uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_GEAR_CLICKED)
256         val intent =
257             Intent(ACTION_BLUETOOTH_DEVICE_DETAILS).apply {
258                 putExtra(
259                     ":settings:show_fragment_args",
260                     Bundle().apply {
261                         putString("device_address", deviceItem.cachedBluetoothDevice.address)
262                     }
263                 )
264             }
265         startSettingsActivity(intent, view)
266     }
267 
onSeeAllClickednull268     override fun onSeeAllClicked(view: View) {
269         uiEventLogger.log(BluetoothTileDialogUiEvent.SEE_ALL_CLICKED)
270         startSettingsActivity(Intent(ACTION_PREVIOUSLY_CONNECTED_DEVICE), view)
271     }
272 
onPairNewDeviceClickednull273     override fun onPairNewDeviceClicked(view: View) {
274         uiEventLogger.log(BluetoothTileDialogUiEvent.PAIR_NEW_DEVICE_CLICKED)
275         startSettingsActivity(Intent(ACTION_PAIR_NEW_DEVICE), view)
276     }
277 
onAudioSharingButtonClickednull278     override fun onAudioSharingButtonClicked(view: View) {
279         uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUDIO_SHARING_BUTTON_CLICKED)
280         startSettingsActivity(Intent(ACTION_AUDIO_SHARING), view)
281     }
282 
cancelJobnull283     private fun cancelJob() {
284         job?.cancel()
285         job = null
286     }
287 
startSettingsActivitynull288     private fun startSettingsActivity(intent: Intent, view: View) {
289         if (job?.isActive == true) {
290             intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
291             activityStarter.postStartActivityDismissingKeyguard(
292                 intent,
293                 0,
294                 dialogTransitionAnimator.createActivityTransitionController(view)
295             )
296         }
297     }
298 
299     @VisibleForTesting
isAutoOnToggleFeatureAvailablenull300     internal suspend fun isAutoOnToggleFeatureAvailable() =
301         bluetoothAutoOnInteractor.isAutoOnSupported()
302 
303     companion object {
304         private const val INTERACTION_JANK_TAG = "bluetooth_tile_dialog"
305         private const val CONTENT_HEIGHT_PREF_KEY = Prefs.Key.BLUETOOTH_TILE_DIALOG_CONTENT_HEIGHT
306         private fun getSubtitleResId(isBluetoothEnabled: Boolean) =
307             if (isBluetoothEnabled) R.string.quick_settings_bluetooth_tile_subtitle
308             else R.string.bt_is_off
309     }
310 
311     internal data class UiProperties(
312         @StringRes val subTitleResId: Int,
313         val autoOnToggleVisibility: Int,
314         @DimenRes val scrollViewMinHeightResId: Int,
315     ) {
316         companion object {
buildnull317             internal fun build(
318                 isBluetoothEnabled: Boolean,
319                 isAutoOnToggleFeatureAvailable: Boolean
320             ) =
321                 UiProperties(
322                     subTitleResId = getSubtitleResId(isBluetoothEnabled),
323                     autoOnToggleVisibility =
324                         if (isAutoOnToggleFeatureAvailable && !isBluetoothEnabled) VISIBLE
325                         else GONE,
326                     scrollViewMinHeightResId =
327                         if (isAutoOnToggleFeatureAvailable)
328                             R.dimen.bluetooth_dialog_scroll_view_min_height_with_auto_on
329                         else R.dimen.bluetooth_dialog_scroll_view_min_height
330                 )
331         }
332     }
333 }
334 
335 interface BluetoothTileDialogCallback {
336     fun onDeviceItemGearClicked(deviceItem: DeviceItem, view: View)
337     fun onSeeAllClicked(view: View)
338     fun onPairNewDeviceClicked(view: View)
339     fun onAudioSharingButtonClicked(view: View)
340 }
341