Skip to content

Commit 79ac333

Browse files
Wait for delayed Telecom endpoints before routing audio (#1866)
* fix: wait for delayed Telecom audio endpoints * fix race-condition in starting jobs for requesting endpoint change and add UT --------- Co-authored-by: Aleksandar Apostolov <apostolov.alexandar@gmail.com>
1 parent a722d46 commit 79ac333

3 files changed

Lines changed: 294 additions & 15 deletions

File tree

‎stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt‎

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,17 @@ import io.getstream.video.android.core.utils.safeCall
6767
import io.getstream.video.android.core.utils.safeCallWithDefault
6868
import io.getstream.video.android.core.utils.targetAudioMaxBitrateBps
6969
import kotlinx.coroutines.CoroutineScope
70+
import kotlinx.coroutines.CoroutineStart
7071
import kotlinx.coroutines.Job
7172
import kotlinx.coroutines.delay
7273
import kotlinx.coroutines.flow.MutableStateFlow
7374
import kotlinx.coroutines.flow.StateFlow
75+
import kotlinx.coroutines.flow.first
76+
import kotlinx.coroutines.flow.mapNotNull
7477
import kotlinx.coroutines.launch
7578
import kotlinx.coroutines.suspendCancellableCoroutine
7679
import kotlinx.coroutines.withContext
80+
import kotlinx.coroutines.withTimeoutOrNull
7781
import org.webrtc.AudioSource
7882
import org.webrtc.AudioTrack
7983
import org.webrtc.Camera2Capturer
@@ -92,6 +96,7 @@ import java.nio.ByteBuffer
9296
import java.util.UUID
9397
import java.util.concurrent.atomic.AtomicBoolean
9498
import kotlin.coroutines.resumeWithException
99+
import kotlin.time.Duration.Companion.seconds
95100

96101
sealed 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
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream 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+
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
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 io.getstream.video.android.core
18+
19+
import kotlinx.coroutines.Job
20+
21+
/**
22+
* Prevents an older Telecom endpoint switch from being applied after a newer
23+
* audio device selection.
24+
*
25+
* Starting a new request cancels the previous pending endpoint wait and
26+
* invalidates its request token.
27+
*/
28+
internal class TelecomEndpointSwitchCoordinator {
29+
30+
class RequestToken
31+
32+
private val lock = Any()
33+
private var currentRequestToken: RequestToken? = null
34+
private var pendingEndpointWaitJob: Job? = null
35+
36+
fun beginRequest(onSelected: () -> Unit): RequestToken = synchronized(lock) {
37+
pendingEndpointWaitJob?.cancel()
38+
pendingEndpointWaitJob = null
39+
RequestToken().also {
40+
currentRequestToken = it
41+
onSelected()
42+
}
43+
}
44+
45+
fun tryRegisterPendingJob(
46+
requestToken: RequestToken,
47+
pendingEndpointWaitJob: Job,
48+
): Boolean = synchronized(lock) {
49+
if (currentRequestToken !== requestToken) {
50+
false
51+
} else {
52+
this.pendingEndpointWaitJob = pendingEndpointWaitJob
53+
true
54+
}
55+
}
56+
57+
fun runIfCurrent(requestToken: RequestToken, action: () -> Unit) {
58+
synchronized(lock) {
59+
if (currentRequestToken === requestToken) {
60+
action()
61+
}
62+
}
63+
}
64+
65+
fun cancel() {
66+
synchronized(lock) {
67+
currentRequestToken = null
68+
pendingEndpointWaitJob?.cancel()
69+
pendingEndpointWaitJob = null
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)