@@ -67,13 +67,17 @@ import io.getstream.video.android.core.utils.safeCall
6767import io.getstream.video.android.core.utils.safeCallWithDefault
6868import io.getstream.video.android.core.utils.targetAudioMaxBitrateBps
6969import kotlinx.coroutines.CoroutineScope
70+ import kotlinx.coroutines.CoroutineStart
7071import kotlinx.coroutines.Job
7172import kotlinx.coroutines.delay
7273import kotlinx.coroutines.flow.MutableStateFlow
7374import kotlinx.coroutines.flow.StateFlow
75+ import kotlinx.coroutines.flow.first
76+ import kotlinx.coroutines.flow.mapNotNull
7477import kotlinx.coroutines.launch
7578import kotlinx.coroutines.suspendCancellableCoroutine
7679import kotlinx.coroutines.withContext
80+ import kotlinx.coroutines.withTimeoutOrNull
7781import org.webrtc.AudioSource
7882import org.webrtc.AudioTrack
7983import org.webrtc.Camera2Capturer
@@ -92,6 +96,7 @@ import java.nio.ByteBuffer
9296import java.util.UUID
9397import java.util.concurrent.atomic.AtomicBoolean
9498import kotlin.coroutines.resumeWithException
99+ import kotlin.time.Duration.Companion.seconds
95100
96101sealed class DeviceStatus {
97102 data object NotSelected : DeviceStatus ()
@@ -556,12 +561,17 @@ class MicrophoneManager(
556561 val audioUsage : Int ,
557562 val audioUsageProvider : (() -> Int ),
558563) {
564+ private companion object {
565+ val TELECOM_ENDPOINT_WAIT_TIMEOUT = 2 .seconds
566+ }
567+
559568 // Internal data
560569 private val logger by taggedLogger(" Media:MicrophoneManager" )
561570
562571 private lateinit var audioHandler: AudioSwitchDecorator
563572
564573 private var setupCompleted = AtomicBoolean (false )
574+ private val telecomEndpointSwitchCoordinator = TelecomEndpointSwitchCoordinator ()
565575 internal var audioManager: AudioManager ? = null
566576 internal var priorStatus: DeviceStatus ? = null
567577
@@ -712,15 +722,21 @@ class MicrophoneManager(
712722 fun select (device : StreamAudioDevice ? ) {
713723 logger.i { " [select] selecting device $device " }
714724 ifAudioHandlerInitialized { it.selectDevice(device?.toAudioDevice()) }
715- _selectedDevice .value = device
716725
717726 // For Telecom-managed calls (e.g. audio calls registered as CALL_TYPE_AUDIO_CALL),
718727 // the Telecom framework controls audio routing and has higher priority than
719728 // AudioManager.setCommunicationDevice(). Route the switch through the Telecom API
720729 // so the OS honours it on all devices including Samsung S21 Ultra (Android 14).
721- if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .O ) {
722- switchTelecomEndpointForDevice(device)
730+ val pendingEndpointWaitJob = if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .O ) {
731+ val requestToken = telecomEndpointSwitchCoordinator.beginRequest {
732+ _selectedDevice .value = device
733+ }
734+ requestTelecomEndpointSwitch(device, requestToken)
735+ } else {
736+ _selectedDevice .value = device
737+ null
723738 }
739+ pendingEndpointWaitJob?.start()
724740
725741 if (device !is StreamAudioDevice .Speakerphone && mediaManager.speaker.isEnabled.value == true ) {
726742 mediaManager.speaker._status .value = DeviceStatus .Disabled
@@ -742,35 +758,95 @@ class MicrophoneManager(
742758 * Telecom endpoint API so the routing is actually honoured.
743759 */
744760 @RequiresApi(Build .VERSION_CODES .O )
745- private fun switchTelecomEndpointForDevice (device : StreamAudioDevice ? ) {
746- val telecomCall = mediaManager.call.state.jetpackTelecomRepository
747- ?.currentCall?.value as ? TelecomCall .Registered ? : return
761+ private fun requestTelecomEndpointSwitch (
762+ device : StreamAudioDevice ? ,
763+ requestToken : TelecomEndpointSwitchCoordinator .RequestToken ,
764+ ): Job ? {
765+ val telecomRepository = mediaManager.call.state.jetpackTelecomRepository ? : return null
766+ val telecomCall =
767+ telecomRepository.currentCall.value as ? TelecomCall .Registered ? : return null
748768
749769 val targetEndpointType = when (device) {
750770 is StreamAudioDevice .Speakerphone -> CallEndpointCompat .TYPE_SPEAKER
751771 is StreamAudioDevice .Earpiece -> CallEndpointCompat .TYPE_EARPIECE
752772 is StreamAudioDevice .BluetoothHeadset -> CallEndpointCompat .TYPE_BLUETOOTH
753773 is StreamAudioDevice .WiredHeadset -> CallEndpointCompat .TYPE_WIRED_HEADSET
754- else -> return
774+ else -> return null
755775 }
756776
757777 val endpoint = telecomCall.availableCallEndpoints
758778 .firstOrNull { it.type == targetEndpointType }
759779
760780 if (endpoint == null ) {
761781 logger.w {
762- " [switchTelecomEndpointForDevice] No Telecom endpoint of type=$targetEndpointType available; available=${telecomCall.availableCallEndpoints.map { it.name }} "
782+ " [requestTelecomEndpointSwitch] No Telecom endpoint of type=$targetEndpointType " +
783+ " available; waiting up to $TELECOM_ENDPOINT_WAIT_TIMEOUT ; " +
784+ " available=${telecomCall.availableCallEndpoints.map { it.name }} "
785+ }
786+
787+ val pendingEndpointWaitJob = mediaManager.scope.launch(start = CoroutineStart .LAZY ) {
788+ val availableEndpoint = withTimeoutOrNull(TELECOM_ENDPOINT_WAIT_TIMEOUT ) {
789+ telecomRepository.currentCall.mapNotNull { call ->
790+ (call as ? TelecomCall .Registered )
791+ ?.takeIf { it.id == telecomCall.id }
792+ ?.availableCallEndpoints
793+ ?.firstOrNull { it.type == targetEndpointType }
794+ }.first()
795+ }
796+
797+ if (availableEndpoint == null ) {
798+ logger.w {
799+ " [requestTelecomEndpointSwitch] Telecom endpoint of " +
800+ " type=$targetEndpointType did not appear within " +
801+ TELECOM_ENDPOINT_WAIT_TIMEOUT
802+ }
803+ return @launch
804+ }
805+
806+ telecomEndpointSwitchCoordinator.runIfCurrent(requestToken) {
807+ if (_selectedDevice .value != device) {
808+ return @runIfCurrent
809+ }
810+
811+ val currentCall =
812+ (telecomRepository.currentCall.value as ? TelecomCall .Registered )
813+ ?.takeIf { it.id == telecomCall.id }
814+ ? : return @runIfCurrent
815+ switchTelecomEndpoint(currentCall, availableEndpoint)
816+ }
817+ }
818+ return if (
819+ telecomEndpointSwitchCoordinator.tryRegisterPendingJob(
820+ requestToken,
821+ pendingEndpointWaitJob,
822+ )
823+ ) {
824+ pendingEndpointWaitJob
825+ } else {
826+ pendingEndpointWaitJob.cancel()
827+ null
763828 }
764- return
765829 }
766830
831+ telecomEndpointSwitchCoordinator.runIfCurrent(requestToken) {
832+ switchTelecomEndpoint(telecomCall, endpoint)
833+ }
834+ return null
835+ }
836+
837+ @RequiresApi(Build .VERSION_CODES .O )
838+ private fun switchTelecomEndpoint (
839+ telecomCall : TelecomCall .Registered ,
840+ endpoint : CallEndpointCompat ,
841+ ) {
767842 if (telecomCall.currentCallEndpoint?.identifier == endpoint.identifier) {
768- logger.d { " [switchTelecomEndpointForDevice ] Telecom endpoint already set to '${endpoint.name} '" }
843+ logger.d { " [switchTelecomEndpoint ] Telecom endpoint already set to '${endpoint.name} '" }
769844 return
770845 }
771846
772847 logger.i {
773- " [switchTelecomEndpointForDevice] Switching Telecom endpoint to '${endpoint.name} ' (type=$targetEndpointType )"
848+ " [switchTelecomEndpoint] Switching Telecom endpoint to " +
849+ " '${endpoint.name} ' (type=${endpoint.type} )"
774850 }
775851 telecomCall.processAction(TelecomCallAction .SwitchAudioEndpoint (endpoint.identifier))
776852 }
@@ -1238,6 +1314,7 @@ class MicrophoneManager(
12381314 }
12391315
12401316 fun cleanup () {
1317+ telecomEndpointSwitchCoordinator.cancel()
12411318 ifAudioHandlerInitialized { it.stop() }
12421319 cleanupUsbDeviceDetection()
12431320 // Dropped with the call that captured it, so a value remembered from a call that ended in
0 commit comments