1 /*
<lambda>null2  * Copyright 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 androidx.core.telecom.test.services
18 
19 import android.bluetooth.BluetoothDevice
20 import android.os.Build
21 import android.os.OutcomeReceiver
22 import android.telecom.CallAudioState
23 import android.telecom.CallEndpoint
24 import android.telecom.CallEndpointException
25 import androidx.annotation.RequiresApi
26 import androidx.core.telecom.test.Compatibility
27 import java.util.UUID
28 import java.util.concurrent.Executor
29 import kotlin.coroutines.resume
30 import kotlinx.coroutines.CoroutineScope
31 import kotlinx.coroutines.async
32 import kotlinx.coroutines.flow.MutableStateFlow
33 import kotlinx.coroutines.flow.StateFlow
34 import kotlinx.coroutines.flow.asStateFlow
35 import kotlinx.coroutines.flow.combine
36 import kotlinx.coroutines.flow.filterNotNull
37 import kotlinx.coroutines.flow.launchIn
38 import kotlinx.coroutines.suspendCancellableCoroutine
39 
40 /**
41  * Tracks the current state of the available and current audio route on the device while in call,
42  * taking into account the device's android API version.
43  *
44  * @param coroutineScope The scope attached to the lifecycle of the Service
45  * @param callData The stream of calls that are active on this device
46  * @param onChangeAudioRoute The callback used when user has requested to change the audio route on
47  *   the device for devices running an API version < UDC
48  * @param onRequestBluetoothAudio The callback used when the user has requested to change the audio
49  *   route for devices running on API version < UDC
50  * @param onRequestEndpointChange The callback used when the user has requested to change the
51  *   endpoint for devices running API version UDC+
52  */
53 class CallAudioRouteResolver(
54     private val coroutineScope: CoroutineScope,
55     callData: StateFlow<List<CallData>>,
56     private val onChangeAudioRoute: (Int) -> Unit,
57     private val onRequestBluetoothAudio: (BluetoothDevice) -> Unit,
58     private val onRequestEndpointChange:
59         (CallEndpoint, Executor, OutcomeReceiver<Void, CallEndpointException>) -> Unit
60 ) {
61     private val mIsCallAudioStateDeprecated =
62         Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
63 
64     // Maps the CallAudioEndpoint to the associated BluetoothDevice (if applicable) for bkwds
65     // compatibility with devices running on API version < UDC
66     data class EndpointEntry(val endpoint: CallAudioEndpoint, val device: BluetoothDevice? = null)
67 
68     private val mCurrentEndpoint: MutableStateFlow<CallAudioEndpoint?> = MutableStateFlow(null)
69     private val mAvailableEndpoints: MutableStateFlow<List<CallAudioEndpoint>> =
70         MutableStateFlow(emptyList())
71     val currentEndpoint: StateFlow<CallAudioEndpoint?> = mCurrentEndpoint.asStateFlow()
72     val availableEndpoints = mAvailableEndpoints.asStateFlow()
73 
74     private val mCallAudioState: MutableStateFlow<CallAudioState?> = MutableStateFlow(null)
75     private val mEndpoints: MutableStateFlow<List<EndpointEntry>> = MutableStateFlow(emptyList())
76     private val mCurrentCallEndpoint: MutableStateFlow<CallEndpoint?> = MutableStateFlow(null)
77     private val mAvailableCallEndpoints: MutableStateFlow<List<CallEndpoint>> =
78         MutableStateFlow(emptyList())
79 
80     init {
81         if (!mIsCallAudioStateDeprecated) {
82             // bkwds compat functionality
83             mCallAudioState
84                 .filterNotNull()
85                 .combine(callData) { state, data ->
86                     if (data.isNotEmpty()) {
87                         mCurrentEndpoint.value = getCurrentEndpoint(state)
88                         mEndpoints.value = createEndpointEntries(state)
89                         mAvailableEndpoints.value = mEndpoints.value.map { it.endpoint }
90                     } else {
91                         mCurrentEndpoint.value = null
92                         mEndpoints.value = emptyList()
93                         mAvailableEndpoints.value = emptyList()
94                     }
95                 }
96                 .launchIn(coroutineScope)
97         } else {
98             // UDC+ functionality
99             mAvailableCallEndpoints
100                 .combine(callData) { endpoints, data ->
101                     val availableEndpoints =
102                         if (data.isNotEmpty()) {
103                             endpoints.mapNotNull(::createCallAudioEndpoint)
104                         } else {
105                             emptyList()
106                         }
107                     mAvailableEndpoints.value = availableEndpoints
108                     availableEndpoints
109                 }
110                 .combine(mCurrentCallEndpoint) { available, current ->
111                     if (available.isEmpty()) {
112                         mCurrentEndpoint.value = null
113                     }
114                     val audioEndpoint = current?.let { createCallAudioEndpoint(it) }
115                     mCurrentEndpoint.value = available.firstOrNull { it.id == audioEndpoint?.id }
116                 }
117                 .launchIn(coroutineScope)
118         }
119     }
120 
121     /** The audio state reported from the ICS has changed. */
122     fun onCallAudioStateChanged(audioState: CallAudioState?) {
123         if (mIsCallAudioStateDeprecated) return
124         mCallAudioState.value = audioState
125     }
126 
127     /** The call endpoint reported from the ICS has changed. */
128     fun onCallEndpointChanged(callEndpoint: CallEndpoint) {
129         if (!mIsCallAudioStateDeprecated) return
130         mCurrentCallEndpoint.value = callEndpoint
131     }
132 
133     /** The available endpoints reported from the ICS have changed. */
134     fun onAvailableCallEndpointsChanged(availableEndpoints: MutableList<CallEndpoint>) {
135         if (!mIsCallAudioStateDeprecated) return
136         mAvailableCallEndpoints.value = availableEndpoints
137     }
138 
139     /**
140      * Request to change the audio route using the provided [CallAudioEndpoint.id].
141      *
142      * @return true if the operation succeeded, false if it did not because the endpoint doesn't
143      *   exist.
144      */
145     suspend fun onChangeAudioRoute(id: String): Boolean {
146         if (mIsCallAudioStateDeprecated) {
147             val endpoint =
148                 mAvailableCallEndpoints.value.firstOrNull { it.identifier.toString() == id }
149             if (endpoint == null) return false
150             return coroutineScope.async { onRequestEndpointChange(endpoint) }.await()
151         } else {
152             val endpoint = mEndpoints.value.firstOrNull { it.endpoint.id == id }
153             if (endpoint == null) return false
154             if (endpoint.endpoint.audioRoute != AudioRoute.BLUETOOTH) {
155                 onChangeAudioRoute(getAudioState(endpoint.endpoint.audioRoute))
156                 return true
157             } else {
158                 if (endpoint.device == null) return false
159                 onRequestBluetoothAudio(endpoint.device)
160                 return true
161             }
162         }
163     }
164 
165     /** Send a request to the InCallService to change the current endpoint. */
166     private suspend fun onRequestEndpointChange(endpoint: CallEndpoint): Boolean =
167         suspendCancellableCoroutine { continuation ->
168             onRequestEndpointChange(
169                 endpoint,
170                 Runnable::run,
171                 @RequiresApi(Build.VERSION_CODES.S)
172                 object : OutcomeReceiver<Void, CallEndpointException> {
173                     override fun onResult(result: Void?) {
174                         continuation.resume(true)
175                     }
176 
177                     override fun onError(error: CallEndpointException) {
178                         continuation.resume(false)
179                     }
180                 }
181             )
182         }
183 
184     /** Maps from the Telecom [CallAudioState] to the app's [CallAudioEndpoint] */
185     private fun getCurrentEndpoint(callAudioState: CallAudioState): CallAudioEndpoint {
186         if (CallAudioState.ROUTE_BLUETOOTH != callAudioState.route) {
187             return CallAudioEndpoint(
188                 id = getAudioEndpointId(callAudioState.route),
189                 audioRoute = getAudioEndpointRoute(callAudioState.route)
190             )
191         }
192         val device: BluetoothDevice? = callAudioState.activeBluetoothDevice
193         if (device?.address != null) {
194             return CallAudioEndpoint(
195                 id = device.address,
196                 audioRoute = AudioRoute.BLUETOOTH,
197                 frameworkName = getName(device)
198             )
199         }
200         val exactMatch = mEndpoints.value.firstOrNull { it.device == device }
201         if (exactMatch != null) return exactMatch.endpoint
202         return CallAudioEndpoint(
203             id = "",
204             audioRoute = AudioRoute.BLUETOOTH,
205             frameworkName = device?.let { getName(it) }
206         )
207     }
208 
209     /** Create the [CallAudioEndpoint] from the telecom [CallEndpoint] for API UDC+ */
210     private fun createCallAudioEndpoint(endpoint: CallEndpoint): CallAudioEndpoint? {
211         val id = Compatibility.getEndpointIdentifier(endpoint) ?: return null
212         val type = Compatibility.getEndpointType(endpoint) ?: return null
213         val name = Compatibility.getEndpointName(endpoint) ?: return null
214         return CallAudioEndpoint(id, getAudioRouteFromEndpointType(type), name)
215     }
216 
217     /** Reconstruct the available audio routes from telecom state and construct [EndpointEntry]s */
218     private fun createEndpointEntries(callAudioState: CallAudioState): List<EndpointEntry> {
219         return buildList {
220             if (CallAudioState.ROUTE_EARPIECE and callAudioState.supportedRouteMask > 0) {
221                 add(
222                     EndpointEntry(
223                         CallAudioEndpoint(
224                             id = getAudioEndpointId(CallAudioState.ROUTE_EARPIECE),
225                             audioRoute = AudioRoute.EARPIECE
226                         )
227                     )
228                 )
229             }
230             if (CallAudioState.ROUTE_SPEAKER and callAudioState.supportedRouteMask > 0) {
231                 add(
232                     EndpointEntry(
233                         CallAudioEndpoint(
234                             id = getAudioEndpointId(CallAudioState.ROUTE_SPEAKER),
235                             audioRoute = AudioRoute.SPEAKER
236                         )
237                     )
238                 )
239             }
240             if (CallAudioState.ROUTE_WIRED_HEADSET and callAudioState.supportedRouteMask > 0) {
241                 add(
242                     EndpointEntry(
243                         CallAudioEndpoint(
244                             id = getAudioEndpointId(CallAudioState.ROUTE_WIRED_HEADSET),
245                             audioRoute = AudioRoute.HEADSET
246                         )
247                     )
248                 )
249             }
250             if (CallAudioState.ROUTE_STREAMING and callAudioState.supportedRouteMask > 0) {
251                 add(
252                     EndpointEntry(
253                         CallAudioEndpoint(
254                             id = getAudioEndpointId(CallAudioState.ROUTE_STREAMING),
255                             audioRoute = AudioRoute.STREAMING
256                         )
257                     )
258                 )
259             }
260             // For Bluetooth, cache the BluetoothDevices associated with the route so we can choose
261             // them later
262             if (CallAudioState.ROUTE_BLUETOOTH and callAudioState.supportedRouteMask > 0) {
263                 addAll(
264                     callAudioState.supportedBluetoothDevices.map { device ->
265                         EndpointEntry(
266                             CallAudioEndpoint(
267                                 id = device.address?.toString() ?: UUID.randomUUID().toString(),
268                                 audioRoute = AudioRoute.BLUETOOTH,
269                                 frameworkName = getName(device)
270                             ),
271                             device
272                         )
273                     }
274                 )
275             }
276         }
277     }
278 
279     private fun getName(device: BluetoothDevice): String? {
280         var name = Compatibility.getBluetoothDeviceAlias(device)
281         if (name.isFailure) {
282             name = getBluetoothDeviceName(device)
283         }
284         return name.getOrDefault(null)
285     }
286 
287     private fun getBluetoothDeviceName(device: BluetoothDevice): Result<String> {
288         return try {
289             Result.success(device.name ?: "")
290         } catch (e: SecurityException) {
291             Result.failure(e)
292         }
293     }
294 
295     private fun getAudioEndpointId(audioState: Int): String {
296         return when (audioState) {
297             CallAudioState.ROUTE_EARPIECE -> "Earpiece"
298             CallAudioState.ROUTE_SPEAKER -> "Speaker"
299             CallAudioState.ROUTE_WIRED_HEADSET -> "Headset"
300             CallAudioState.ROUTE_BLUETOOTH -> "Bluetooth"
301             CallAudioState.ROUTE_STREAMING -> "Streaming"
302             else -> "Unknown"
303         }
304     }
305 
306     private fun getAudioRouteFromEndpointType(endpointType: Int): AudioRoute {
307         return when (endpointType) {
308             CallEndpoint.TYPE_EARPIECE -> AudioRoute.EARPIECE
309             CallEndpoint.TYPE_SPEAKER -> AudioRoute.SPEAKER
310             CallEndpoint.TYPE_WIRED_HEADSET -> AudioRoute.HEADSET
311             CallEndpoint.TYPE_BLUETOOTH -> AudioRoute.BLUETOOTH
312             CallEndpoint.TYPE_STREAMING -> AudioRoute.STREAMING
313             else -> {
314                 AudioRoute.UNKNOWN
315             }
316         }
317     }
318 
319     private fun getAudioEndpointRoute(audioState: Int): AudioRoute {
320         return when (audioState) {
321             CallAudioState.ROUTE_EARPIECE -> AudioRoute.EARPIECE
322             CallAudioState.ROUTE_SPEAKER -> AudioRoute.SPEAKER
323             CallAudioState.ROUTE_WIRED_HEADSET -> AudioRoute.HEADSET
324             CallAudioState.ROUTE_BLUETOOTH -> AudioRoute.BLUETOOTH
325             CallAudioState.ROUTE_STREAMING -> AudioRoute.STREAMING
326             else -> AudioRoute.UNKNOWN
327         }
328     }
329 
330     private fun getAudioState(audioRoute: AudioRoute): Int {
331         return when (audioRoute) {
332             AudioRoute.EARPIECE -> CallAudioState.ROUTE_EARPIECE
333             AudioRoute.SPEAKER -> CallAudioState.ROUTE_SPEAKER
334             AudioRoute.HEADSET -> CallAudioState.ROUTE_WIRED_HEADSET
335             AudioRoute.BLUETOOTH -> CallAudioState.ROUTE_BLUETOOTH
336             AudioRoute.STREAMING -> CallAudioState.ROUTE_STREAMING
337             else -> CallAudioState.ROUTE_EARPIECE
338         }
339     }
340 }
341