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