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.bluetooth.BluetoothDevice
20 import android.bluetooth.BluetoothProfile
21 import android.content.Intent
22 import android.os.Bundle
23 import android.provider.Settings
24 import com.android.internal.logging.UiEventLogger
25 import com.android.settingslib.bluetooth.A2dpProfile
26 import com.android.settingslib.bluetooth.BluetoothUtils
27 import com.android.settingslib.bluetooth.HeadsetProfile
28 import com.android.settingslib.bluetooth.HearingAidProfile
29 import com.android.settingslib.bluetooth.LeAudioProfile
30 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast
31 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant
32 import com.android.settingslib.bluetooth.LocalBluetoothManager
33 import com.android.systemui.animation.DialogTransitionAnimator
34 import com.android.systemui.bluetooth.qsdialog.DeviceItemActionInteractor.LaunchSettingsCriteria.Companion.getCurrentConnectedLeByGroupId
35 import com.android.systemui.dagger.SysUISingleton
36 import com.android.systemui.dagger.qualifiers.Background
37 import com.android.systemui.plugins.ActivityStarter
38 import com.android.systemui.statusbar.phone.SystemUIDialog
39 import javax.inject.Inject
40 import kotlinx.coroutines.CoroutineDispatcher
41 import kotlinx.coroutines.withContext
42 
43 @SysUISingleton
44 class DeviceItemActionInteractor
45 @Inject
46 constructor(
47     private val activityStarter: ActivityStarter,
48     private val dialogTransitionAnimator: DialogTransitionAnimator,
49     private val localBluetoothManager: LocalBluetoothManager?,
50     @Background private val backgroundDispatcher: CoroutineDispatcher,
51     private val logger: BluetoothTileDialogLogger,
52     private val uiEventLogger: UiEventLogger,
53 ) {
54     private val leAudioProfile: LeAudioProfile?
55         get() = localBluetoothManager?.profileManager?.leAudioProfile
56 
57     private val assistantProfile: LocalBluetoothLeBroadcastAssistant?
58         get() = localBluetoothManager?.profileManager?.leAudioBroadcastAssistantProfile
59 
60     private val launchSettingsCriteriaList: List<LaunchSettingsCriteria>
61         get() =
62             listOf(
63                 InSharingClickedNoSource(localBluetoothManager, backgroundDispatcher, logger),
64                 NotSharingClickedNonConnect(
65                     leAudioProfile,
66                     assistantProfile,
67                     backgroundDispatcher,
68                     logger
69                 ),
70                 NotSharingClickedConnected(
71                     leAudioProfile,
72                     assistantProfile,
73                     backgroundDispatcher,
74                     logger
75                 )
76             )
77 
78     suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog) {
79         withContext(backgroundDispatcher) {
80             logger.logDeviceClick(deviceItem.cachedBluetoothDevice.address, deviceItem.type)
81             if (
82                 BluetoothUtils.isAudioSharingEnabled() &&
83                     localBluetoothManager != null &&
84                     leAudioProfile != null &&
85                     assistantProfile != null
86             ) {
87                 val inAudioSharing = BluetoothUtils.isBroadcasting(localBluetoothManager)
88                 logger.logDeviceClickInAudioSharingWhenEnabled(inAudioSharing)
89 
90                 val criteriaMatched =
91                     launchSettingsCriteriaList.firstOrNull {
92                         it.matched(inAudioSharing, deviceItem)
93                     }
94                 if (criteriaMatched != null) {
95                     uiEventLogger.log(criteriaMatched.getClickUiEvent(deviceItem))
96                     launchSettings(deviceItem.cachedBluetoothDevice.device, dialog)
97                     return@withContext
98                 }
99             }
100             deviceItem.cachedBluetoothDevice.apply {
101                 when (deviceItem.type) {
102                     DeviceItemType.ACTIVE_MEDIA_BLUETOOTH_DEVICE -> {
103                         disconnect()
104                         uiEventLogger.log(BluetoothTileDialogUiEvent.ACTIVE_DEVICE_DISCONNECT)
105                     }
106                     DeviceItemType.AUDIO_SHARING_MEDIA_BLUETOOTH_DEVICE -> {
107                         uiEventLogger.log(BluetoothTileDialogUiEvent.AUDIO_SHARING_DEVICE_CLICKED)
108                     }
109                     DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE -> {
110                         setActive()
111                         uiEventLogger.log(BluetoothTileDialogUiEvent.CONNECTED_DEVICE_SET_ACTIVE)
112                     }
113                     DeviceItemType.CONNECTED_BLUETOOTH_DEVICE -> {
114                         disconnect()
115                         uiEventLogger.log(
116                             BluetoothTileDialogUiEvent.CONNECTED_OTHER_DEVICE_DISCONNECT
117                         )
118                     }
119                     DeviceItemType.SAVED_BLUETOOTH_DEVICE -> {
120                         connect()
121                         uiEventLogger.log(BluetoothTileDialogUiEvent.SAVED_DEVICE_CONNECT)
122                     }
123                 }
124             }
125         }
126     }
127 
128     private fun launchSettings(device: BluetoothDevice, dialog: SystemUIDialog) {
129         val intent =
130             Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply {
131                 putExtra(
132                     EXTRA_SHOW_FRAGMENT_ARGUMENTS,
133                     Bundle().apply {
134                         putParcelable(LocalBluetoothLeBroadcast.EXTRA_BLUETOOTH_DEVICE, device)
135                     }
136                 )
137             }
138         intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK
139         activityStarter.postStartActivityDismissingKeyguard(
140             intent,
141             0,
142             dialogTransitionAnimator.createActivityTransitionController(dialog)
143         )
144     }
145 
146     private interface LaunchSettingsCriteria {
147         suspend fun matched(inAudioSharing: Boolean, deviceItem: DeviceItem): Boolean
148 
149         suspend fun getClickUiEvent(deviceItem: DeviceItem): BluetoothTileDialogUiEvent
150 
151         companion object {
152             suspend fun getCurrentConnectedLeByGroupId(
153                 leAudioProfile: LeAudioProfile,
154                 assistantProfile: LocalBluetoothLeBroadcastAssistant,
155                 @Background backgroundDispatcher: CoroutineDispatcher,
156                 logger: BluetoothTileDialogLogger,
157             ): Map<Int, List<BluetoothDevice>> {
158                 return withContext(backgroundDispatcher) {
159                     assistantProfile
160                         .getDevicesMatchingConnectionStates(
161                             intArrayOf(BluetoothProfile.STATE_CONNECTED)
162                         )
163                         ?.filterNotNull()
164                         ?.groupBy { leAudioProfile.getGroupId(it) }
165                         ?.also { logger.logConnectedLeByGroupId(it) } ?: emptyMap()
166                 }
167             }
168         }
169     }
170 
171     private class InSharingClickedNoSource(
172         private val localBluetoothManager: LocalBluetoothManager?,
173         @Background private val backgroundDispatcher: CoroutineDispatcher,
174         private val logger: BluetoothTileDialogLogger,
175     ) : LaunchSettingsCriteria {
176         // If currently broadcasting and the clicked device is not connected to the source
177         override suspend fun matched(inAudioSharing: Boolean, deviceItem: DeviceItem): Boolean {
178             return withContext(backgroundDispatcher) {
179                 val matched =
180                     inAudioSharing &&
181                         deviceItem.isMediaDevice &&
182                         !BluetoothUtils.hasConnectedBroadcastSource(
183                             deviceItem.cachedBluetoothDevice,
184                             localBluetoothManager
185                         )
186 
187                 if (matched) {
188                     logger.logLaunchSettingsCriteriaMatched("InSharingClickedNoSource", deviceItem)
189                 }
190 
191                 matched
192             }
193         }
194 
195         override suspend fun getClickUiEvent(deviceItem: DeviceItem) =
196             if (deviceItem.isLeAudioSupported)
197                 BluetoothTileDialogUiEvent.LAUNCH_SETTINGS_IN_SHARING_LE_DEVICE_CLICKED
198             else BluetoothTileDialogUiEvent.LAUNCH_SETTINGS_IN_SHARING_NON_LE_DEVICE_CLICKED
199     }
200 
201     private class NotSharingClickedNonConnect(
202         private val leAudioProfile: LeAudioProfile?,
203         private val assistantProfile: LocalBluetoothLeBroadcastAssistant?,
204         @Background private val backgroundDispatcher: CoroutineDispatcher,
205         private val logger: BluetoothTileDialogLogger,
206     ) : LaunchSettingsCriteria {
207         // If not broadcasting, having one device connected, and clicked on a not yet connected LE
208         // audio device
209         override suspend fun matched(inAudioSharing: Boolean, deviceItem: DeviceItem): Boolean {
210             return withContext(backgroundDispatcher) {
211                 val matched =
212                     leAudioProfile?.let { leAudio ->
213                         assistantProfile?.let { assistant ->
214                             !inAudioSharing &&
215                                 getCurrentConnectedLeByGroupId(
216                                         leAudio,
217                                         assistant,
218                                         backgroundDispatcher,
219                                         logger
220                                     )
221                                     .size == 1 &&
222                                 deviceItem.isNotConnectedLeAudioSupported
223                         }
224                     } ?: false
225 
226                 if (matched) {
227                     logger.logLaunchSettingsCriteriaMatched(
228                         "NotSharingClickedNonConnect",
229                         deviceItem
230                     )
231                 }
232 
233                 matched
234             }
235         }
236 
237         override suspend fun getClickUiEvent(deviceItem: DeviceItem) =
238             BluetoothTileDialogUiEvent.LAUNCH_SETTINGS_NOT_SHARING_SAVED_LE_DEVICE_CLICKED
239     }
240 
241     private class NotSharingClickedConnected(
242         private val leAudioProfile: LeAudioProfile?,
243         private val assistantProfile: LocalBluetoothLeBroadcastAssistant?,
244         @Background private val backgroundDispatcher: CoroutineDispatcher,
245         private val logger: BluetoothTileDialogLogger,
246     ) : LaunchSettingsCriteria {
247         // If not broadcasting, having two device connected, clicked on any connected LE audio
248         // devices
249         override suspend fun matched(inAudioSharing: Boolean, deviceItem: DeviceItem): Boolean {
250             return withContext(backgroundDispatcher) {
251                 val matched =
252                     leAudioProfile?.let { leAudio ->
253                         assistantProfile?.let { assistant ->
254                             !inAudioSharing &&
255                                 getCurrentConnectedLeByGroupId(
256                                         leAudio,
257                                         assistant,
258                                         backgroundDispatcher,
259                                         logger
260                                     )
261                                     .size == 2 &&
262                                 deviceItem.isActiveOrConnectedLeAudioSupported
263                         }
264                     } ?: false
265 
266                 if (matched) {
267                     logger.logLaunchSettingsCriteriaMatched(
268                         "NotSharingClickedConnected",
269                         deviceItem
270                     )
271                 }
272 
273                 matched
274             }
275         }
276 
277         override suspend fun getClickUiEvent(deviceItem: DeviceItem) =
278             BluetoothTileDialogUiEvent.LAUNCH_SETTINGS_NOT_SHARING_CONNECTED_LE_DEVICE_CLICKED
279     }
280 
281     private companion object {
282         const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
283 
284         val DeviceItem.isLeAudioSupported: Boolean
285             get() =
286                 cachedBluetoothDevice.profiles.any { profile ->
287                     profile is LeAudioProfile && profile.isEnabled(cachedBluetoothDevice.device)
288                 }
289 
290         val DeviceItem.isNotConnectedLeAudioSupported: Boolean
291             get() = type == DeviceItemType.SAVED_BLUETOOTH_DEVICE && isLeAudioSupported
292 
293         val DeviceItem.isActiveOrConnectedLeAudioSupported: Boolean
294             get() =
295                 (type == DeviceItemType.ACTIVE_MEDIA_BLUETOOTH_DEVICE ||
296                     type == DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE) && isLeAudioSupported
297 
298         val DeviceItem.isMediaDevice: Boolean
299             get() =
300                 cachedBluetoothDevice.connectableProfiles.any {
301                     it is A2dpProfile ||
302                         it is HearingAidProfile ||
303                         it is LeAudioProfile ||
304                         it is HeadsetProfile
305                 }
306     }
307 }
308