From 1b7340ac860c3bdfd686ba4ccb2aa227bc659d8a Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 9 Dec 2025 20:49:12 +0530 Subject: [PATCH 01/15] Migrated from twilio's audioswitch to in-house implemenation --- .../android/ui/menu/AudioDeviceUiState.kt | 4 +- .../video/android/ui/menu/MenuDefinitions.kt | 6 +- .../video/android/ui/menu/SettingsMenu.kt | 50 +- .../android/util/StreamVideoInitHelper.kt | 1 + .../api/stream-video-android-core.api | 88 ++- stream-video-android-core/build.gradle.kts | 4 +- .../src/main/AndroidManifest.xml | 4 +- .../io/getstream/video/android/core/Call.kt | 35 +- .../video/android/core/MediaManager.kt | 104 ++- .../video/android/core/StreamVideoBuilder.kt | 6 + .../video/android/core/StreamVideoClient.kt | 1 + .../android/core/audio/AudioDeviceManager.kt | 56 ++ .../video/android/core/audio/AudioHandler.kt | 25 + .../android/core/audio/AudioHandlerFactory.kt | 105 +++ .../android/core/audio/CustomAudioDevice.kt | 181 +++++ .../core/audio/LegacyAudioDeviceManager.kt | 744 ++++++++++++++++++ .../core/audio/ModernAudioDeviceManager.kt | 117 +++ .../android/core/audio/StreamAudioDevice.kt | 47 +- .../android/core/audio/StreamAudioManager.kt | 136 ++++ .../android/core/audio/StreamAudioSwitch.kt | 447 +++++++++++ .../core/audio/StreamAudioSwitchHandler.kt | 82 ++ .../video/android/core/SpeakerManagerTest.kt | 28 +- 22 files changed, 2203 insertions(+), 68 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt index 0dddfdf576d..6f8f8cc32ce 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt @@ -17,10 +17,10 @@ package io.getstream.video.android.ui.menu import androidx.compose.ui.graphics.vector.ImageVector -import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.audio.CustomAudioDevice data class AudioDeviceUiState( - val streamAudioDevice: StreamAudioDevice, + val streamAudioDevice: CustomAudioDevice, val text: String, val icon: ImageVector, // Assuming it's a drawable resource ID val highlight: Boolean, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt index 4e6552ee840..b974f7a89b6 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt @@ -44,7 +44,7 @@ import androidx.compose.material.icons.filled.VideoSettings import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import io.getstream.video.android.compose.ui.components.video.VideoScalingType -import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.audio.CustomAudioDevice import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState import io.getstream.video.android.ui.menu.base.ActionMenuItem @@ -75,11 +75,11 @@ fun defaultStreamMenu( onSelectIncomingVideoResolution: (PreferredVideoResolution?) -> Unit, isIncomingVideoEnabled: Boolean, onToggleIncomingVideoEnabled: (Boolean) -> Unit, - onDeviceSelected: (StreamAudioDevice) -> Unit, + onDeviceSelected: (CustomAudioDevice) -> Unit, onSfuRejoinClick: () -> Unit, onSfuFastReconnectClick: () -> Unit, onSelectScaleType: (VideoScalingType) -> Unit, - availableDevices: List, + availableDevices: List, loadRecordings: suspend () -> List, transcriptionUiState: TranscriptionUiState, onToggleTranscription: suspend () -> Unit, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt index 5871b425807..ac99aa8f996 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt @@ -58,7 +58,7 @@ import com.google.accompanist.permissions.rememberPermissionState import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.ui.components.video.VideoScalingType import io.getstream.video.android.core.Call -import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.audio.CustomAudioDevice import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.mapper.ReactionMapper import io.getstream.video.android.core.model.PreferredVideoResolution @@ -94,7 +94,7 @@ internal fun SettingsMenu( onClosedCaptionsToggle: () -> Unit, ) { val context = LocalContext.current - val availableDevices by call.microphone.devices.collectAsStateWithLifecycle() + val availableDevices by call.microphone.customAudioDevices.collectAsStateWithLifecycle() val currentAudioUsage by call.speaker.audioUsage.collectAsStateWithLifecycle() val audioUsageUiState = remember(currentAudioUsage) { @@ -208,19 +208,53 @@ internal fun SettingsMenu( } } - val selectedMicroPhoneDevice by call.microphone.selectedDevice.collectAsStateWithLifecycle() + val selectedMicroPhoneDevice by call.microphone.selectedCustomAudioDevice.collectAsStateWithLifecycle() val audioDeviceUiStateList: List = availableDevices.map { val icon = when (it) { - is StreamAudioDevice.BluetoothHeadset -> Icons.Default.BluetoothAudio - is StreamAudioDevice.Earpiece -> Icons.Default.Headphones - is StreamAudioDevice.Speakerphone -> Icons.Default.SpeakerPhone - is StreamAudioDevice.WiredHeadset -> Icons.Default.HeadsetMic + is CustomAudioDevice.BluetoothHeadset -> Icons.Default.BluetoothAudio + is CustomAudioDevice.Earpiece -> Icons.Default.Headphones + is CustomAudioDevice.Speakerphone -> Icons.Default.SpeakerPhone + is CustomAudioDevice.WiredHeadset -> Icons.Default.HeadsetMic + } + // Compare devices by type and audioDeviceInfo ID (if available) since audio can be null when using custom audio switch + val selected = selectedMicroPhoneDevice + val isSelected = when { + selected == null -> false + else -> { + // First try to compare by audioDeviceInfo ID if both have it + val itInfoId = when (it) { + is CustomAudioDevice.BluetoothHeadset -> it.audioDeviceInfo?.id + is CustomAudioDevice.WiredHeadset -> it.audioDeviceInfo?.id + is CustomAudioDevice.Earpiece -> it.audioDeviceInfo?.id + is CustomAudioDevice.Speakerphone -> it.audioDeviceInfo?.id + } + val selectedInfoId = when (selected) { + is CustomAudioDevice.BluetoothHeadset -> selected.audioDeviceInfo?.id + is CustomAudioDevice.WiredHeadset -> selected.audioDeviceInfo?.id + is CustomAudioDevice.Earpiece -> selected.audioDeviceInfo?.id + is CustomAudioDevice.Speakerphone -> selected.audioDeviceInfo?.id + } + + if (itInfoId != null && selectedInfoId != null) { + // Both have audioDeviceInfo, compare by ID + itInfoId == selectedInfoId + } else { + // Fall back to type comparison + when { + it is CustomAudioDevice.BluetoothHeadset && selected is CustomAudioDevice.BluetoothHeadset -> true + it is CustomAudioDevice.WiredHeadset && selected is CustomAudioDevice.WiredHeadset -> true + it is CustomAudioDevice.Earpiece && selected is CustomAudioDevice.Earpiece -> true + it is CustomAudioDevice.Speakerphone && selected is CustomAudioDevice.Speakerphone -> true + else -> false + } + } + } } AudioDeviceUiState( it, it.name, icon, - it.audio.name == selectedMicroPhoneDevice?.audio?.name, + isSelected, ) } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index f8cb6ea6b0c..e460e5086ba 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -317,6 +317,7 @@ object StreamVideoInitHelper { telecomConfig = TelecomConfig( context.packageName, ), + useCustomAudioSwitch = true, ).build() } } diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 33372d00a4c..07ee4089900 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8016,8 +8016,8 @@ public final class io/getstream/video/android/core/LocalStats { } public final class io/getstream/video/android/core/MediaManagerImpl { - public fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;)V - public synthetic fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;Z)V + public synthetic fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun cleanup ()V public final fun getAudioSource ()Lorg/webrtc/AudioSource; public final fun getAudioTrack ()Lorg/webrtc/AudioTrack; @@ -8029,6 +8029,7 @@ public final class io/getstream/video/android/core/MediaManagerImpl { public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; public final fun getScreenShareTrack ()Lorg/webrtc/VideoTrack; public final fun getScreenShareVideoSource ()Lorg/webrtc/VideoSource; + public final fun getUseCustomAudioSwitch ()Z public final fun getVideoSource ()Lorg/webrtc/VideoSource; public final fun getVideoTrack ()Lorg/webrtc/VideoTrack; public final fun setAudioTrack (Lorg/webrtc/AudioTrack;)V @@ -8101,8 +8102,10 @@ public final class io/getstream/video/android/core/MicrophoneManager { public final fun getAudioBitrateProfile ()Lkotlinx/coroutines/flow/StateFlow; public final fun getAudioUsage ()I public final fun getAudioUsageProvider ()Lkotlin/jvm/functions/Function0; + public final fun getCustomAudioDevices ()Lkotlinx/coroutines/flow/StateFlow; public final fun getDevices ()Lkotlinx/coroutines/flow/StateFlow; public final fun getMediaManager ()Lio/getstream/video/android/core/MediaManagerImpl; + public final fun getSelectedCustomAudioDevice ()Lkotlinx/coroutines/flow/StateFlow; public final fun getSelectedDevice ()Lkotlinx/coroutines/flow/StateFlow; public final fun getStatus ()Lkotlinx/coroutines/flow/StateFlow; public final fun isEnabled ()Lkotlinx/coroutines/flow/StateFlow; @@ -8111,6 +8114,7 @@ public final class io/getstream/video/android/core/MicrophoneManager { public static synthetic fun pause$default (Lio/getstream/video/android/core/MicrophoneManager;ZILjava/lang/Object;)V public final fun resume (Z)V public static synthetic fun resume$default (Lio/getstream/video/android/core/MicrophoneManager;ZILjava/lang/Object;)V + public final fun select (Lio/getstream/video/android/core/audio/CustomAudioDevice;)V public final fun select (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public final fun setAudioBitrateProfile-gIAlu-s (Lstream/video/sfu/models/AudioBitrateProfile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setEnabled (ZZ)V @@ -8462,7 +8466,8 @@ public final class io/getstream/video/android/core/StreamVideoBuilder { public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZ)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZ)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;Z)V + public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun build ()Lio/getstream/video/android/core/StreamVideo; } @@ -8500,6 +8505,8 @@ public abstract interface class io/getstream/video/android/core/api/SignalServer } public abstract interface class io/getstream/video/android/core/audio/AudioHandler { + public abstract fun selectCustomAudioDevice (Lio/getstream/video/android/core/audio/CustomAudioDevice;)V + public abstract fun selectDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public abstract fun start ()V public abstract fun stop ()V } @@ -8507,7 +8514,9 @@ public abstract interface class io/getstream/video/android/core/audio/AudioHandl public final class io/getstream/video/android/core/audio/AudioSwitchHandler : io/getstream/video/android/core/audio/AudioHandler { public static final field Companion Lio/getstream/video/android/core/audio/AudioSwitchHandler$Companion; public fun (Landroid/content/Context;Ljava/util/List;Lkotlin/jvm/functions/Function2;)V + public fun selectCustomAudioDevice (Lio/getstream/video/android/core/audio/CustomAudioDevice;)V public final fun selectDevice (Lcom/twilio/audioswitch/AudioDevice;)V + public fun selectDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public fun start ()V public fun stop ()V } @@ -8515,6 +8524,79 @@ public final class io/getstream/video/android/core/audio/AudioSwitchHandler : io public final class io/getstream/video/android/core/audio/AudioSwitchHandler$Companion { } +public abstract class io/getstream/video/android/core/audio/CustomAudioDevice { + public static final field Companion Lio/getstream/video/android/core/audio/CustomAudioDevice$Companion; + public static final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice; + public abstract fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; + public abstract fun getName ()Ljava/lang/String; + public static final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/CustomAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; +} + +public final class io/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset : io/getstream/video/android/core/audio/CustomAudioDevice { + public fun ()V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset; + public fun equals (Ljava/lang/Object;)Z + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; + public fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/video/android/core/audio/CustomAudioDevice$Companion { + public final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice; + public final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/CustomAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; +} + +public final class io/getstream/video/android/core/audio/CustomAudioDevice$Earpiece : io/getstream/video/android/core/audio/CustomAudioDevice { + public fun ()V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Earpiece; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$Earpiece;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Earpiece; + public fun equals (Ljava/lang/Object;)Z + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; + public fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone : io/getstream/video/android/core/audio/CustomAudioDevice { + public fun ()V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone; + public fun equals (Ljava/lang/Object;)Z + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; + public fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset : io/getstream/video/android/core/audio/CustomAudioDevice { + public fun ()V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset; + public fun equals (Ljava/lang/Object;)Z + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; + public fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class io/getstream/video/android/core/audio/StreamAudioDevice { public static final field Companion Lio/getstream/video/android/core/audio/StreamAudioDevice$Companion; public static final fun fromAudio (Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice; diff --git a/stream-video-android-core/build.gradle.kts b/stream-video-android-core/build.gradle.kts index eeb7212e355..0110d4846a3 100644 --- a/stream-video-android-core/build.gradle.kts +++ b/stream-video-android-core/build.gradle.kts @@ -134,8 +134,6 @@ dependencies { // webrtc api(libs.stream.webrtc) - implementation(libs.audioswitch) - // video filter dependencies implementation(libs.libyuv) @@ -191,6 +189,8 @@ dependencies { implementation(libs.tink) implementation(libs.androidx.media.media) + // Twilio AudioSwitch - use api() so it's available to consumers who need to access audio property + api(libs.audioswitch) // unit tests testImplementation(libs.stream.result) diff --git a/stream-video-android-core/src/main/AndroidManifest.xml b/stream-video-android-core/src/main/AndroidManifest.xml index 760b2a6cb67..38d8f5bc5ac 100644 --- a/stream-video-android-core/src/main/AndroidManifest.xml +++ b/stream-video-android-core/src/main/AndroidManifest.xml @@ -26,7 +26,9 @@ - + + + diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 14147a6a224..54565d37f76 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -341,12 +341,14 @@ public class Call( testInstanceProvider.mediaManagerCreator!!.invoke() } else { MediaManagerImpl( - clientImpl.context, - this, - scope, - eglBase.eglBaseContext, - clientImpl.callServiceConfigRegistry.get(type).audioUsage, - ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } + context = clientImpl.context, + call = this, + scope = scope, + eglBaseContext = eglBase.eglBaseContext, + audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, + audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, + useCustomAudioSwitch = clientImpl.useCustomAudioSwitch, + ) } } @@ -1300,7 +1302,13 @@ public class Call( private fun monitorHeadset() { microphone.devices.onEach { availableDevices -> logger.d { - "[monitorHeadset] new available devices, prev selected: ${microphone.nonHeadsetFallbackDevice}" + "[monitorHeadset] new available devices, prev selected: ${ + if (clientImpl.useCustomAudioSwitch) { + microphone.nonHeadsetFallbackCustomeAudioDevice + } else { + microphone.nonHeadsetFallbackDevice + } + }" } val bluetoothHeadset = @@ -1316,9 +1324,16 @@ public class Call( } else { logger.d { "[monitorHeadset] no headset found" } - microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> - logger.d { "[monitorHeadset] before device selected" } - microphone.select(deviceBeforeHeadset) + if (clientImpl.useCustomAudioSwitch) { + microphone.nonHeadsetFallbackCustomeAudioDevice?.let { deviceBeforeHeadset -> + logger.d { "[monitorHeadset] before device selected" } + microphone.select(deviceBeforeHeadset) + } + } else { + microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> + logger.d { "[monitorHeadset] before device selected" } + microphone.select(deviceBeforeHeadset) + } } } }.launchIn(scope) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index 6d0ac2c666b..a3cd055da00 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -36,15 +36,14 @@ import android.os.IBinder import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService -import com.twilio.audioswitch.AudioDevice import io.getstream.android.video.generated.models.VideoSettingsResponse import io.getstream.log.taggedLogger import io.getstream.result.extractCause import io.getstream.video.android.core.audio.AudioHandler -import io.getstream.video.android.core.audio.AudioSwitchHandler +import io.getstream.video.android.core.audio.AudioHandlerFactory +import io.getstream.video.android.core.audio.CustomAudioDevice import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.audio.StreamAudioDevice.Companion.fromAudio -import io.getstream.video.android.core.audio.StreamAudioDevice.Companion.toAudioDevice import io.getstream.video.android.core.call.video.FilterVideoProcessor import io.getstream.video.android.core.camera.CameraCharacteristicsValidator import io.getstream.video.android.core.camera.DefaultCameraCharacteristicsValidator @@ -559,16 +558,26 @@ class MicrophoneManager( public val isEnabled: StateFlow = _status.mapState { it is DeviceStatus.Enabled } private val _selectedDevice = MutableStateFlow(null) + private val _selectedNativeDevice = MutableStateFlow(null) + internal var nonHeadsetFallbackDevice: StreamAudioDevice? = null + internal var nonHeadsetFallbackCustomeAudioDevice: CustomAudioDevice? = null + /** Currently selected device */ val selectedDevice: StateFlow = _selectedDevice + val selectedCustomAudioDevice: StateFlow = _selectedNativeDevice private val _devices = MutableStateFlow>(emptyList()) /** List of available devices. */ val devices: StateFlow> = _devices + private val _nativeDevices = MutableStateFlow>(emptyList()) + + /** List of available devices. */ + val customAudioDevices: StateFlow> = _nativeDevices + private val _audioBitrateProfile = MutableStateFlow( AudioBitrateProfile.AUDIO_BITRATE_PROFILE_VOICE_STANDARD_UNSPECIFIED, @@ -635,7 +644,7 @@ class MicrophoneManager( */ fun select(device: StreamAudioDevice?) { logger.i { "selecting device $device" } - ifAudioHandlerInitialized { it.selectDevice(device?.toAudioDevice()) } + ifAudioHandlerInitialized { it.selectDevice(device) } _selectedDevice.value = device if (device !is StreamAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { @@ -651,6 +660,27 @@ class MicrophoneManager( } } + /** + * Select a specific device + */ + fun select(device: CustomAudioDevice?) { + logger.i { "selecting device $device" } + ifAudioHandlerInitialized { it.selectCustomAudioDevice(device) } + _selectedNativeDevice.value = device + + if (device !is CustomAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { + mediaManager.speaker._status.value = DeviceStatus.Disabled + } + + if (device is CustomAudioDevice.Speakerphone) { + mediaManager.speaker._status.value = DeviceStatus.Enabled + } + + if (device !is CustomAudioDevice.BluetoothHeadset && device !is CustomAudioDevice.WiredHeadset) { + nonHeadsetFallbackCustomeAudioDevice = device + } + } + /** * List the devices, returns a stateflow with audio devices */ @@ -739,22 +769,42 @@ class MicrophoneManager( } if (canHandleDeviceSwitch() && !::audioHandler.isInitialized) { - audioHandler = AudioSwitchHandler( + // Use default priority (Bluetooth -> Wired -> Earpiece -> Speakerphone) + // unless preferSpeaker is true, then prioritize speakerphone over earpiece + val preferredDeviceList = listOf( + StreamAudioDevice.BluetoothHeadset::class.java, + StreamAudioDevice.WiredHeadset::class.java, + ) + if (preferSpeaker) { + listOf( + StreamAudioDevice.Speakerphone::class.java, + StreamAudioDevice.Earpiece::class.java, + ) + } else { + listOf( + StreamAudioDevice.Earpiece::class.java, + StreamAudioDevice.Speakerphone::class.java, + ) + } + + val preferredNativeDeviceList = listOf( + CustomAudioDevice.BluetoothHeadset::class.java, + CustomAudioDevice.WiredHeadset::class.java, + ) + if (preferSpeaker) { + listOf( + CustomAudioDevice.Speakerphone::class.java, + CustomAudioDevice.Earpiece::class.java, + ) + } else { + listOf( + CustomAudioDevice.Earpiece::class.java, + CustomAudioDevice.Speakerphone::class.java, + ) + } + + audioHandler = AudioHandlerFactory.create( context = mediaManager.context, - preferredDeviceList = listOf( - AudioDevice.BluetoothHeadset::class.java, - AudioDevice.WiredHeadset::class.java, - ) + if (preferSpeaker) { - listOf( - AudioDevice.Speakerphone::class.java, - AudioDevice.Earpiece::class.java, - ) - } else { - listOf( - AudioDevice.Earpiece::class.java, - AudioDevice.Speakerphone::class.java, - ) - }, + preferredDeviceList = preferredDeviceList, + preferredNativeDeviceList = preferredNativeDeviceList, audioDeviceChangeListener = { devices, selected -> logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } @@ -766,6 +816,17 @@ class MicrophoneManager( capturedOnAudioDevicesUpdate?.invoke() capturedOnAudioDevicesUpdate = null }, + audioNativeDeviceListener = { devices, selected -> + logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } + _nativeDevices.value = devices + _selectedNativeDevice.value = selected + + setupCompleted = true + + capturedOnAudioDevicesUpdate?.invoke() + capturedOnAudioDevicesUpdate = null + }, + useCustomAudioSwitch = mediaManager.useCustomAudioSwitch, ) logger.d { "[setup] Calling start on instance $audioHandler" } @@ -782,9 +843,9 @@ class MicrophoneManager( onAudioDevicesUpdate = actual, ) - private fun ifAudioHandlerInitialized(then: (audioHandler: AudioSwitchHandler) -> Unit) { + private fun ifAudioHandlerInitialized(then: (audioHandler: AudioHandler) -> Unit) { if (this::audioHandler.isInitialized) { - then(this.audioHandler as AudioSwitchHandler) + then(this.audioHandler) } else { logger.e { "Audio handler not initialized. Ensure calling setup(), before using the handler." } } @@ -1254,6 +1315,7 @@ class MediaManagerImpl( @Deprecated("Use audioUsageProvider instead", replaceWith = ReplaceWith("audioUsageProvider")) val audioUsage: Int = defaultAudioUsage, val audioUsageProvider: (() -> Int) = { audioUsage }, + val useCustomAudioSwitch: Boolean = false, ) { internal val camera = CameraManager(this, eglBaseContext, DefaultCameraCharacteristicsValidator()) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index cc9dad27aed..ab6d39c05ef 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -144,6 +144,11 @@ public class StreamVideoBuilder @JvmOverloads constructor( @InternalStreamVideoApi private val enableStereoForSubscriber: Boolean = true, private val telecomConfig: TelecomConfig? = null, + /** + * If true, uses the custom StreamAudioSwitch implementation (native Android APIs). + * If false, uses Twilio's AudioSwitch library (default for backward compatibility). + */ + private val useCustomAudioSwitch: Boolean = false, ) { private val context: Context = context.applicationContext private val scope = UserScope(ClientScope()) @@ -272,6 +277,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( vibrationConfig = vibrationConfig, enableStereoForSubscriber = enableStereoForSubscriber, telecomConfig = telecomConfig, + useCustomAudioSwitch = useCustomAudioSwitch, ) if (user.type == UserType.Guest) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index eb536dde76b..57158adb87e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -178,6 +178,7 @@ internal class StreamVideoClient internal constructor( internal val enableStatsCollection: Boolean = true, internal val enableStereoForSubscriber: Boolean = true, internal val telecomConfig: TelecomConfig? = null, + internal val useCustomAudioSwitch: Boolean = false, ) : StreamVideo, NotificationHandler by streamNotificationManager { private var locationJob: Deferred>? = null diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt new file mode 100644 index 00000000000..bad7f7a17e7 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +/** + * Interface for managing audio device operations. + * Different implementations handle API level differences. + * Uses NativeStreamAudioDevice for the custom audio switch implementation. + */ +internal interface AudioDeviceManager { + /** + * Enumerates available audio devices. + */ + fun enumerateDevices(): List + + /** + * Selects an audio device for routing. + * @param device The device to select + * @return true if selection was successful, false otherwise + */ + fun selectDevice(device: CustomAudioDevice): Boolean + + /** + * Clears the current device selection. + */ + fun clearDevice() + + /** + * Gets the currently selected device. + */ + fun getSelectedDevice(): CustomAudioDevice? + + /** + * Starts the device manager (registers listeners, etc.) + */ + fun start() + + /** + * Stops the device manager (unregisters listeners, etc.) + */ + fun stop() +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt index 48231ccdb37..ab1a7aa2976 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt @@ -25,6 +25,7 @@ import com.twilio.audioswitch.AudioDeviceChangeListener import com.twilio.audioswitch.AudioSwitch import io.getstream.log.StreamLog import io.getstream.log.taggedLogger +import kotlin.DeprecationLevel public interface AudioHandler { /** @@ -36,6 +37,10 @@ public interface AudioHandler { * Called when a room is disconnected. */ public fun stop() + + public fun selectDevice(audioDevice: StreamAudioDevice?) + + public fun selectCustomAudioDevice(customAudioDevice: io.getstream.video.android.core.audio.CustomAudioDevice?) } /** @@ -84,12 +89,32 @@ public class AudioSwitchHandler( } } + @Deprecated( + message = "StreamAudioDevice is deprecated. Use NativeStreamAudioDevice when useCustomAudioSwitch is true.", + level = DeprecationLevel.WARNING, + ) + override fun selectDevice(audioDevice: StreamAudioDevice?) { + val twilioDevice = convertStreamDeviceToTwilioDevice(audioDevice) + selectDevice(twilioDevice) + } + + override fun selectCustomAudioDevice(customAudioDevice: io.getstream.video.android.core.audio.CustomAudioDevice?) { + } + public fun selectDevice(audioDevice: AudioDevice?) { logger.i { "[selectDevice] audioDevice: $audioDevice" } audioSwitch?.selectDevice(audioDevice) audioSwitch?.activate() } + /** + * Converts a StreamAudioDevice to Twilio's AudioDevice. + * Returns null if the input is null. + */ + private fun convertStreamDeviceToTwilioDevice(streamDevice: StreamAudioDevice?): AudioDevice? { + return streamDevice?.audio + } + public companion object { private const val TAG = "AudioSwitchHandler" private val onAudioFocusChangeListener by lazy(LazyThreadSafetyMode.NONE) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt new file mode 100644 index 00000000000..4562d1eb0c4 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.content.Context +import com.twilio.audioswitch.AudioDevice +import com.twilio.audioswitch.AudioDeviceChangeListener as TwilioAudioDeviceChangeListener + +/** + * Factory for creating AudioHandler instances. + * Supports both Twilio AudioSwitch (legacy) and custom StreamAudioSwitch implementations. + */ +internal object AudioHandlerFactory { + /** + * Creates an AudioHandler instance based on the useCustomAudioSwitch flag. + * + * @param context Android context + * @param preferredDeviceList List of preferred audio device types in priority order + * @param audioDeviceChangeListener Callback for device changes + * @param useCustomAudioSwitch If true, uses custom StreamAudioSwitch; if false, uses Twilio AudioSwitch + * @return An AudioHandler instance + */ + fun create( + context: Context, + preferredDeviceList: List>, + preferredNativeDeviceList: + List>, + audioDeviceChangeListener: TwilioAudioDeviceChangeListener, + audioNativeDeviceListener: CustomAudioDeviceChangeListener, + useCustomAudioSwitch: Boolean, + ): AudioHandler { + return if (useCustomAudioSwitch) { + StreamAudioSwitchHandler( + context = context, + preferredDeviceList = preferredNativeDeviceList, + audioDeviceChangeListener = audioNativeDeviceListener, + ) + } else { + // Twilio audio switcher + // Convert StreamAudioDevice types to Twilio AudioDevice types + val twilioPreferredDevices = preferredDeviceList.map { streamDeviceClass -> + when (streamDeviceClass) { + StreamAudioDevice.BluetoothHeadset::class.java -> AudioDevice.BluetoothHeadset::class.java + StreamAudioDevice.WiredHeadset::class.java -> AudioDevice.WiredHeadset::class.java + StreamAudioDevice.Earpiece::class.java -> AudioDevice.Earpiece::class.java + StreamAudioDevice.Speakerphone::class.java -> AudioDevice.Speakerphone::class.java + else -> AudioDevice.Speakerphone::class.java // fallback + } + } + + // Convert our AudioDeviceChangeListener to Twilio's AudioDeviceChangeListener + val twilioListener: TwilioAudioDeviceChangeListener = { devices, selected -> + // Convert Twilio AudioDevice to StreamAudioDevice + val streamDevices = devices.map { twilioDevice -> + convertTwilioDeviceToStreamDevice(twilioDevice) + } + val streamSelected = selected?.let { convertTwilioDeviceToStreamDevice(it) } + // The callback expects Twilio AudioDevice types, so convert back + val twilioDevices = streamDevices.map { it.audio } + val twilioSelected = streamSelected?.audio + audioDeviceChangeListener(devices, selected) + } + + AudioSwitchHandler( + context = context, + preferredDeviceList = twilioPreferredDevices, + audioDeviceChangeListener = twilioListener, + ) + } + } + + /** + * Converts a Twilio AudioDevice to StreamAudioDevice. + */ + private fun convertTwilioDeviceToStreamDevice(twilioDevice: AudioDevice): StreamAudioDevice { + return when (twilioDevice) { + is AudioDevice.BluetoothHeadset -> StreamAudioDevice.BluetoothHeadset( + audio = twilioDevice, + ) + is AudioDevice.WiredHeadset -> StreamAudioDevice.WiredHeadset( + audio = twilioDevice, + ) + is AudioDevice.Earpiece -> StreamAudioDevice.Earpiece( + audio = twilioDevice, + ) + is AudioDevice.Speakerphone -> StreamAudioDevice.Speakerphone( + audio = twilioDevice, + ) + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt new file mode 100644 index 00000000000..d1e78fc0bb8 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * Represents an audio device for the native Android audio switch implementation. + * This class is used when [useCustomAudioSwitch] is true. + * + * Unlike [StreamAudioDevice], this class does not depend on Twilio's AudioDevice. + * It uses Android's native [AudioDeviceInfo] for device identification and management. + */ +public sealed class CustomAudioDevice { + + /** The friendly name of the device.*/ + public abstract val name: String + + /** + * The Android AudioDeviceInfo instance. + * This provides device identification and capabilities. + * @see android.media.AudioDeviceInfo + */ + public abstract val audioDeviceInfo: AudioDeviceInfo? + + /** A [CustomAudioDevice] representing a Bluetooth Headset.*/ + public data class BluetoothHeadset constructor( + override val name: String = "Bluetooth", + override val audioDeviceInfo: AudioDeviceInfo? = null, + ) : CustomAudioDevice() + + /** A [CustomAudioDevice] representing a Wired Headset.*/ + public data class WiredHeadset constructor( + override val name: String = "Wired Headset", + override val audioDeviceInfo: AudioDeviceInfo? = null, + ) : CustomAudioDevice() + + /** A [CustomAudioDevice] representing the Earpiece.*/ + public data class Earpiece constructor( + override val name: String = "Earpiece", + override val audioDeviceInfo: AudioDeviceInfo? = null, + ) : CustomAudioDevice() + + /** A [CustomAudioDevice] representing the Speakerphone.*/ + public data class Speakerphone constructor( + override val name: String = "Speakerphone", + override val audioDeviceInfo: AudioDeviceInfo? = null, + ) : CustomAudioDevice() + + public companion object { + /** + * Converts an Android AudioDeviceInfo to a NativeStreamAudioDevice. + * Returns null if the device type is not supported. + * Available from API 23+ (always available since minSdk is 24). + */ + @JvmStatic + public fun fromAudioDeviceInfo(deviceInfo: AudioDeviceInfo): CustomAudioDevice? { + return when (deviceInfo.type) { + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + -> { + CustomAudioDevice.BluetoothHeadset( + audioDeviceInfo = deviceInfo, + ) + } + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_HEADSET, + -> { + CustomAudioDevice.WiredHeadset( + audioDeviceInfo = deviceInfo, + ) + } + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> { + CustomAudioDevice.Earpiece( + audioDeviceInfo = deviceInfo, + ) + } + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> { + CustomAudioDevice.Speakerphone( + audioDeviceInfo = deviceInfo, + ) + } + else -> null + } + } + + /** + * Converts a NativeStreamAudioDevice to an AudioDeviceInfo by finding a matching device + * from the available communication devices. + * Returns null if no matching device is found. + */ + @RequiresApi(Build.VERSION_CODES.S) + @JvmStatic + public fun toAudioDeviceInfo( + nativeDevice: CustomAudioDevice, + audioManager: AudioManager, + ): AudioDeviceInfo? { + // If the device already has an AudioDeviceInfo, use it + val existingInfo = when (nativeDevice) { + is BluetoothHeadset -> nativeDevice.audioDeviceInfo?.id + is WiredHeadset -> nativeDevice.audioDeviceInfo?.id + is Earpiece -> nativeDevice.audioDeviceInfo?.id + is Speakerphone -> nativeDevice.audioDeviceInfo?.id + } + + // Otherwise, try to find a matching device from available devices + // For API 31+: use communication devices, for API 24-30: use all output devices + val availableDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return StreamAudioManager.getAvailableCommunicationDevices(audioManager).find { + it.id == existingInfo + } + } else { + // For API < 31, use getDevices() to get all output devices + try { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } catch (e: Exception) { + emptyList() + } + } + + return when (nativeDevice) { + is BluetoothHeadset -> { + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + } + } + is WiredHeadset -> { + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || + it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || + it.type == AudioDeviceInfo.TYPE_USB_HEADSET + } + } + is Earpiece -> { + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + } + } + is Speakerphone -> { + // For speakerphone, also check all devices if not found in communication devices + // Speakerphone might not always be in availableCommunicationDevices + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + } ?: run { + // Fallback: try to get from all devices if not in communication devices + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + } + } catch (e: Exception) { + null + } + } else { + null + } + } + } + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt new file mode 100644 index 00000000000..174fe6b3e6a --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt @@ -0,0 +1,744 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import io.getstream.log.taggedLogger + +/** + * Audio device manager for API 24-30. + * Uses legacy AudioManager APIs and BluetoothHeadset profile proxy for Bluetooth device detection. + */ +internal class LegacyAudioDeviceManager( + private val context: Context, + private val audioManager: AudioManager, + private val onDeviceChange: () -> Unit, + private val onBluetoothConnectionFailure: () -> Unit, +) : AudioDeviceManager { + + private val logger by taggedLogger(TAG) + + private var selectedDevice: CustomAudioDevice? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + // Legacy listener support + private var headsetPlugReceiver: BroadcastReceiver? = null + private var bluetoothStateReceiver: BroadcastReceiver? = null + private var bluetoothManager: BluetoothManager? = null + private var bluetoothAdapter: BluetoothAdapter? = null + private var bluetoothHeadset: BluetoothHeadset? = null + private var bluetoothDevice: BluetoothDevice? = null + private var bluetoothProfileProxyAvailable = false + + // Bluetooth SCO management state + private enum class BluetoothScoState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING, + } + private var bluetoothScoState = BluetoothScoState.DISCONNECTED + private var bluetoothScoConnectionAttempts = 0 + private val maxBluetoothScoAttempts = 3 + private val bluetoothScoTimeoutHandler = Handler(Looper.getMainLooper()) + private var bluetoothScoTimeoutRunnable: Runnable? = null + + override fun enumerateDevices(): List { + val devices = mutableListOf() + + // Detect Bluetooth devices - use profile proxy if available, otherwise fall back to AudioDeviceInfo + if (bluetoothProfileProxyAvailable && bluetoothHeadset != null) { + // Use BluetoothHeadset profile proxy - only shows devices that support SCO + logger.d { + "[enumerateDevices] Checking Bluetooth devices via headset profile. bluetoothHeadset is connected" + } + val bluetoothDevices = detectBluetoothDevices() + if (bluetoothDevices.isNotEmpty()) { + logger.d { + "[enumerateDevices] Found ${bluetoothDevices.size} Bluetooth device(s) via headset profile" + } + devices.addAll(bluetoothDevices) + } else { + logger.d { "[enumerateDevices] No Bluetooth devices found via headset profile" } + } + } else { + logger.d { + "[enumerateDevices] Profile proxy not available, using AudioDeviceInfo enumeration for Bluetooth" + } + } + + // Detect other devices (wired headset, earpiece, speakerphone) and Bluetooth (if profile proxy unavailable) via AudioDeviceInfo + val androidDevices = StreamAudioManager.getAvailableCommunicationDevices(audioManager) + logger.d { "[enumerateDevices] Found ${androidDevices.size} available communication devices" } + + // Track Bluetooth devices by address to deduplicate SCO and A2DP for the same device + val bluetoothDevicesByAddress = mutableMapOf() + + for (androidDevice in androidDevices) { + val customAudioDevice = CustomAudioDevice.fromAudioDeviceInfo(androidDevice) + if (customAudioDevice != null) { + when (customAudioDevice) { + is CustomAudioDevice.BluetoothHeadset -> { + // If profile proxy is available, skip AudioDeviceInfo Bluetooth devices (we already got them from profile) + if (bluetoothProfileProxyAvailable && bluetoothHeadset != null) { + continue + } + // Otherwise, use AudioDeviceInfo enumeration and deduplicate by address + val address = androidDevice.address ?: "" + val existing = bluetoothDevicesByAddress[address] + if (existing == null) { + logger.d { + "[enumerateDevices] Detected Bluetooth device via AudioDeviceInfo: ${customAudioDevice.name}, type=${androidDevice.type}, address=$address" + } + bluetoothDevicesByAddress[address] = customAudioDevice + } else { + // Prefer SCO over A2DP + val isSco = androidDevice.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO + val existingIsSco = existing.audioDeviceInfo?.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO + if (isSco && !existingIsSco) { + logger.d { + "[enumerateDevices] Replacing A2DP with SCO for Bluetooth device: ${customAudioDevice.name}, address=$address" + } + bluetoothDevicesByAddress[address] = customAudioDevice + } else { + logger.d { + "[enumerateDevices] Skipping duplicate Bluetooth device (keeping ${if (existingIsSco) "SCO" else "A2DP"}): type=${androidDevice.type}, address=$address" + } + } + } + } + else -> { + logger.d { + "[enumerateDevices] Detected device: ${customAudioDevice::class.simpleName} (${customAudioDevice.name})" + } + devices.add(customAudioDevice) + } + } + } else { + logger.w { + "[enumerateDevices] Could not convert AudioDeviceInfo to CustomAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" + } + } + } + + // Add deduplicated Bluetooth devices from AudioDeviceInfo (if profile proxy was not used) + if (!bluetoothProfileProxyAvailable || bluetoothHeadset == null) { + devices.addAll(bluetoothDevicesByAddress.values) + } + + // Check for wired headset using ACTION_HEADSET_PLUG state + // (getDevices() might not always detect it reliably) + if (devices.none { it is CustomAudioDevice.WiredHeadset }) { + @Suppress("DEPRECATION") + val isWiredHeadsetOn = try { + audioManager.isWiredHeadsetOn + } catch (e: Exception) { + false + } + if (isWiredHeadsetOn) { + logger.d { "[enumerateDevices] Adding WiredHeadset via fallback check (isWiredHeadsetOn=true)" } + devices.add(CustomAudioDevice.WiredHeadset()) + } + } + + // Add speakerphone - always available + if (devices.none { it is CustomAudioDevice.Speakerphone }) { + logger.d { "[enumerateDevices] Adding Speakerphone (always available)" } + devices.add(CustomAudioDevice.Speakerphone()) + } + + // Add earpiece only if device has telephony feature (is a phone) + if (devices.none { it is CustomAudioDevice.Earpiece }) { + val hasEarpiece = hasEarpiece(context) + if (hasEarpiece) { + logger.d { "[enumerateDevices] Adding Earpiece (device has telephony feature)" } + devices.add(CustomAudioDevice.Earpiece()) + } else { + logger.d { "[enumerateDevices] Skipping Earpiece (device does not have telephony feature)" } + } + } + + logger.d { "[enumerateDevices] Total enumerated devices: ${devices.size}" } + devices.forEachIndexed { index, device -> + logger.d { "[enumerateDevices] Final device $index: ${device::class.simpleName} (${device.name})" } + } + + return devices + } + + override fun selectDevice(device: CustomAudioDevice): Boolean { + when (device) { + is CustomAudioDevice.Speakerphone -> { + stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = true + selectedDevice = device + return true + } + is CustomAudioDevice.Earpiece -> { + stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + selectedDevice = device + return true + } + is CustomAudioDevice.BluetoothHeadset -> { + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + // All Bluetooth devices detected via BluetoothHeadset profile support SCO + logger.d { "[selectDevice] Bluetooth device selected, starting SCO connection" } + startBluetoothSco() + + selectedDevice = device + return true + } + is CustomAudioDevice.WiredHeadset -> { + stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + selectedDevice = device + return true + } + } + } + + override fun clearDevice() { + stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + selectedDevice = null + } + + override fun getSelectedDevice(): CustomAudioDevice? = selectedDevice + + override fun start() { + registerLegacyListeners() + } + + override fun stop() { + unregisterLegacyListeners() + stopBluetoothSco() + clearDevice() + } + + /** + * Registers legacy listeners for API 24-30: + * - BroadcastReceiver for ACTION_HEADSET_PLUG (wired headset) + * - BluetoothManager for Bluetooth device detection + */ + private fun registerLegacyListeners() { + // Register BroadcastReceiver for wired headset plug/unplug + headsetPlugReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == AudioManager.ACTION_HEADSET_PLUG) { + val state = intent.getIntExtra("state", -1) + val hasMicrophone = intent.getIntExtra("microphone", -1) == 1 + logger.d { + "[onReceive] ACTION_HEADSET_PLUG: state=$state, hasMicrophone=$hasMicrophone" + } + // Re-enumerate devices when headset is plugged/unplugged + onDeviceChange() + } + } + } + + val filter = IntentFilter(AudioManager.ACTION_HEADSET_PLUG) + context.registerReceiver(headsetPlugReceiver, filter) + + // Initialize Bluetooth adapter and headset profile proxy + bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + bluetoothAdapter = bluetoothManager?.adapter ?: BluetoothAdapter.getDefaultAdapter() + + if (bluetoothAdapter == null) { + logger.i { "[registerLegacyListeners] Device does not support Bluetooth" } + return + } + + // Check if Bluetooth SCO is available + @Suppress("DEPRECATION") + if (!audioManager.isBluetoothScoAvailableOffCall) { + logger.w { "[registerLegacyListeners] Bluetooth SCO audio is not available off call" } + return + } + + // Get BluetoothHeadset profile proxy + logger.d { "[registerLegacyListeners] Requesting BluetoothHeadset profile proxy" } + val profileProxyResult = try { + bluetoothAdapter?.getProfileProxy( + context, + bluetoothProfileServiceListener, + BluetoothProfile.HEADSET, + ) + } catch (e: SecurityException) { + logger.w { + "[registerLegacyListeners] SecurityException getting Bluetooth profile proxy: ${e.message}. Will fall back to AudioDeviceInfo enumeration for Bluetooth." + } + bluetoothProfileProxyAvailable = false + // Continue without profile proxy - we'll use AudioDeviceInfo enumeration as fallback + // Don't return, continue to register receivers for state changes + } + + logger.d { "[registerLegacyListeners] getProfileProxy result: $profileProxyResult" } + if (profileProxyResult == true) { + bluetoothProfileProxyAvailable = true + logger.d { + "[registerLegacyListeners] Profile proxy requested, waiting for onServiceConnected callback" + } + } else { + bluetoothProfileProxyAvailable = false + logger.w { + "[registerLegacyListeners] BluetoothAdapter.getProfileProxy(HEADSET) failed. Will fall back to AudioDeviceInfo enumeration for Bluetooth." + } + } + + // Register Bluetooth state change receiver + bluetoothStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + when { + action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> { + val connectionState = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED) + logger.d { "[onReceive] Bluetooth headset connection state changed: $connectionState" } + onHeadsetConnectionStateChanged(connectionState) + } + action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED -> { + val audioState = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED) + logger.d { "[onReceive] Bluetooth audio state changed: $audioState" } + onAudioStateChanged(audioState, isInitialStickyBroadcast) + } + action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> { + val state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1) + handleBluetoothScoStateUpdate(state) + } + } + } + } + + val bluetoothFilter = IntentFilter().apply { + addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) + addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) + addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) + } + context.registerReceiver(bluetoothStateReceiver, bluetoothFilter) + } + + /** + * Unregisters legacy listeners. + */ + private fun unregisterLegacyListeners() { + headsetPlugReceiver?.let { receiver -> + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + logger.w { "[unregisterLegacyListeners] Failed to unregister headset receiver: $e" } + } + headsetPlugReceiver = null + } + + bluetoothStateReceiver?.let { receiver -> + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + logger.w { "[unregisterLegacyListeners] Failed to unregister Bluetooth receiver: $e" } + } + bluetoothStateReceiver = null + } + + // Close BluetoothHeadset profile proxy + bluetoothHeadset?.let { headset -> + try { + bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, headset) + } catch (e: Exception) { + logger.w { "[unregisterLegacyListeners] Failed to close Bluetooth profile proxy: $e" } + } + } + bluetoothHeadset = null + bluetoothDevice = null + + bluetoothManager = null + bluetoothAdapter = null + } + + /** + * Detects Bluetooth devices using BluetoothHeadset profile + * This only returns devices that support SCO and are actually connected. + */ + private fun detectBluetoothDevices(): List { + if (bluetoothHeadset == null) { + logger.d { "[detectBluetoothDevices] bluetoothHeadset is null, profile proxy not connected yet" } + return emptyList() + } + + val devices = mutableListOf() + + try { + val connectedDevices: List? = bluetoothHeadset?.connectedDevices + logger.d { "[detectBluetoothDevices] connectedDevices count: ${connectedDevices?.size ?: 0}" } + + if (connectedDevices.isNullOrEmpty()) { + logger.d { "[detectBluetoothDevices] No connected Bluetooth headsets" } + return emptyList() + } + + // Get the first connected device + val device = connectedDevices[0] + val deviceName = device.name ?: "Bluetooth Headset" + val deviceAddress = device.address ?: "" + + // Check connection state + val connectionState = try { + bluetoothHeadset?.getConnectionState(device) + } catch (e: SecurityException) { + logger.w { "[detectBluetoothDevices] SecurityException getting connection state: ${e.message}" } + BluetoothHeadset.STATE_DISCONNECTED + } + + logger.d { + "[detectBluetoothDevices] Found Bluetooth device: name=$deviceName, " + + "address=$deviceAddress, connectionState=$connectionState" + } + + // Only add device if it's actually connected + if (connectionState == BluetoothHeadset.STATE_CONNECTED) { + // Create CustomAudioDevice for the Bluetooth headset + // Note: We don't have AudioDeviceInfo here, but that's okay - we know it supports SCO + devices.add( + CustomAudioDevice.BluetoothHeadset( + name = deviceName, + audioDeviceInfo = null, // We don't have AudioDeviceInfo from headset profile + ), + ) + + bluetoothDevice = device + logger.d { "[detectBluetoothDevices] Added Bluetooth device to list" } + } else { + logger.d { + "[detectBluetoothDevices] Device not in connected state, skipping. State: $connectionState" + } + } + } catch (e: SecurityException) { + logger.w { "[detectBluetoothDevices] SecurityException getting Bluetooth devices: ${e.message}" } + } catch (e: Exception) { + logger.e(e) { "[detectBluetoothDevices] Error detecting Bluetooth devices" } + } + + return devices + } + + /** + * BluetoothProfile.ServiceListener for BluetoothHeadset profile proxy. + */ + private val bluetoothProfileServiceListener = object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { + if (profile == BluetoothProfile.HEADSET) { + mainHandler.post { + logger.i { "[onServiceConnected] BluetoothHeadset profile connected, proxy=$proxy" } + bluetoothHeadset = proxy as? BluetoothHeadset + bluetoothProfileProxyAvailable = true + + // Immediately check for connected devices and trigger enumeration + if (bluetoothHeadset != null) { + logger.d { "[onServiceConnected] Profile connected, checking for devices" } + try { + val connectedDevices = bluetoothHeadset?.connectedDevices + logger.d { "[onServiceConnected] Connected devices count: ${connectedDevices?.size ?: 0}" } + if (!connectedDevices.isNullOrEmpty()) { + connectedDevices.forEachIndexed { index, device -> + val connectionState = try { + bluetoothHeadset?.getConnectionState(device) + } catch (e: SecurityException) { + logger.w { "[onServiceConnected] SecurityException getting connection state: ${e.message}" } + -1 + } + logger.d { + "[onServiceConnected] Device $index: name=${device.name}, " + + "address=${device.address}, state=$connectionState" + } + } + } + } catch (e: SecurityException) { + logger.w { "[onServiceConnected] SecurityException checking devices: ${e.message}" } + } + } + + onDeviceChange() + } + } else { + logger.w { "[onServiceConnected] Unexpected profile: $profile" } + } + } + + override fun onServiceDisconnected(profile: Int) { + if (profile == BluetoothProfile.HEADSET) { + mainHandler.post { + logger.i { "[onServiceDisconnected] BluetoothHeadset profile disconnected" } + stopBluetoothSco() + bluetoothHeadset = null + bluetoothDevice = null + bluetoothProfileProxyAvailable = false + onDeviceChange() + } + } else { + logger.w { "[onServiceDisconnected] Unexpected profile: $profile" } + } + } + } + + /** + * Handles Bluetooth headset connection state changes. + */ + private fun onHeadsetConnectionStateChanged(connectionState: Int) { + logger.d { "[onHeadsetConnectionStateChanged] Connection state: $connectionState" } + when (connectionState) { + BluetoothHeadset.STATE_CONNECTED -> { + bluetoothScoConnectionAttempts = 0 + onDeviceChange() + } + BluetoothHeadset.STATE_DISCONNECTED -> { + stopBluetoothSco() + onDeviceChange() + } + } + } + + /** + * Handles Bluetooth audio state changes. + */ + private fun onAudioStateChanged(audioState: Int, isInitialStateChange: Boolean) { + logger.d { "[onAudioStateChanged] Audio state: $audioState, isInitial: $isInitialStateChange" } + + if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTED) { + bluetoothScoTimeoutRunnable?.let { bluetoothScoTimeoutHandler.removeCallbacks(it) } + if (bluetoothScoState == BluetoothScoState.CONNECTING) { + logger.d { "[onAudioStateChanged] Bluetooth audio SCO is now connected" } + bluetoothScoState = BluetoothScoState.CONNECTED + bluetoothScoConnectionAttempts = 0 + } + } else if (audioState == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { + if (isInitialStateChange) { + logger.d { "[onAudioStateChanged] Ignoring initial sticky broadcast for audio disconnect" } + return + } + logger.d { "[onAudioStateChanged] Bluetooth audio SCO is now disconnected" } + onDeviceChange() + } + } + + /** + * Handles Bluetooth SCO audio state updates. + */ + private fun handleBluetoothScoStateUpdate(state: Int) { + logger.d { "[handleBluetoothScoStateUpdate] SCO state: $state" } + when (state) { + AudioManager.SCO_AUDIO_STATE_CONNECTED -> { + if (bluetoothScoState == BluetoothScoState.CONNECTING) { + logger.d { "[handleBluetoothScoStateUpdate] Bluetooth SCO connected" } + bluetoothScoTimeoutRunnable?.let { + bluetoothScoTimeoutHandler.removeCallbacks( + it, + ) + } + bluetoothScoState = BluetoothScoState.CONNECTED + bluetoothScoConnectionAttempts = 0 + } else { + logger.w { "[handleBluetoothScoStateUpdate] Unexpected SCO connected state: $bluetoothScoState" } + } + } + AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> { + if (bluetoothScoState == BluetoothScoState.CONNECTED || + bluetoothScoState == BluetoothScoState.CONNECTING + ) { + logger.d { "[handleBluetoothScoStateUpdate] Bluetooth SCO disconnected" } + bluetoothScoState = BluetoothScoState.DISCONNECTED + // If we were trying to connect and it disconnected, it's a failure + if (bluetoothScoConnectionAttempts > 0) { + handleBluetoothConnectionFailure() + } + } + } + AudioManager.SCO_AUDIO_STATE_CONNECTING -> { + logger.d { "[handleBluetoothScoStateUpdate] Bluetooth SCO connecting" } + bluetoothScoState = BluetoothScoState.CONNECTING + } + AudioManager.SCO_AUDIO_STATE_ERROR -> { + logger.w { "[handleBluetoothScoStateUpdate] Bluetooth SCO error" } + handleBluetoothConnectionFailure() + } + } + } + + /** + * Starts Bluetooth SCO connection with robust error handling and retry logic. + * checks if SCO is available and device is connected. + */ + private fun startBluetoothSco(): Boolean { + if (bluetoothScoConnectionAttempts >= maxBluetoothScoAttempts) { + logger.w { "[startBluetoothSco] SCO connection attempts maxed out" } + return false + } + + if (bluetoothHeadset == null || bluetoothDevice == null) { + logger.w { "[startBluetoothSco] No Bluetooth headset or device available" } + return false + } + + // Check if device is actually connected via headset profile + try { + val connectionState = bluetoothHeadset?.getConnectionState(bluetoothDevice) + if (connectionState != BluetoothHeadset.STATE_CONNECTED) { + logger.w { "[startBluetoothSco] Bluetooth device not connected, state: $connectionState" } + return false + } + } catch (e: SecurityException) { + logger.w { "[startBluetoothSco] SecurityException checking connection state: ${e.message}" } + return false + } + + if (bluetoothScoState == BluetoothScoState.CONNECTED || + bluetoothScoState == BluetoothScoState.CONNECTING + ) { + logger.d { "[startBluetoothSco] Already connected or connecting, state: $bluetoothScoState" } + return true + } + + bluetoothScoState = BluetoothScoState.CONNECTING + bluetoothScoConnectionAttempts++ + + logger.d { "[startBluetoothSco] Starting Bluetooth SCO (attempt $bluetoothScoConnectionAttempts)" } + + try { + @Suppress("DEPRECATION") + audioManager.startBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isBluetoothScoOn = true + + // Set up timeout to detect connection failure + // The ACTION_SCO_AUDIO_STATE_UPDATED and ACTION_AUDIO_STATE_CHANGED receivers will notify us when connection succeeds + bluetoothScoTimeoutRunnable = Runnable { + if (bluetoothScoState == BluetoothScoState.CONNECTING) { + // Check if actually connected + val isAudioConnected = try { + bluetoothHeadset?.isAudioConnected(bluetoothDevice) == true + } catch (e: SecurityException) { + false + } + + if (isAudioConnected) { + logger.i { "[startBluetoothSco] Device actually connected, not timed out" } + bluetoothScoState = BluetoothScoState.CONNECTED + bluetoothScoConnectionAttempts = 0 + } else { + logger.w { "[startBluetoothSco] Connection timeout after ${BLUETOOTH_SCO_CONNECTION_TIMEOUT_MS}ms" } + stopBluetoothSco() + handleBluetoothConnectionFailure() + } + } + } + bluetoothScoTimeoutHandler.postDelayed( + bluetoothScoTimeoutRunnable!!, + BLUETOOTH_SCO_CONNECTION_TIMEOUT_MS, + ) + + logger.i { "[startBluetoothSco] SCO audio started successfully" } + return true + } catch (e: Exception) { + logger.e(e) { "[startBluetoothSco] Failed to start Bluetooth SCO" } + bluetoothScoState = BluetoothScoState.DISCONNECTED + handleBluetoothConnectionFailure() + return false + } + } + + /** + * Stops Bluetooth SCO connection. + */ + private fun stopBluetoothSco() { + if (bluetoothScoState != BluetoothScoState.CONNECTING && bluetoothScoState != BluetoothScoState.CONNECTED) { + logger.d { "[stopBluetoothSco] Skipping SCO stop due to state: $bluetoothScoState" } + return + } + + logger.d { "[stopBluetoothSco] Stopping Bluetooth SCO" } + + bluetoothScoTimeoutRunnable?.let { + bluetoothScoTimeoutHandler.removeCallbacks(it) + bluetoothScoTimeoutRunnable = null + } + + try { + @Suppress("DEPRECATION") + audioManager.stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isBluetoothScoOn = false + bluetoothScoState = BluetoothScoState.DISCONNECTED + bluetoothScoConnectionAttempts = 0 + logger.i { "[stopBluetoothSco] SCO audio stopped successfully" } + } catch (e: Exception) { + logger.e(e) { "[stopBluetoothSco] Failed to stop Bluetooth SCO" } + // Even if stop fails, mark as disconnected + bluetoothScoState = BluetoothScoState.DISCONNECTED + bluetoothScoConnectionAttempts = 0 + } + } + + /** + * Handles Bluetooth connection failure by reverting to earpiece (matching Twilio behavior). + */ + private fun handleBluetoothConnectionFailure() { + logger.w { "[handleBluetoothConnectionFailure] Bluetooth connection failed, reverting to earpiece" } + + bluetoothScoState = BluetoothScoState.DISCONNECTED + bluetoothScoTimeoutRunnable?.let { + bluetoothScoTimeoutHandler.removeCallbacks(it) + bluetoothScoTimeoutRunnable = null + } + + // Reset attempts after max retries + if (bluetoothScoConnectionAttempts >= maxBluetoothScoAttempts) { + logger.w { "[handleBluetoothConnectionFailure] Max attempts reached, resetting counter" } + bluetoothScoConnectionAttempts = 0 + } + + // Trigger Bluetooth connection failure callback to allow StreamAudioSwitch to handle fallback + onBluetoothConnectionFailure() + } + + /** + * Checks if the device has an earpiece (i.e., is a phone). + * checks for telephony feature. + */ + private fun hasEarpiece(context: Context): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) + } + + companion object { + private const val TAG = "LegacyAudioDeviceManager" + private const val BLUETOOTH_SCO_CONNECTION_TIMEOUT_MS = 5000L + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt new file mode 100644 index 00000000000..032fdc18042 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.media.AudioManager +import androidx.annotation.RequiresApi +import io.getstream.log.taggedLogger + +/** + * Audio device manager for API 31+ (Android S and above). + * Uses modern communication device APIs. + */ +@RequiresApi(android.os.Build.VERSION_CODES.S) +internal class ModernAudioDeviceManager( + private val audioManager: AudioManager, +) : AudioDeviceManager { + + private val logger by taggedLogger(TAG) + private var selectedDevice: CustomAudioDevice? = null + + override fun enumerateDevices(): List { + val androidDevices = StreamAudioManager.getAvailableCommunicationDevices(audioManager) + logger.d { "[enumerateDevices] Found ${androidDevices.size} available communication devices" } + + // Log details of each available device + androidDevices.forEachIndexed { index, device -> + logger.d { + "[enumerateDevices] Device $index: type=${device.type}, " + + "name=${device.productName}, " + + "id=${device.id}, " + + "address=${device.address}" + } + } + + val customAudioDevices = mutableListOf() + + for (androidDevice in androidDevices) { + val customAudioDevice = CustomAudioDevice.fromAudioDeviceInfo(androidDevice) + if (customAudioDevice != null) { + logger.d { + "[enumerateDevices] Detected device: ${customAudioDevice::class.simpleName} (${customAudioDevice.name})" + } + customAudioDevices.add(customAudioDevice) + } else { + logger.w { + "[enumerateDevices] Could not convert AudioDeviceInfo to CustomAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" + } + } + } + + logger.d { "[enumerateDevices] Total enumerated devices: ${customAudioDevices.size}" } + customAudioDevices.forEachIndexed { index, device -> + logger.d { "[enumerateDevices] Final device $index: ${device::class.simpleName} (${device.name})" } + } + + return customAudioDevices + } + + override fun selectDevice(device: CustomAudioDevice): Boolean { + val androidDevice = device.audioDeviceInfo + ?: CustomAudioDevice.toAudioDeviceInfo(device, audioManager) + logger.d { "[selectDevice] :: $device" } + return if (androidDevice != null) { + val success = StreamAudioManager.setCommunicationDevice(audioManager, androidDevice) + if (success) { + selectedDevice = device + } + success + } else { + false + } + } + + override fun clearDevice() { + StreamAudioManager.clearCommunicationDevice(audioManager) + selectedDevice = null + } + + override fun getSelectedDevice(): CustomAudioDevice? { + // Try to get from AudioManager first + val currentDevice = StreamAudioManager.getCommunicationDevice(audioManager) + if (currentDevice != null) { + val customAudioDevice = CustomAudioDevice.fromAudioDeviceInfo(currentDevice) + if (customAudioDevice != null) { + selectedDevice = customAudioDevice + return customAudioDevice + } + } + return selectedDevice + } + + override fun start() { + // No special setup needed for modern API + } + + override fun stop() { + clearDevice() + } + + public companion object { + private const val TAG = "ModernAudioDeviceManager" + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt index ae62dba5263..04241173153 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt @@ -17,35 +17,80 @@ package io.getstream.video.android.core.audio import com.twilio.audioswitch.AudioDevice +import kotlin.DeprecationLevel +import kotlin.ReplaceWith -sealed class StreamAudioDevice { +/** + * Represents an audio device for Twilio's AudioSwitch implementation. + * + * @deprecated This class is deprecated. Use [AudioDevice] when [useCustomAudioSwitch] is true. + * This class will be removed in a future version. For new code, use [AudioDevice] instead. + * + * @see AudioDevice + */ +@Deprecated( + message = "StreamAudioDevice is deprecated. Use NativeStreamAudioDevice when useCustomAudioSwitch is true. " + + "This class is kept for backward compatibility with Twilio's AudioSwitch.", + replaceWith = ReplaceWith( + "NativeStreamAudioDevice", + "io.getstream.video.android.core.audio.NativeStreamAudioDevice", + ), + level = DeprecationLevel.WARNING, +) +public sealed class StreamAudioDevice { /** The friendly name of the device.*/ abstract val name: String + /** + * The Twilio AudioDevice instance. + * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. + * For custom audio switch implementation, use [AudioDevice] instead. + * @see com.twilio.audioswitch.AudioDevice + */ abstract val audio: AudioDevice /** An [StreamAudioDevice] representing a Bluetooth Headset.*/ data class BluetoothHeadset constructor( override val name: String = "Bluetooth", + /** + * The Twilio AudioDevice instance. + * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. + * @see com.twilio.audioswitch.AudioDevice + */ override val audio: AudioDevice, ) : StreamAudioDevice() /** An [StreamAudioDevice] representing a Wired Headset.*/ data class WiredHeadset constructor( override val name: String = "Wired Headset", + /** + * The Twilio AudioDevice instance. + * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. + * @see com.twilio.audioswitch.AudioDevice + */ override val audio: AudioDevice, ) : StreamAudioDevice() /** An [StreamAudioDevice] representing the Earpiece.*/ data class Earpiece constructor( override val name: String = "Earpiece", + /** + * The Twilio AudioDevice instance. + * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. + * @see com.twilio.audioswitch.AudioDevice + */ override val audio: AudioDevice, ) : StreamAudioDevice() /** An [StreamAudioDevice] representing the Speakerphone.*/ data class Speakerphone constructor( override val name: String = "Speakerphone", + /** + * The Twilio AudioDevice instance. + * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. + * @see com.twilio.audioswitch.AudioDevice + */ override val audio: AudioDevice, ) : StreamAudioDevice() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioManager.kt new file mode 100644 index 00000000000..fcf4d1cc248 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioManager.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * Compatibility wrapper for AudioManager APIs. + * Supports API 24+ (Android 7.0 Nougat and above). + */ +internal object StreamAudioManager { + + /** + * Registers an audio device callback to monitor device changes. + * always available since minSdk is 24 (Available from API 23+) + */ + fun registerAudioDeviceCallback( + audioManager: AudioManager, + callback: AudioDeviceCallback, + handler: android.os.Handler? = null, + ): Boolean { + return try { + audioManager.registerAudioDeviceCallback(callback, handler) + true + } catch (e: Exception) { + false + } + } + + /** + * Unregisters an audio device callback. + * always available since minSdk is 24 (Available from API 23+) + */ + fun unregisterAudioDeviceCallback( + audioManager: AudioManager, + callback: AudioDeviceCallback, + ): Boolean { + return try { + audioManager.unregisterAudioDeviceCallback(callback) + true + } catch (e: Exception) { + false + } + } + + /** + * Gets the list of available communication devices. + * For API 31+: Uses getAvailableCommunicationDevices() + * For API 24-30: Uses getDevices(AudioManager.GET_DEVICES_OUTPUTS) to get only output devices + * (using GET_DEVICES_ALL would include both input and output, causing duplicates for Bluetooth devices) + */ + fun getAvailableCommunicationDevices(audioManager: AudioManager): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + audioManager.availableCommunicationDevices + } catch (e: Exception) { + emptyList() + } + } else { + return try { + // Use GET_DEVICES_OUTPUTS instead of GET_DEVICES_ALL to avoid duplicates + // (Bluetooth devices appear as both input and output, causing duplicates) + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } catch (e: Exception) { + emptyList() + } + } + } + + /** + * Gets the currently selected communication device. + * On API < 31, returns null. + */ + @RequiresApi(Build.VERSION_CODES.S) + fun getCommunicationDevice(audioManager: AudioManager): AudioDeviceInfo? { + return try { + audioManager.communicationDevice + } catch (e: Exception) { + null + } + } + + /** + * Sets the communication device + * For API 31+: Uses setCommunicationDevice() + * For API < 31: Returns false (use setDeviceLegacy in StreamAudioSwitch instead) + */ + @RequiresApi(Build.VERSION_CODES.S) + fun setCommunicationDevice( + audioManager: AudioManager, + device: AudioDeviceInfo?, + ): Boolean { + return try { + if (device != null) { + audioManager.setCommunicationDevice(device) + } else { + audioManager.clearCommunicationDevice() + } + true + } catch (e: Exception) { + false + } + } + + /** + * Clears the communication device selection. + * On API < 31, uses legacy AudioManager APIs. + */ + @RequiresApi(Build.VERSION_CODES.S) + fun clearCommunicationDevice(audioManager: AudioManager): Boolean { + return try { + audioManager.clearCommunicationDevice() + true + } catch (e: Exception) { + false + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt new file mode 100644 index 00000000000..598a8d579a7 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.getstream.log.taggedLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Listener interface for audio device changes using NativeStreamAudioDevice. + */ +internal typealias CustomAudioDeviceChangeListener = ( + devices: List, + selectedDevice: CustomAudioDevice?, +) -> Unit + +/** + * Custom AudioSwitch implementation using Android's AudioManager APIs. + * Replaces Twilio's AudioSwitch library with native Android functionality. + * Uses NativeStreamAudioDevice for all device operations. + */ +internal class StreamAudioSwitch( + private val context: Context, + preferredDeviceList: List>? = null, +) { + private val logger by taggedLogger(TAG) + + private val preferredDeviceList: List> = + preferredDeviceList ?: getDefaultPreferredDeviceList() + + private val audioManager: AudioManager = context.getSystemService( + Context.AUDIO_SERVICE, + ) as AudioManager + private val mainHandler = Handler(Looper.getMainLooper()) + + private var audioDeviceChangeListener: CustomAudioDeviceChangeListener? = null + private var audioDeviceCallback: AudioDeviceCallback? = null + private var isStarted = false + + // Device manager - selected based on SDK version in start() + private var deviceManager: AudioDeviceManager? = null + + // Audio focus management + private var audioFocusRequest: AudioFocusRequest? = null + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + val typeOfChange: String = when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> "AUDIOFOCUS_GAIN" + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> "AUDIOFOCUS_GAIN_TRANSIENT" + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE" + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK" + AudioManager.AUDIOFOCUS_LOSS -> "AUDIOFOCUS_LOSS" + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "AUDIOFOCUS_LOSS_TRANSIENT" + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK" + else -> "AUDIOFOCUS_INVALID" + } + logger.d { "[onAudioFocusChange] focusChange: $typeOfChange" } + } + + private val _availableDevices = MutableStateFlow>(emptyList()) + public val availableDevices: StateFlow> = _availableDevices.asStateFlow() + + private val _selectedDeviceState = MutableStateFlow(null) + public val selectedDeviceState: StateFlow = _selectedDeviceState.asStateFlow() + + // Track previous device before Bluetooth was selected (for fallback on Bluetooth failure) + private var previousDeviceBeforeBluetooth: CustomAudioDevice? = null + + /** + * Starts monitoring audio devices and begins device enumeration. + * @param listener Callback that receives device updates + */ + public fun start(listener: CustomAudioDeviceChangeListener? = null) { + synchronized(this) { + if (isStarted) { + logger.w { "[start] AudioSwitch already started" } + return + } + + audioDeviceChangeListener = listener + isStarted = true + + logger.d { "[start] Starting AudioSwitch" } + + // Create appropriate device manager based on SDK version + deviceManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + logger.d { "[start] Using ModernAudioDeviceManager (API 31+)" } + ModernAudioDeviceManager(audioManager) + } else { + logger.d { "[start] Using LegacyAudioDeviceManager (API 24-30)" } + LegacyAudioDeviceManager( + context = context, + audioManager = audioManager, + onDeviceChange = { enumerateDevices() }, + onBluetoothConnectionFailure = { handleBluetoothConnectionFailure() }, + ) + } + + // Request audio focus for voice communication + requestAudioFocus() + + // Start the device manager + deviceManager?.start() + + // Register device callback (always available since minSdk is 24) + registerDeviceCallback() + + // Initial device enumeration + enumerateDevices() + } + } + + /** + * Stops monitoring audio devices and releases resources. + */ + public fun stop() { + synchronized(this) { + if (!isStarted) { + logger.w { "[stop] AudioSwitch not started" } + return + } + + logger.d { "[stop] Stopping AudioSwitch" } + + // Deactivate audio routing (matches Twilio behavior) + deactivate() + + // Abandon audio focus + abandonAudioFocus() + + // Unregister device callback + unregisterDeviceCallback() + + // Stop and clear device manager + deviceManager?.stop() + deviceManager = null + + audioDeviceChangeListener = null + isStarted = false + _availableDevices.value = emptyList() + _selectedDeviceState.value = null + previousDeviceBeforeBluetooth = null + } + } + + /** + * Selects an audio device for routing. + * @param device The device to select, or null for automatic selection + */ + public fun selectDevice(device: CustomAudioDevice?) { + selectCustomAudioDevice(device) + } + + /** + * Deactivates audio routing. + */ + public fun deactivate() { + synchronized(this) { + logger.d { "[deactivate] Deactivating audio routing" } + // Note: Audio focus is managed by WebRTC, not by device switching + } + } + + /** + * Gets the currently selected device. + */ + public fun getSelectedDevice(): CustomAudioDevice? = _selectedDeviceState.value + + /** + * Gets the list of available devices. + */ + public fun getAvailableDevices(): List = _availableDevices.value + + /** + * Selects a native audio device for routing. + * @param device The native device to select, or null for automatic selection + */ + public fun selectCustomAudioDevice(device: CustomAudioDevice?) { + synchronized(this) { + if (!isStarted) { + logger.w { "[selectCustomAudioDevice] AudioSwitch not started" } + return + } + + logger.i { "[selectCustomAudioDevice] Selecting native device: $device" } + + val deviceToSelect = device ?: selectCustomDeviceByPriority() + val currentSelected = _selectedDeviceState.value + + if (deviceToSelect != null) { + val manager = deviceManager + if (manager != null) { + // If switching to Bluetooth, save the previous device for fallback + if (deviceToSelect is CustomAudioDevice.BluetoothHeadset && + currentSelected != null && + currentSelected !is CustomAudioDevice.BluetoothHeadset + ) { + previousDeviceBeforeBluetooth = currentSelected + logger.d { + "[selectNativeDevice] Saving previous device before Bluetooth: $previousDeviceBeforeBluetooth" + } + } + + val success = manager.selectDevice(deviceToSelect) + if (success) { + _selectedDeviceState.value = deviceToSelect + logger.d { "[selectNativeDevice] Native device selected: $deviceToSelect" } + } else { + logger.w { "[selectNativeDevice] Failed to select native device: $deviceToSelect" } + // Fallback to automatic selection if the requested device is not available + val autoSelected = selectCustomDeviceByPriority() + if (autoSelected != null && autoSelected != deviceToSelect) { + selectCustomAudioDevice(autoSelected) + } + } + } else { + logger.w { "[selectNativeDevice] Device manager not initialized" } + } + } else { + logger.w { "[selectNativeDevice] No device available to select" } + deviceManager?.clearDevice() + _selectedDeviceState.value = null + } + } + } + + /** + * Selects a device by priority from available native devices. + */ + private fun selectCustomDeviceByPriority(): CustomAudioDevice? { + val availableDevices = _availableDevices.value + if (availableDevices.isEmpty()) { + return null + } + + // Select device based on preferredDeviceList priority + for (preferredType in preferredDeviceList) { + val device = availableDevices.find { preferredType.isInstance(it) } + if (device != null) { + return device + } + } + + // Fallback to first available device + return availableDevices.firstOrNull() + } + + private fun registerDeviceCallback() { + if (audioDeviceCallback != null) { + return + } + + audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + logger.d { "[onAudioDevicesAdded] Devices added: ${addedDevices.size}" } + enumerateDevices() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + logger.d { "[onAudioDevicesRemoved] Devices removed: ${removedDevices.size}" } + enumerateDevices() + } + } + + StreamAudioManager.registerAudioDeviceCallback( + audioManager, + audioDeviceCallback!!, + mainHandler, + ) + } + + private fun unregisterDeviceCallback() { + audioDeviceCallback?.let { callback -> + StreamAudioManager.unregisterAudioDeviceCallback(audioManager, callback) + audioDeviceCallback = null + } + } + + private fun enumerateDevices() { + // Enumerate devices using the device manager (which works with NativeStreamAudioDevice) + val manager = deviceManager + val nativeDevices = manager?.enumerateDevices() ?: emptyList() + + _availableDevices.value = nativeDevices + + // Update selected device + val currentSelected = _selectedDeviceState.value + if (currentSelected != null && !nativeDevices.contains(currentSelected)) { + logger.w { "[enumerateDevices] Selected device no longer available: $currentSelected" } + deviceManager?.clearDevice() + _selectedDeviceState.value = null + } else { + // Update from manager + val managerSelected = manager?.getSelectedDevice() + if (managerSelected != null) { + _selectedDeviceState.value = managerSelected + } + } + + // Notify listener + audioDeviceChangeListener?.invoke(nativeDevices, _selectedDeviceState.value) + } + + /** + * Handles Bluetooth connection failure by reverting to the previously selected device. + */ + private fun handleBluetoothConnectionFailure() { + logger.w { + "[handleBluetoothConnectionFailure] Bluetooth connection failed, reverting to previous device" + } + + val availableDevices = _availableDevices.value + + // First, try to revert to the device that was selected before Bluetooth + val previousDevice = previousDeviceBeforeBluetooth + if (previousDevice != null) { + if (availableDevices.contains(previousDevice)) { + logger.d { "[handleBluetoothConnectionFailure] Reverting to previous device: $previousDevice" } + previousDeviceBeforeBluetooth = null // Clear after use + selectCustomAudioDevice(previousDevice) + return + } else { + logger.d { + "[handleBluetoothConnectionFailure] Previous device no longer available: $previousDeviceBeforeBluetooth" + } + previousDeviceBeforeBluetooth = null + } + } + + // Fallback to automatic selection by priority if no previous device + logger.d { "[handleBluetoothConnectionFailure] No previous device, using automatic selection" } + val autoSelected = selectCustomDeviceByPriority() + if (autoSelected != null) { + selectCustomAudioDevice(autoSelected) + } + } + + /** + * Requests audio focus for audio routing. + * Uses AudioFocusRequest on API 26+ and legacy requestAudioFocus on older versions. + */ + private fun requestAudioFocus() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Use generic audio attributes - the actual usage will be set by AudioTrack + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes(audioAttributes) + .setAcceptsDelayedFocusGain(false) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + + val result = audioManager.requestAudioFocus(focusRequest) + audioFocusRequest = focusRequest + + logger.d { "[requestAudioFocus] Audio focus request result: $result" } + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + logger.d { "[requestAudioFocus] Audio focus granted" } + } else { + logger.w { "[requestAudioFocus] Audio focus not granted, result: $result" } + } + } else { + // Legacy API - uses stream type instead of usage + @Suppress("DEPRECATION") + val result = audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, + ) + logger.d { "[requestAudioFocus] Audio focus request result: $result" } + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + logger.d { "[requestAudioFocus] Audio focus granted" } + } else { + logger.w { "[requestAudioFocus] Audio focus not granted, result: $result" } + } + } + } catch (e: Exception) { + logger.e(e) { "[requestAudioFocus] Error requesting audio focus: ${e.message}" } + } + } + + /** + * Abandons audio focus. + */ + private fun abandonAudioFocus() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { request -> + val result = audioManager.abandonAudioFocusRequest(request) + logger.d { "[abandonAudioFocus] Audio focus abandoned, result: $result" } + audioFocusRequest = null + } + } else { + @Suppress("DEPRECATION") + val result = audioManager.abandonAudioFocus(audioFocusChangeListener) + logger.d { "[abandonAudioFocus] Audio focus abandoned, result: $result" } + } + } catch (e: Exception) { + logger.e(e) { "[abandonAudioFocus] Error abandoning audio focus: ${e.message}" } + } + } + + public companion object { + private const val TAG = "StreamAudioSwitch" + + /** + * Returns the default preferred device list matching Twilio's priority: + * BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone + */ + @JvmStatic + fun getDefaultPreferredDeviceList(): List> { + return listOf( + CustomAudioDevice.BluetoothHeadset::class.java, + CustomAudioDevice.WiredHeadset::class.java, + CustomAudioDevice.Earpiece::class.java, + CustomAudioDevice.Speakerphone::class.java, + ) + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt new file mode 100644 index 00000000000..11553580fd5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.content.Context +import android.os.Handler +import android.os.Looper +import io.getstream.log.taggedLogger + +/** + * Handler for audio device switching using our custom StreamAudioSwitch implementation. + * This is the new implementation that uses native Android AudioManager APIs. + * Uses NativeStreamAudioDevice for all device operations. + */ +internal class StreamAudioSwitchHandler( + private val context: Context, + private val preferredDeviceList: List>, + private var audioDeviceChangeListener: CustomAudioDeviceChangeListener, +) : AudioHandler { + + private val logger by taggedLogger(TAG) + private var streamAudioSwitch: StreamAudioSwitch? = null + private val mainThreadHandler = Handler(Looper.getMainLooper()) + private var isAudioSwitchInitScheduled = false + + override fun start() { + synchronized(this) { + if (!isAudioSwitchInitScheduled) { + isAudioSwitchInitScheduled = true + mainThreadHandler.removeCallbacksAndMessages(null) + + logger.d { "[start] Posting on main" } + + mainThreadHandler.post { + logger.d { "[start] Running on main" } + + val switch = StreamAudioSwitch( + context = context, + preferredDeviceList = preferredDeviceList, + ) + streamAudioSwitch = switch + switch.start(audioDeviceChangeListener) + } + } + } + } + + override fun stop() { + logger.d { "[stop] no args" } + mainThreadHandler.removeCallbacksAndMessages(null) + mainThreadHandler.post { + streamAudioSwitch?.stop() + streamAudioSwitch = null + } + } + + override fun selectDevice(audioDevice: StreamAudioDevice?) { + } + + override fun selectCustomAudioDevice(customAudioDevice: CustomAudioDevice?) { + logger.i { "[selectDevice] audioDevice: $customAudioDevice" } + streamAudioSwitch?.selectCustomAudioDevice(customAudioDevice) + } + + public companion object { + private const val TAG = "StreamAudioSwitchHandler" + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt index c46eff53e39..784093f4a1a 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt @@ -17,7 +17,6 @@ package io.getstream.video.android.core import android.media.AudioAttributes -import com.twilio.audioswitch.AudioDevice import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.mockk.every @@ -37,9 +36,8 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val audioDevice = mockk() - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker", audioDevice) - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece", audioDevice) + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") val devices = listOf(speakerDevice, earpieceDevice) val deviceSlot = slot() @@ -69,9 +67,8 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val audioDevice = mockk() - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker", audioDevice) - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece", audioDevice) + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") speakerManager.selectedBeforeSpeaker = earpieceDevice @@ -102,9 +99,8 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val audioDevice = mockk() - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker", audioDevice) - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece", audioDevice) + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") val devices = listOf(speakerDevice, earpieceDevice) speakerManager.selectedBeforeSpeaker = null @@ -135,10 +131,9 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val audioDevice = mockk() - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker", audioDevice) - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece", audioDevice) - val wiredHeadsetDevice = StreamAudioDevice.WiredHeadset("test-wired", audioDevice) + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") + val wiredHeadsetDevice = StreamAudioDevice.WiredHeadset("test-wired") val devices = listOf(speakerDevice, earpieceDevice, wiredHeadsetDevice) val deviceSlot = slot() @@ -167,9 +162,8 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val audioDevice = mockk() - val speakerDevice1 = StreamAudioDevice.Speakerphone("test-speaker-1", audioDevice) - val speakerDevice2 = StreamAudioDevice.Speakerphone("test-speaker-2", audioDevice) + val speakerDevice1 = StreamAudioDevice.Speakerphone("test-speaker-1") + val speakerDevice2 = StreamAudioDevice.Speakerphone("test-speaker-2") // Only speaker devices are available val devices = listOf(speakerDevice1, speakerDevice2) From 41173105d416f7a54333e7ef02fa847fb91f1b40 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Mon, 15 Dec 2025 16:41:57 +0530 Subject: [PATCH 02/15] fix unit tests --- .../io/getstream/video/android/core/MicrophoneManagerTest.kt | 3 ++- .../io/getstream/video/android/core/SpeakerManagerTest.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index 44007a375f7..451222742b4 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -21,6 +21,7 @@ import android.media.AudioAttributes import android.media.AudioManager import io.getstream.android.video.generated.models.OwnCapability import io.getstream.video.android.core.audio.AudioSwitchHandler +import io.getstream.video.android.core.audio.StreamAudioDevice import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -70,7 +71,7 @@ class MicrophoneManagerTest { // When microphoneManager.enable() // 1 - microphoneManager.select(null) // 0 + microphoneManager.select(null as StreamAudioDevice?) // 0 microphoneManager.resume() // 2, 3, Resume calls enable internally, thus two invocations microphoneManager.disable() // 4 microphoneManager.pause() // 5 diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt index 784093f4a1a..6f1b50dd644 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt @@ -187,7 +187,7 @@ class SpeakerManagerTest { // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } // Since we only have speakers available, verify we selected the first one - verify { microphoneManager.select(any()) } // Verify the select method was called + verify { microphoneManager.select(any()) } // Verify the select method was called assertEquals(false, speakerManager.speakerPhoneEnabled.value) } From 4c6b87abfea2da20eb44ea038f2670befa46ddfb Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Wed, 17 Dec 2025 15:59:17 +0530 Subject: [PATCH 03/15] fix unit tests --- .../android/core/MicrophoneManagerTest.kt | 4 +- .../video/android/core/SpeakerManagerTest.kt | 74 ++++++++++--------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index 451222742b4..7b68fcea5aa 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -21,7 +21,7 @@ import android.media.AudioAttributes import android.media.AudioManager import io.getstream.android.video.generated.models.OwnCapability import io.getstream.video.android.core.audio.AudioSwitchHandler -import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.audio.CustomAudioDevice import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -71,7 +71,7 @@ class MicrophoneManagerTest { // When microphoneManager.enable() // 1 - microphoneManager.select(null as StreamAudioDevice?) // 0 + microphoneManager.select(null as CustomAudioDevice?) // 0 microphoneManager.resume() // 2, 3, Resume calls enable internally, thus two invocations microphoneManager.disable() // 4 microphoneManager.pause() // 5 diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt index 6f1b50dd644..ae4a5073207 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt @@ -17,7 +17,7 @@ package io.getstream.video.android.core import android.media.AudioAttributes -import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.audio.CustomAudioDevice import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.mockk.every import io.mockk.mockk @@ -36,27 +36,27 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") + val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") val devices = listOf(speakerDevice, earpieceDevice) - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.devices.value } returns devices - every { microphoneManager.selectedDevice.value } returns earpieceDevice + every { microphoneManager.customAudioDevices.value } returns devices + every { microphoneManager.selectedCustomAudioDevice.value } returns earpieceDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(true) + speakerManager.setSpeakerPhone(true, null as CustomAudioDevice?) // Then verify { microphoneManager.enforceSetup(preferSpeaker = true, any()) } assertEquals(speakerDevice, deviceSlot.captured) - assertEquals(earpieceDevice, speakerManager.selectedBeforeSpeaker) + assertEquals(earpieceDevice, speakerManager.selectedBeforeSpeakerCustomAudioDevice) assertEquals(true, speakerManager.speakerPhoneEnabled.value) } @@ -67,24 +67,24 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") + val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") - speakerManager.selectedBeforeSpeaker = earpieceDevice + speakerManager.selectedBeforeSpeakerCustomAudioDevice = earpieceDevice - val deviceSlot = slot() + val deviceSlot = slot() val devices = listOf(speakerDevice, earpieceDevice) // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.devices.value } returns devices - every { microphoneManager.selectedDevice.value } returns speakerDevice + every { microphoneManager.customAudioDevices.value } returns devices + every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false) + speakerManager.setSpeakerPhone(false, null as CustomAudioDevice?) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } @@ -99,24 +99,24 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") + val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") val devices = listOf(speakerDevice, earpieceDevice) speakerManager.selectedBeforeSpeaker = null - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.devices.value } returns devices - every { microphoneManager.selectedDevice.value } returns speakerDevice + every { microphoneManager.customAudioDevices.value } returns devices + every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false) + speakerManager.setSpeakerPhone(false, null as CustomAudioDevice?) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } @@ -131,19 +131,19 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") - val wiredHeadsetDevice = StreamAudioDevice.WiredHeadset("test-wired") + val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") + val wiredHeadsetDevice = CustomAudioDevice.WiredHeadset("test-wired") val devices = listOf(speakerDevice, earpieceDevice, wiredHeadsetDevice) - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.devices.value } returns devices - every { microphoneManager.selectedDevice.value } returns speakerDevice + every { microphoneManager.customAudioDevices.value } returns devices + every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When @@ -162,32 +162,36 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice1 = StreamAudioDevice.Speakerphone("test-speaker-1") - val speakerDevice2 = StreamAudioDevice.Speakerphone("test-speaker-2") + val speakerDevice1 = CustomAudioDevice.Speakerphone("test-speaker-1") + val speakerDevice2 = CustomAudioDevice.Speakerphone("test-speaker-2") // Only speaker devices are available val devices = listOf(speakerDevice1, speakerDevice2) // No previously selected device - speakerManager.selectedBeforeSpeaker = null + speakerManager.selectedBeforeSpeakerCustomAudioDevice = null - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.devices.value } returns devices - every { microphoneManager.selectedDevice.value } returns speakerDevice1 + every { microphoneManager.customAudioDevices.value } returns devices + every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice1 every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false) + speakerManager.setSpeakerPhone(false, null as CustomAudioDevice?) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } // Since we only have speakers available, verify we selected the first one - verify { microphoneManager.select(any()) } // Verify the select method was called + verify { + microphoneManager.select( + any(), + ) + } // Verify the select method was called assertEquals(false, speakerManager.speakerPhoneEnabled.value) } From 4b0850e45db31ef54603cd5026101323cbbddc24 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Wed, 17 Dec 2025 15:59:36 +0530 Subject: [PATCH 04/15] Add missing APIs for CustomAudioDevice --- .../video/android/core/MediaManager.kt | 109 ++++++++++++++---- 1 file changed, 87 insertions(+), 22 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index a3cd055da00..7b64fde8b69 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -122,19 +122,28 @@ class SpeakerManager( val speakerPhoneEnabled: StateFlow = _speakerPhoneEnabled internal var selectedBeforeSpeaker: StreamAudioDevice? = null + internal var selectedBeforeSpeakerCustomAudioDevice: CustomAudioDevice? = null internal fun enable(fromUser: Boolean = true) { if (fromUser) { _status.value = DeviceStatus.Enabled } - setSpeakerPhone(true) + if (mediaManager.useCustomAudioSwitch) { + setSpeakerPhone(true, null as CustomAudioDevice?) + } else { + setSpeakerPhone(true, null as StreamAudioDevice?) + } } fun disable(fromUser: Boolean = true) { if (fromUser) { _status.value = DeviceStatus.Disabled } - setSpeakerPhone(false) + if (mediaManager.useCustomAudioSwitch) { + setSpeakerPhone(false, null as CustomAudioDevice?) + } else { + setSpeakerPhone(false, null as StreamAudioDevice?) + } } /** @@ -202,6 +211,58 @@ class SpeakerManager( } } + /** + * Enables or disables the speakerphone. + * + * When the speaker is disabled the device that gets selected next is by default the first device + * that is NOT a speakerphone. To override this use [defaultFallback]. + * If you want the earpice to be selected if the speakerphone is disabled do + * ```kotlin + * setSpeakerPhone(enable, CustomAudioDevice.Earpiece) + * ``` + * + * @param enable if true, enables the speakerphone, if false disables it and selects another device. + * @param defaultFallback when [enable] is false this is used to select the next device after the speaker. + * */ + fun setSpeakerPhone(enable: Boolean, defaultFallback: CustomAudioDevice? = null) { + microphoneManager.enforceSetup(preferSpeaker = enable) { + val devices = microphoneManager.customAudioDevices.value + if (enable) { + val speaker = + devices.filterIsInstance().firstOrNull() + selectedBeforeSpeakerCustomAudioDevice = microphoneManager.selectedCustomAudioDevice.value.takeUnless { + it is CustomAudioDevice.Speakerphone + } ?: devices.firstOrNull { + it !is CustomAudioDevice.Speakerphone + } + + logger.d { "#deviceDebug; selectedBeforeSpeakerCustomAudioDevice: $selectedBeforeSpeakerCustomAudioDevice" } + + _speakerPhoneEnabled.value = true + microphoneManager.select(speaker) + } else { + _speakerPhoneEnabled.value = false + // swap back to the old one + val defaultFallbackFromType = defaultFallback?.let { + devices.filterIsInstance(defaultFallback::class.java) + }?.firstOrNull() + + val firstNonSpeaker = devices.firstOrNull { it !is CustomAudioDevice.Speakerphone } + + val fallback: CustomAudioDevice? = when { + defaultFallbackFromType != null -> defaultFallbackFromType + selectedBeforeSpeakerCustomAudioDevice != null && + selectedBeforeSpeakerCustomAudioDevice !is CustomAudioDevice.Speakerphone && + devices.contains(selectedBeforeSpeakerCustomAudioDevice) -> selectedBeforeSpeakerCustomAudioDevice + + else -> firstNonSpeaker + } + + microphoneManager.select(fallback) + } + } + } + /** * Set the volume as a percentage, 0-100 */ @@ -644,19 +705,21 @@ class MicrophoneManager( */ fun select(device: StreamAudioDevice?) { logger.i { "selecting device $device" } - ifAudioHandlerInitialized { it.selectDevice(device) } - _selectedDevice.value = device + enforceSetup { + ifAudioHandlerInitialized { it.selectDevice(device) } + _selectedDevice.value = device - if (device !is StreamAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { - mediaManager.speaker._status.value = DeviceStatus.Disabled - } + if (device !is StreamAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { + mediaManager.speaker._status.value = DeviceStatus.Disabled + } - if (device is StreamAudioDevice.Speakerphone) { - mediaManager.speaker._status.value = DeviceStatus.Enabled - } + if (device is StreamAudioDevice.Speakerphone) { + mediaManager.speaker._status.value = DeviceStatus.Enabled + } - if (device !is StreamAudioDevice.BluetoothHeadset && device !is StreamAudioDevice.WiredHeadset) { - nonHeadsetFallbackDevice = device + if (device !is StreamAudioDevice.BluetoothHeadset && device !is StreamAudioDevice.WiredHeadset) { + nonHeadsetFallbackDevice = device + } } } @@ -665,19 +728,21 @@ class MicrophoneManager( */ fun select(device: CustomAudioDevice?) { logger.i { "selecting device $device" } - ifAudioHandlerInitialized { it.selectCustomAudioDevice(device) } - _selectedNativeDevice.value = device + enforceSetup { + ifAudioHandlerInitialized { it.selectCustomAudioDevice(device) } + _selectedNativeDevice.value = device - if (device !is CustomAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { - mediaManager.speaker._status.value = DeviceStatus.Disabled - } + if (device !is CustomAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { + mediaManager.speaker._status.value = DeviceStatus.Disabled + } - if (device is CustomAudioDevice.Speakerphone) { - mediaManager.speaker._status.value = DeviceStatus.Enabled - } + if (device is CustomAudioDevice.Speakerphone) { + mediaManager.speaker._status.value = DeviceStatus.Enabled + } - if (device !is CustomAudioDevice.BluetoothHeadset && device !is CustomAudioDevice.WiredHeadset) { - nonHeadsetFallbackCustomeAudioDevice = device + if (device !is CustomAudioDevice.BluetoothHeadset && device !is CustomAudioDevice.WiredHeadset) { + nonHeadsetFallbackCustomeAudioDevice = device + } } } From 7a9db526e4e97ee29a211d2a8e48b46c042d03ba Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Wed, 17 Dec 2025 15:59:53 +0530 Subject: [PATCH 05/15] Remove un-necessary logs --- .../android/core/audio/StreamAudioDevice.kt | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt index 04241173153..760eab30689 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt @@ -23,8 +23,8 @@ import kotlin.ReplaceWith /** * Represents an audio device for Twilio's AudioSwitch implementation. * - * @deprecated This class is deprecated. Use [AudioDevice] when [useCustomAudioSwitch] is true. - * This class will be removed in a future version. For new code, use [AudioDevice] instead. + * @deprecated This class is deprecated. Use [CustomAudioDevice] when [useCustomAudioSwitch] is true. + * This class will be removed in a future version. For new code, use [CustomAudioDevice] instead. * * @see AudioDevice */ @@ -32,65 +32,39 @@ import kotlin.ReplaceWith message = "StreamAudioDevice is deprecated. Use NativeStreamAudioDevice when useCustomAudioSwitch is true. " + "This class is kept for backward compatibility with Twilio's AudioSwitch.", replaceWith = ReplaceWith( - "NativeStreamAudioDevice", - "io.getstream.video.android.core.audio.NativeStreamAudioDevice", + "CustomAudioDevice", + "io.getstream.video.android.core.audio.CustomAudioDevice", ), level = DeprecationLevel.WARNING, ) -public sealed class StreamAudioDevice { +sealed class StreamAudioDevice { /** The friendly name of the device.*/ abstract val name: String - /** - * The Twilio AudioDevice instance. - * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. - * For custom audio switch implementation, use [AudioDevice] instead. - * @see com.twilio.audioswitch.AudioDevice - */ abstract val audio: AudioDevice /** An [StreamAudioDevice] representing a Bluetooth Headset.*/ data class BluetoothHeadset constructor( override val name: String = "Bluetooth", - /** - * The Twilio AudioDevice instance. - * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. - * @see com.twilio.audioswitch.AudioDevice - */ override val audio: AudioDevice, ) : StreamAudioDevice() /** An [StreamAudioDevice] representing a Wired Headset.*/ data class WiredHeadset constructor( override val name: String = "Wired Headset", - /** - * The Twilio AudioDevice instance. - * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. - * @see com.twilio.audioswitch.AudioDevice - */ override val audio: AudioDevice, ) : StreamAudioDevice() /** An [StreamAudioDevice] representing the Earpiece.*/ data class Earpiece constructor( override val name: String = "Earpiece", - /** - * The Twilio AudioDevice instance. - * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. - * @see com.twilio.audioswitch.AudioDevice - */ override val audio: AudioDevice, ) : StreamAudioDevice() /** An [StreamAudioDevice] representing the Speakerphone.*/ data class Speakerphone constructor( override val name: String = "Speakerphone", - /** - * The Twilio AudioDevice instance. - * This is always non-null since StreamAudioDevice is only used with Twilio's AudioSwitch. - * @see com.twilio.audioswitch.AudioDevice - */ override val audio: AudioDevice, ) : StreamAudioDevice() From 1dceba794a045788e97cce468041a4c4e9cf8cf4 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Wed, 17 Dec 2025 16:04:11 +0530 Subject: [PATCH 06/15] spotless apply and api dump --- stream-video-android-core/api/stream-video-android-core.api | 2 ++ .../kotlin/io/getstream/video/android/core/MediaManager.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 603090de608..e3e14069d3b 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8397,7 +8397,9 @@ public final class io/getstream/video/android/core/SpeakerManager { public final fun setAudioUsage (I)Z public final fun setEnabled (ZZ)V public static synthetic fun setEnabled$default (Lio/getstream/video/android/core/SpeakerManager;ZZILjava/lang/Object;)V + public final fun setSpeakerPhone (ZLio/getstream/video/android/core/audio/CustomAudioDevice;)V public final fun setSpeakerPhone (ZLio/getstream/video/android/core/audio/StreamAudioDevice;)V + public static synthetic fun setSpeakerPhone$default (Lio/getstream/video/android/core/SpeakerManager;ZLio/getstream/video/android/core/audio/CustomAudioDevice;ILjava/lang/Object;)V public static synthetic fun setSpeakerPhone$default (Lio/getstream/video/android/core/SpeakerManager;ZLio/getstream/video/android/core/audio/StreamAudioDevice;ILjava/lang/Object;)V public final fun setVolume (I)V } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index 7b64fde8b69..bec103f5337 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -236,7 +236,9 @@ class SpeakerManager( it !is CustomAudioDevice.Speakerphone } - logger.d { "#deviceDebug; selectedBeforeSpeakerCustomAudioDevice: $selectedBeforeSpeakerCustomAudioDevice" } + logger.d { + "#deviceDebug; selectedBeforeSpeakerCustomAudioDevice: $selectedBeforeSpeakerCustomAudioDevice" + } _speakerPhoneEnabled.value = true microphoneManager.select(speaker) From 4470d33848a89056283eb99f26a1fa0c8b91684e Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Mon, 22 Dec 2025 11:43:08 +0530 Subject: [PATCH 07/15] Removed the extra enforcing of setup done in selecting device --- .../video/android/core/MediaManager.kt | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index bec103f5337..76c30ef308d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -707,21 +707,19 @@ class MicrophoneManager( */ fun select(device: StreamAudioDevice?) { logger.i { "selecting device $device" } - enforceSetup { - ifAudioHandlerInitialized { it.selectDevice(device) } - _selectedDevice.value = device + ifAudioHandlerInitialized { it.selectDevice(device) } + _selectedDevice.value = device - if (device !is StreamAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { - mediaManager.speaker._status.value = DeviceStatus.Disabled - } + if (device !is StreamAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { + mediaManager.speaker._status.value = DeviceStatus.Disabled + } - if (device is StreamAudioDevice.Speakerphone) { - mediaManager.speaker._status.value = DeviceStatus.Enabled - } + if (device is StreamAudioDevice.Speakerphone) { + mediaManager.speaker._status.value = DeviceStatus.Enabled + } - if (device !is StreamAudioDevice.BluetoothHeadset && device !is StreamAudioDevice.WiredHeadset) { - nonHeadsetFallbackDevice = device - } + if (device !is StreamAudioDevice.BluetoothHeadset && device !is StreamAudioDevice.WiredHeadset) { + nonHeadsetFallbackDevice = device } } @@ -730,21 +728,19 @@ class MicrophoneManager( */ fun select(device: CustomAudioDevice?) { logger.i { "selecting device $device" } - enforceSetup { - ifAudioHandlerInitialized { it.selectCustomAudioDevice(device) } - _selectedNativeDevice.value = device + ifAudioHandlerInitialized { it.selectCustomAudioDevice(device) } + _selectedNativeDevice.value = device - if (device !is CustomAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { - mediaManager.speaker._status.value = DeviceStatus.Disabled - } + if (device !is CustomAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { + mediaManager.speaker._status.value = DeviceStatus.Disabled + } - if (device is CustomAudioDevice.Speakerphone) { - mediaManager.speaker._status.value = DeviceStatus.Enabled - } + if (device is CustomAudioDevice.Speakerphone) { + mediaManager.speaker._status.value = DeviceStatus.Enabled + } - if (device !is CustomAudioDevice.BluetoothHeadset && device !is CustomAudioDevice.WiredHeadset) { - nonHeadsetFallbackCustomeAudioDevice = device - } + if (device !is CustomAudioDevice.BluetoothHeadset && device !is CustomAudioDevice.WiredHeadset) { + nonHeadsetFallbackCustomeAudioDevice = device } } From 68729cd046bc50117c7a40f5b06b9c60ff32e40b Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Mon, 22 Dec 2025 11:45:30 +0530 Subject: [PATCH 08/15] Fix test comment --- .../io/getstream/video/android/core/MicrophoneManagerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index 7b68fcea5aa..f510c3976ac 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -80,7 +80,7 @@ class MicrophoneManagerTest { // Then verify(exactly = 9) { - // Setup will be called exactly 10 times + // Setup will be called exactly 9 times microphoneManager.setup(any(), any()) } } From 9285dc95402635e93f2e4167163932b0fbbc7fd2 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 23 Dec 2025 14:02:26 +0530 Subject: [PATCH 09/15] Remove customAudioDevice and use StreamAudioDevice --- .../android/ui/menu/AudioDeviceUiState.kt | 4 +- .../video/android/ui/menu/MenuDefinitions.kt | 6 +- .../video/android/ui/menu/SettingsMenu.kt | 38 +-- .../android/util/StreamVideoInitHelper.kt | 2 +- .../api/stream-video-android-core.api | 2 +- .../io/getstream/video/android/core/Call.kt | 23 +- .../video/android/core/MediaManager.kt | 129 +------- .../video/android/core/StreamVideoBuilder.kt | 4 +- .../video/android/core/StreamVideoClient.kt | 2 +- .../android/core/audio/AudioDeviceManager.kt | 8 +- .../video/android/core/audio/AudioHandler.kt | 10 - .../android/core/audio/AudioHandlerFactory.kt | 41 +-- .../android/core/audio/CustomAudioDevice.kt | 181 ------------ .../core/audio/LegacyAudioDeviceManager.kt | 62 ++-- .../core/audio/ModernAudioDeviceManager.kt | 36 +-- .../android/core/audio/StreamAudioDevice.kt | 275 +++++++++++++++--- .../android/core/audio/StreamAudioSwitch.kt | 66 ++--- .../core/audio/StreamAudioSwitchHandler.kt | 10 +- .../android/core/MicrophoneManagerTest.kt | 3 +- .../video/android/core/SpeakerManagerTest.kt | 70 ++--- 20 files changed, 423 insertions(+), 549 deletions(-) delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt index 6f8f8cc32ce..0dddfdf576d 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/AudioDeviceUiState.kt @@ -17,10 +17,10 @@ package io.getstream.video.android.ui.menu import androidx.compose.ui.graphics.vector.ImageVector -import io.getstream.video.android.core.audio.CustomAudioDevice +import io.getstream.video.android.core.audio.StreamAudioDevice data class AudioDeviceUiState( - val streamAudioDevice: CustomAudioDevice, + val streamAudioDevice: StreamAudioDevice, val text: String, val icon: ImageVector, // Assuming it's a drawable resource ID val highlight: Boolean, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt index b974f7a89b6..4e6552ee840 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt @@ -44,7 +44,7 @@ import androidx.compose.material.icons.filled.VideoSettings import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import io.getstream.video.android.compose.ui.components.video.VideoScalingType -import io.getstream.video.android.core.audio.CustomAudioDevice +import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState import io.getstream.video.android.ui.menu.base.ActionMenuItem @@ -75,11 +75,11 @@ fun defaultStreamMenu( onSelectIncomingVideoResolution: (PreferredVideoResolution?) -> Unit, isIncomingVideoEnabled: Boolean, onToggleIncomingVideoEnabled: (Boolean) -> Unit, - onDeviceSelected: (CustomAudioDevice) -> Unit, + onDeviceSelected: (StreamAudioDevice) -> Unit, onSfuRejoinClick: () -> Unit, onSfuFastReconnectClick: () -> Unit, onSelectScaleType: (VideoScalingType) -> Unit, - availableDevices: List, + availableDevices: List, loadRecordings: suspend () -> List, transcriptionUiState: TranscriptionUiState, onToggleTranscription: suspend () -> Unit, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt index ac99aa8f996..e2072bf64a1 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt @@ -58,7 +58,7 @@ import com.google.accompanist.permissions.rememberPermissionState import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.ui.components.video.VideoScalingType import io.getstream.video.android.core.Call -import io.getstream.video.android.core.audio.CustomAudioDevice +import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.mapper.ReactionMapper import io.getstream.video.android.core.model.PreferredVideoResolution @@ -94,7 +94,7 @@ internal fun SettingsMenu( onClosedCaptionsToggle: () -> Unit, ) { val context = LocalContext.current - val availableDevices by call.microphone.customAudioDevices.collectAsStateWithLifecycle() + val availableDevices by call.microphone.devices.collectAsStateWithLifecycle() val currentAudioUsage by call.speaker.audioUsage.collectAsStateWithLifecycle() val audioUsageUiState = remember(currentAudioUsage) { @@ -208,13 +208,13 @@ internal fun SettingsMenu( } } - val selectedMicroPhoneDevice by call.microphone.selectedCustomAudioDevice.collectAsStateWithLifecycle() + val selectedMicroPhoneDevice by call.microphone.selectedDevice.collectAsStateWithLifecycle() val audioDeviceUiStateList: List = availableDevices.map { val icon = when (it) { - is CustomAudioDevice.BluetoothHeadset -> Icons.Default.BluetoothAudio - is CustomAudioDevice.Earpiece -> Icons.Default.Headphones - is CustomAudioDevice.Speakerphone -> Icons.Default.SpeakerPhone - is CustomAudioDevice.WiredHeadset -> Icons.Default.HeadsetMic + is StreamAudioDevice.BluetoothHeadset -> Icons.Default.BluetoothAudio + is StreamAudioDevice.Earpiece -> Icons.Default.Headphones + is StreamAudioDevice.Speakerphone -> Icons.Default.SpeakerPhone + is StreamAudioDevice.WiredHeadset -> Icons.Default.HeadsetMic } // Compare devices by type and audioDeviceInfo ID (if available) since audio can be null when using custom audio switch val selected = selectedMicroPhoneDevice @@ -223,16 +223,16 @@ internal fun SettingsMenu( else -> { // First try to compare by audioDeviceInfo ID if both have it val itInfoId = when (it) { - is CustomAudioDevice.BluetoothHeadset -> it.audioDeviceInfo?.id - is CustomAudioDevice.WiredHeadset -> it.audioDeviceInfo?.id - is CustomAudioDevice.Earpiece -> it.audioDeviceInfo?.id - is CustomAudioDevice.Speakerphone -> it.audioDeviceInfo?.id + is StreamAudioDevice.BluetoothHeadset -> it.audioDeviceInfo?.id + is StreamAudioDevice.WiredHeadset -> it.audioDeviceInfo?.id + is StreamAudioDevice.Earpiece -> it.audioDeviceInfo?.id + is StreamAudioDevice.Speakerphone -> it.audioDeviceInfo?.id } val selectedInfoId = when (selected) { - is CustomAudioDevice.BluetoothHeadset -> selected.audioDeviceInfo?.id - is CustomAudioDevice.WiredHeadset -> selected.audioDeviceInfo?.id - is CustomAudioDevice.Earpiece -> selected.audioDeviceInfo?.id - is CustomAudioDevice.Speakerphone -> selected.audioDeviceInfo?.id + is StreamAudioDevice.BluetoothHeadset -> selected.audioDeviceInfo?.id + is StreamAudioDevice.WiredHeadset -> selected.audioDeviceInfo?.id + is StreamAudioDevice.Earpiece -> selected.audioDeviceInfo?.id + is StreamAudioDevice.Speakerphone -> selected.audioDeviceInfo?.id } if (itInfoId != null && selectedInfoId != null) { @@ -241,10 +241,10 @@ internal fun SettingsMenu( } else { // Fall back to type comparison when { - it is CustomAudioDevice.BluetoothHeadset && selected is CustomAudioDevice.BluetoothHeadset -> true - it is CustomAudioDevice.WiredHeadset && selected is CustomAudioDevice.WiredHeadset -> true - it is CustomAudioDevice.Earpiece && selected is CustomAudioDevice.Earpiece -> true - it is CustomAudioDevice.Speakerphone && selected is CustomAudioDevice.Speakerphone -> true + it is StreamAudioDevice.BluetoothHeadset && selected is StreamAudioDevice.BluetoothHeadset -> true + it is StreamAudioDevice.WiredHeadset && selected is StreamAudioDevice.WiredHeadset -> true + it is StreamAudioDevice.Earpiece && selected is StreamAudioDevice.Earpiece -> true + it is StreamAudioDevice.Speakerphone && selected is StreamAudioDevice.Speakerphone -> true else -> false } } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index 37bc5c2775a..e5780a33b65 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -335,7 +335,7 @@ object StreamVideoInitHelper { telecomConfig = TelecomConfig( context.packageName, ), - useCustomAudioSwitch = true, + useInBuiltAudioSwitch = true, ).build() } } diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 1efea57f31f..7829907b3de 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8032,7 +8032,7 @@ public final class io/getstream/video/android/core/MediaManagerImpl { public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; public final fun getScreenShareTrack ()Lorg/webrtc/VideoTrack; public final fun getScreenShareVideoSource ()Lorg/webrtc/VideoSource; - public final fun getUseCustomAudioSwitch ()Z + public final fun getuseInBuiltAudioSwitch ()Z public final fun getVideoSource ()Lorg/webrtc/VideoSource; public final fun getVideoTrack ()Lorg/webrtc/VideoTrack; public final fun setAudioTrack (Lorg/webrtc/AudioTrack;)V diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index af7d33446d2..00d749373cf 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -348,7 +348,7 @@ public class Call( eglBaseContext = eglBase.eglBaseContext, audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, - useCustomAudioSwitch = clientImpl.useCustomAudioSwitch, + useInBuiltAudioSwitch = clientImpl.useInBuiltAudioSwitch, ) } } @@ -1354,11 +1354,7 @@ public class Call( microphone.devices.onEach { availableDevices -> logger.d { "[monitorHeadset] new available devices, prev selected: ${ - if (clientImpl.useCustomAudioSwitch) { - microphone.nonHeadsetFallbackCustomeAudioDevice - } else { - microphone.nonHeadsetFallbackDevice - } + microphone.nonHeadsetFallbackDevice }" } @@ -1375,17 +1371,10 @@ public class Call( } else { logger.d { "[monitorHeadset] no headset found" } - if (clientImpl.useCustomAudioSwitch) { - microphone.nonHeadsetFallbackCustomeAudioDevice?.let { deviceBeforeHeadset -> - logger.d { "[monitorHeadset] before device selected" } - microphone.select(deviceBeforeHeadset) - } - } else { - microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> - logger.d { "[monitorHeadset] before device selected" } - microphone.select(deviceBeforeHeadset) - } - } + microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> + logger.d { "[monitorHeadset] before device selected" } + microphone.select(deviceBeforeHeadset) + } } }.launchIn(scope) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index 76c30ef308d..2eef17b07a8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -41,7 +41,6 @@ import io.getstream.log.taggedLogger import io.getstream.result.extractCause import io.getstream.video.android.core.audio.AudioHandler import io.getstream.video.android.core.audio.AudioHandlerFactory -import io.getstream.video.android.core.audio.CustomAudioDevice import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.audio.StreamAudioDevice.Companion.fromAudio import io.getstream.video.android.core.call.video.FilterVideoProcessor @@ -122,28 +121,19 @@ class SpeakerManager( val speakerPhoneEnabled: StateFlow = _speakerPhoneEnabled internal var selectedBeforeSpeaker: StreamAudioDevice? = null - internal var selectedBeforeSpeakerCustomAudioDevice: CustomAudioDevice? = null internal fun enable(fromUser: Boolean = true) { if (fromUser) { _status.value = DeviceStatus.Enabled } - if (mediaManager.useCustomAudioSwitch) { - setSpeakerPhone(true, null as CustomAudioDevice?) - } else { - setSpeakerPhone(true, null as StreamAudioDevice?) - } + setSpeakerPhone(true) } fun disable(fromUser: Boolean = true) { if (fromUser) { _status.value = DeviceStatus.Disabled } - if (mediaManager.useCustomAudioSwitch) { - setSpeakerPhone(false, null as CustomAudioDevice?) - } else { - setSpeakerPhone(false, null as StreamAudioDevice?) - } + setSpeakerPhone(false) } /** @@ -211,60 +201,6 @@ class SpeakerManager( } } - /** - * Enables or disables the speakerphone. - * - * When the speaker is disabled the device that gets selected next is by default the first device - * that is NOT a speakerphone. To override this use [defaultFallback]. - * If you want the earpice to be selected if the speakerphone is disabled do - * ```kotlin - * setSpeakerPhone(enable, CustomAudioDevice.Earpiece) - * ``` - * - * @param enable if true, enables the speakerphone, if false disables it and selects another device. - * @param defaultFallback when [enable] is false this is used to select the next device after the speaker. - * */ - fun setSpeakerPhone(enable: Boolean, defaultFallback: CustomAudioDevice? = null) { - microphoneManager.enforceSetup(preferSpeaker = enable) { - val devices = microphoneManager.customAudioDevices.value - if (enable) { - val speaker = - devices.filterIsInstance().firstOrNull() - selectedBeforeSpeakerCustomAudioDevice = microphoneManager.selectedCustomAudioDevice.value.takeUnless { - it is CustomAudioDevice.Speakerphone - } ?: devices.firstOrNull { - it !is CustomAudioDevice.Speakerphone - } - - logger.d { - "#deviceDebug; selectedBeforeSpeakerCustomAudioDevice: $selectedBeforeSpeakerCustomAudioDevice" - } - - _speakerPhoneEnabled.value = true - microphoneManager.select(speaker) - } else { - _speakerPhoneEnabled.value = false - // swap back to the old one - val defaultFallbackFromType = defaultFallback?.let { - devices.filterIsInstance(defaultFallback::class.java) - }?.firstOrNull() - - val firstNonSpeaker = devices.firstOrNull { it !is CustomAudioDevice.Speakerphone } - - val fallback: CustomAudioDevice? = when { - defaultFallbackFromType != null -> defaultFallbackFromType - selectedBeforeSpeakerCustomAudioDevice != null && - selectedBeforeSpeakerCustomAudioDevice !is CustomAudioDevice.Speakerphone && - devices.contains(selectedBeforeSpeakerCustomAudioDevice) -> selectedBeforeSpeakerCustomAudioDevice - - else -> firstNonSpeaker - } - - microphoneManager.select(fallback) - } - } - } - /** * Set the volume as a percentage, 0-100 */ @@ -621,26 +557,16 @@ class MicrophoneManager( public val isEnabled: StateFlow = _status.mapState { it is DeviceStatus.Enabled } private val _selectedDevice = MutableStateFlow(null) - private val _selectedNativeDevice = MutableStateFlow(null) - internal var nonHeadsetFallbackDevice: StreamAudioDevice? = null - internal var nonHeadsetFallbackCustomeAudioDevice: CustomAudioDevice? = null - /** Currently selected device */ val selectedDevice: StateFlow = _selectedDevice - val selectedCustomAudioDevice: StateFlow = _selectedNativeDevice private val _devices = MutableStateFlow>(emptyList()) /** List of available devices. */ val devices: StateFlow> = _devices - private val _nativeDevices = MutableStateFlow>(emptyList()) - - /** List of available devices. */ - val customAudioDevices: StateFlow> = _nativeDevices - private val _audioBitrateProfile = MutableStateFlow( AudioBitrateProfile.AUDIO_BITRATE_PROFILE_VOICE_STANDARD_UNSPECIFIED, @@ -723,27 +649,6 @@ class MicrophoneManager( } } - /** - * Select a specific device - */ - fun select(device: CustomAudioDevice?) { - logger.i { "selecting device $device" } - ifAudioHandlerInitialized { it.selectCustomAudioDevice(device) } - _selectedNativeDevice.value = device - - if (device !is CustomAudioDevice.Speakerphone && mediaManager.speaker.isEnabled.value == true) { - mediaManager.speaker._status.value = DeviceStatus.Disabled - } - - if (device is CustomAudioDevice.Speakerphone) { - mediaManager.speaker._status.value = DeviceStatus.Enabled - } - - if (device !is CustomAudioDevice.BluetoothHeadset && device !is CustomAudioDevice.WiredHeadset) { - nonHeadsetFallbackCustomeAudioDevice = device - } - } - /** * List the devices, returns a stateflow with audio devices */ @@ -849,26 +754,10 @@ class MicrophoneManager( ) } - val preferredNativeDeviceList = listOf( - CustomAudioDevice.BluetoothHeadset::class.java, - CustomAudioDevice.WiredHeadset::class.java, - ) + if (preferSpeaker) { - listOf( - CustomAudioDevice.Speakerphone::class.java, - CustomAudioDevice.Earpiece::class.java, - ) - } else { - listOf( - CustomAudioDevice.Earpiece::class.java, - CustomAudioDevice.Speakerphone::class.java, - ) - } - audioHandler = AudioHandlerFactory.create( context = mediaManager.context, - preferredDeviceList = preferredDeviceList, - preferredNativeDeviceList = preferredNativeDeviceList, - audioDeviceChangeListener = { devices, selected -> + preferredStreamAudioDeviceList = preferredDeviceList, + twilioAudioDeviceChangeListener = { devices, selected -> logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } _devices.value = devices.map { it.fromAudio() } @@ -879,17 +768,17 @@ class MicrophoneManager( capturedOnAudioDevicesUpdate?.invoke() capturedOnAudioDevicesUpdate = null }, - audioNativeDeviceListener = { devices, selected -> + streamAudioDeviceChangeListener = { devices, selected -> logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } - _nativeDevices.value = devices - _selectedNativeDevice.value = selected + _devices.value = devices + _selectedDevice.value = selected setupCompleted = true capturedOnAudioDevicesUpdate?.invoke() capturedOnAudioDevicesUpdate = null }, - useCustomAudioSwitch = mediaManager.useCustomAudioSwitch, + useInBuiltAudioSwitch = mediaManager.useInBuiltAudioSwitch, ) logger.d { "[setup] Calling start on instance $audioHandler" } @@ -1378,7 +1267,7 @@ class MediaManagerImpl( @Deprecated("Use audioUsageProvider instead", replaceWith = ReplaceWith("audioUsageProvider")) val audioUsage: Int = defaultAudioUsage, val audioUsageProvider: (() -> Int) = { audioUsage }, - val useCustomAudioSwitch: Boolean = false, + val useInBuiltAudioSwitch: Boolean = false, ) { internal val camera = CameraManager(this, eglBaseContext, DefaultCameraCharacteristicsValidator()) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index b8b84658541..ebcde7e6dc8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -150,7 +150,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( * If true, uses the custom StreamAudioSwitch implementation (native Android APIs). * If false, uses Twilio's AudioSwitch library (default for backward compatibility). */ - private val useCustomAudioSwitch: Boolean = false, + private val useInBuiltAudioSwitch: Boolean = false, ) { private val context: Context = context.applicationContext private val scope = UserScope(ClientScope()) @@ -280,7 +280,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( enableStereoForSubscriber = enableStereoForSubscriber, telecomConfig = telecomConfig, tokenRepository = tokenRepository, - useCustomAudioSwitch = useCustomAudioSwitch, + useInBuiltAudioSwitch = useInBuiltAudioSwitch, ) if (user.type == UserType.Guest) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index 906d237d60d..c01318981a1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -180,7 +180,7 @@ internal class StreamVideoClient internal constructor( internal val enableStatsCollection: Boolean = true, internal val enableStereoForSubscriber: Boolean = true, internal val telecomConfig: TelecomConfig? = null, - internal val useCustomAudioSwitch: Boolean = false, + internal val useInBuiltAudioSwitch: Boolean = false, ) : StreamVideo, NotificationHandler by streamNotificationManager { private var locationJob: Deferred>? = null diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt index bad7f7a17e7..953c44b7f87 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioDeviceManager.kt @@ -19,20 +19,20 @@ package io.getstream.video.android.core.audio /** * Interface for managing audio device operations. * Different implementations handle API level differences. - * Uses NativeStreamAudioDevice for the custom audio switch implementation. + * Uses StreamAudioDevice for the custom audio switch implementation. */ internal interface AudioDeviceManager { /** * Enumerates available audio devices. */ - fun enumerateDevices(): List + fun enumerateDevices(): List /** * Selects an audio device for routing. * @param device The device to select * @return true if selection was successful, false otherwise */ - fun selectDevice(device: CustomAudioDevice): Boolean + fun selectDevice(device: StreamAudioDevice): Boolean /** * Clears the current device selection. @@ -42,7 +42,7 @@ internal interface AudioDeviceManager { /** * Gets the currently selected device. */ - fun getSelectedDevice(): CustomAudioDevice? + fun getSelectedDevice(): StreamAudioDevice? /** * Starts the device manager (registers listeners, etc.) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt index ab1a7aa2976..92a88b056de 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt @@ -25,7 +25,6 @@ import com.twilio.audioswitch.AudioDeviceChangeListener import com.twilio.audioswitch.AudioSwitch import io.getstream.log.StreamLog import io.getstream.log.taggedLogger -import kotlin.DeprecationLevel public interface AudioHandler { /** @@ -39,8 +38,6 @@ public interface AudioHandler { public fun stop() public fun selectDevice(audioDevice: StreamAudioDevice?) - - public fun selectCustomAudioDevice(customAudioDevice: io.getstream.video.android.core.audio.CustomAudioDevice?) } /** @@ -89,18 +86,11 @@ public class AudioSwitchHandler( } } - @Deprecated( - message = "StreamAudioDevice is deprecated. Use NativeStreamAudioDevice when useCustomAudioSwitch is true.", - level = DeprecationLevel.WARNING, - ) override fun selectDevice(audioDevice: StreamAudioDevice?) { val twilioDevice = convertStreamDeviceToTwilioDevice(audioDevice) selectDevice(twilioDevice) } - override fun selectCustomAudioDevice(customAudioDevice: io.getstream.video.android.core.audio.CustomAudioDevice?) { - } - public fun selectDevice(audioDevice: AudioDevice?) { logger.i { "[selectDevice] audioDevice: $audioDevice" } audioSwitch?.selectDevice(audioDevice) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt index 4562d1eb0c4..ba4c8c2c551 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt @@ -26,33 +26,31 @@ import com.twilio.audioswitch.AudioDeviceChangeListener as TwilioAudioDeviceChan */ internal object AudioHandlerFactory { /** - * Creates an AudioHandler instance based on the useCustomAudioSwitch flag. + * Creates an AudioHandler instance based on the useInBuiltAudioSwitch flag. * * @param context Android context - * @param preferredDeviceList List of preferred audio device types in priority order - * @param audioDeviceChangeListener Callback for device changes - * @param useCustomAudioSwitch If true, uses custom StreamAudioSwitch; if false, uses Twilio AudioSwitch + * @param preferredStreamAudioDeviceList List of preferred audio device types in priority order + * @param twilioAudioDeviceChangeListener Callback for device changes + * @param useInBuiltAudioSwitch If true, uses custom StreamAudioSwitch; if false, uses Twilio AudioSwitch * @return An AudioHandler instance */ fun create( context: Context, - preferredDeviceList: List>, - preferredNativeDeviceList: - List>, - audioDeviceChangeListener: TwilioAudioDeviceChangeListener, - audioNativeDeviceListener: CustomAudioDeviceChangeListener, - useCustomAudioSwitch: Boolean, + preferredStreamAudioDeviceList: List>, + twilioAudioDeviceChangeListener: TwilioAudioDeviceChangeListener, + streamAudioDeviceChangeListener: StreamAudioDeviceChangeListener, + useInBuiltAudioSwitch: Boolean, ): AudioHandler { - return if (useCustomAudioSwitch) { + return if (useInBuiltAudioSwitch) { StreamAudioSwitchHandler( context = context, - preferredDeviceList = preferredNativeDeviceList, - audioDeviceChangeListener = audioNativeDeviceListener, + preferredDeviceList = preferredStreamAudioDeviceList, + audioDeviceChangeListener = streamAudioDeviceChangeListener, ) } else { // Twilio audio switcher // Convert StreamAudioDevice types to Twilio AudioDevice types - val twilioPreferredDevices = preferredDeviceList.map { streamDeviceClass -> + val twilioPreferredDevices = preferredStreamAudioDeviceList.map { streamDeviceClass -> when (streamDeviceClass) { StreamAudioDevice.BluetoothHeadset::class.java -> AudioDevice.BluetoothHeadset::class.java StreamAudioDevice.WiredHeadset::class.java -> AudioDevice.WiredHeadset::class.java @@ -62,23 +60,10 @@ internal object AudioHandlerFactory { } } - // Convert our AudioDeviceChangeListener to Twilio's AudioDeviceChangeListener - val twilioListener: TwilioAudioDeviceChangeListener = { devices, selected -> - // Convert Twilio AudioDevice to StreamAudioDevice - val streamDevices = devices.map { twilioDevice -> - convertTwilioDeviceToStreamDevice(twilioDevice) - } - val streamSelected = selected?.let { convertTwilioDeviceToStreamDevice(it) } - // The callback expects Twilio AudioDevice types, so convert back - val twilioDevices = streamDevices.map { it.audio } - val twilioSelected = streamSelected?.audio - audioDeviceChangeListener(devices, selected) - } - AudioSwitchHandler( context = context, preferredDeviceList = twilioPreferredDevices, - audioDeviceChangeListener = twilioListener, + audioDeviceChangeListener = twilioAudioDeviceChangeListener, ) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt deleted file mode 100644 index d1e78fc0bb8..00000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/CustomAudioDevice.kt +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.core.audio - -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build -import androidx.annotation.RequiresApi - -/** - * Represents an audio device for the native Android audio switch implementation. - * This class is used when [useCustomAudioSwitch] is true. - * - * Unlike [StreamAudioDevice], this class does not depend on Twilio's AudioDevice. - * It uses Android's native [AudioDeviceInfo] for device identification and management. - */ -public sealed class CustomAudioDevice { - - /** The friendly name of the device.*/ - public abstract val name: String - - /** - * The Android AudioDeviceInfo instance. - * This provides device identification and capabilities. - * @see android.media.AudioDeviceInfo - */ - public abstract val audioDeviceInfo: AudioDeviceInfo? - - /** A [CustomAudioDevice] representing a Bluetooth Headset.*/ - public data class BluetoothHeadset constructor( - override val name: String = "Bluetooth", - override val audioDeviceInfo: AudioDeviceInfo? = null, - ) : CustomAudioDevice() - - /** A [CustomAudioDevice] representing a Wired Headset.*/ - public data class WiredHeadset constructor( - override val name: String = "Wired Headset", - override val audioDeviceInfo: AudioDeviceInfo? = null, - ) : CustomAudioDevice() - - /** A [CustomAudioDevice] representing the Earpiece.*/ - public data class Earpiece constructor( - override val name: String = "Earpiece", - override val audioDeviceInfo: AudioDeviceInfo? = null, - ) : CustomAudioDevice() - - /** A [CustomAudioDevice] representing the Speakerphone.*/ - public data class Speakerphone constructor( - override val name: String = "Speakerphone", - override val audioDeviceInfo: AudioDeviceInfo? = null, - ) : CustomAudioDevice() - - public companion object { - /** - * Converts an Android AudioDeviceInfo to a NativeStreamAudioDevice. - * Returns null if the device type is not supported. - * Available from API 23+ (always available since minSdk is 24). - */ - @JvmStatic - public fun fromAudioDeviceInfo(deviceInfo: AudioDeviceInfo): CustomAudioDevice? { - return when (deviceInfo.type) { - AudioDeviceInfo.TYPE_BLUETOOTH_SCO, - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, - -> { - CustomAudioDevice.BluetoothHeadset( - audioDeviceInfo = deviceInfo, - ) - } - AudioDeviceInfo.TYPE_WIRED_HEADSET, - AudioDeviceInfo.TYPE_WIRED_HEADPHONES, - AudioDeviceInfo.TYPE_USB_HEADSET, - -> { - CustomAudioDevice.WiredHeadset( - audioDeviceInfo = deviceInfo, - ) - } - AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> { - CustomAudioDevice.Earpiece( - audioDeviceInfo = deviceInfo, - ) - } - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> { - CustomAudioDevice.Speakerphone( - audioDeviceInfo = deviceInfo, - ) - } - else -> null - } - } - - /** - * Converts a NativeStreamAudioDevice to an AudioDeviceInfo by finding a matching device - * from the available communication devices. - * Returns null if no matching device is found. - */ - @RequiresApi(Build.VERSION_CODES.S) - @JvmStatic - public fun toAudioDeviceInfo( - nativeDevice: CustomAudioDevice, - audioManager: AudioManager, - ): AudioDeviceInfo? { - // If the device already has an AudioDeviceInfo, use it - val existingInfo = when (nativeDevice) { - is BluetoothHeadset -> nativeDevice.audioDeviceInfo?.id - is WiredHeadset -> nativeDevice.audioDeviceInfo?.id - is Earpiece -> nativeDevice.audioDeviceInfo?.id - is Speakerphone -> nativeDevice.audioDeviceInfo?.id - } - - // Otherwise, try to find a matching device from available devices - // For API 31+: use communication devices, for API 24-30: use all output devices - val availableDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return StreamAudioManager.getAvailableCommunicationDevices(audioManager).find { - it.id == existingInfo - } - } else { - // For API < 31, use getDevices() to get all output devices - try { - audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() - } catch (e: Exception) { - emptyList() - } - } - - return when (nativeDevice) { - is BluetoothHeadset -> { - availableDevices.firstOrNull { - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP - } - } - is WiredHeadset -> { - availableDevices.firstOrNull { - it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || - it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || - it.type == AudioDeviceInfo.TYPE_USB_HEADSET - } - } - is Earpiece -> { - availableDevices.firstOrNull { - it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE - } - } - is Speakerphone -> { - // For speakerphone, also check all devices if not found in communication devices - // Speakerphone might not always be in availableCommunicationDevices - availableDevices.firstOrNull { - it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER - } ?: run { - // Fallback: try to get from all devices if not in communication devices - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - try { - audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { - it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER - } - } catch (e: Exception) { - null - } - } else { - null - } - } - } - } - } - } -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt index 174fe6b3e6a..5af931b52eb 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt @@ -44,7 +44,7 @@ internal class LegacyAudioDeviceManager( private val logger by taggedLogger(TAG) - private var selectedDevice: CustomAudioDevice? = null + private var selectedDevice: StreamAudioDevice? = null private val mainHandler = Handler(Looper.getMainLooper()) // Legacy listener support @@ -69,8 +69,8 @@ internal class LegacyAudioDeviceManager( private val bluetoothScoTimeoutHandler = Handler(Looper.getMainLooper()) private var bluetoothScoTimeoutRunnable: Runnable? = null - override fun enumerateDevices(): List { - val devices = mutableListOf() + override fun enumerateDevices(): List { + val devices = mutableListOf() // Detect Bluetooth devices - use profile proxy if available, otherwise fall back to AudioDeviceInfo if (bluetoothProfileProxyAvailable && bluetoothHeadset != null) { @@ -98,13 +98,13 @@ internal class LegacyAudioDeviceManager( logger.d { "[enumerateDevices] Found ${androidDevices.size} available communication devices" } // Track Bluetooth devices by address to deduplicate SCO and A2DP for the same device - val bluetoothDevicesByAddress = mutableMapOf() + val bluetoothDevicesByAddress = mutableMapOf() for (androidDevice in androidDevices) { - val customAudioDevice = CustomAudioDevice.fromAudioDeviceInfo(androidDevice) - if (customAudioDevice != null) { - when (customAudioDevice) { - is CustomAudioDevice.BluetoothHeadset -> { + val streamAudioDevice = StreamAudioDevice.fromAudioDeviceInfo(androidDevice) + if (streamAudioDevice != null) { + when (streamAudioDevice) { + is StreamAudioDevice.BluetoothHeadset -> { // If profile proxy is available, skip AudioDeviceInfo Bluetooth devices (we already got them from profile) if (bluetoothProfileProxyAvailable && bluetoothHeadset != null) { continue @@ -114,18 +114,18 @@ internal class LegacyAudioDeviceManager( val existing = bluetoothDevicesByAddress[address] if (existing == null) { logger.d { - "[enumerateDevices] Detected Bluetooth device via AudioDeviceInfo: ${customAudioDevice.name}, type=${androidDevice.type}, address=$address" + "[enumerateDevices] Detected Bluetooth device via AudioDeviceInfo: ${streamAudioDevice.name}, type=${androidDevice.type}, address=$address" } - bluetoothDevicesByAddress[address] = customAudioDevice + bluetoothDevicesByAddress[address] = streamAudioDevice } else { // Prefer SCO over A2DP val isSco = androidDevice.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO val existingIsSco = existing.audioDeviceInfo?.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO if (isSco && !existingIsSco) { logger.d { - "[enumerateDevices] Replacing A2DP with SCO for Bluetooth device: ${customAudioDevice.name}, address=$address" + "[enumerateDevices] Replacing A2DP with SCO for Bluetooth device: ${streamAudioDevice.name}, address=$address" } - bluetoothDevicesByAddress[address] = customAudioDevice + bluetoothDevicesByAddress[address] = streamAudioDevice } else { logger.d { "[enumerateDevices] Skipping duplicate Bluetooth device (keeping ${if (existingIsSco) "SCO" else "A2DP"}): type=${androidDevice.type}, address=$address" @@ -135,14 +135,14 @@ internal class LegacyAudioDeviceManager( } else -> { logger.d { - "[enumerateDevices] Detected device: ${customAudioDevice::class.simpleName} (${customAudioDevice.name})" + "[enumerateDevices] Detected device: ${streamAudioDevice::class.simpleName} (${streamAudioDevice.name})" } - devices.add(customAudioDevice) + devices.add(streamAudioDevice) } } } else { logger.w { - "[enumerateDevices] Could not convert AudioDeviceInfo to CustomAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" + "[enumerateDevices] Could not convert AudioDeviceInfo to StreamAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" } } } @@ -154,7 +154,7 @@ internal class LegacyAudioDeviceManager( // Check for wired headset using ACTION_HEADSET_PLUG state // (getDevices() might not always detect it reliably) - if (devices.none { it is CustomAudioDevice.WiredHeadset }) { + if (devices.none { it is StreamAudioDevice.WiredHeadset }) { @Suppress("DEPRECATION") val isWiredHeadsetOn = try { audioManager.isWiredHeadsetOn @@ -163,22 +163,22 @@ internal class LegacyAudioDeviceManager( } if (isWiredHeadsetOn) { logger.d { "[enumerateDevices] Adding WiredHeadset via fallback check (isWiredHeadsetOn=true)" } - devices.add(CustomAudioDevice.WiredHeadset()) + devices.add(StreamAudioDevice.WiredHeadset()) } } // Add speakerphone - always available - if (devices.none { it is CustomAudioDevice.Speakerphone }) { + if (devices.none { it is StreamAudioDevice.Speakerphone }) { logger.d { "[enumerateDevices] Adding Speakerphone (always available)" } - devices.add(CustomAudioDevice.Speakerphone()) + devices.add(StreamAudioDevice.Speakerphone()) } // Add earpiece only if device has telephony feature (is a phone) - if (devices.none { it is CustomAudioDevice.Earpiece }) { + if (devices.none { it is StreamAudioDevice.Earpiece }) { val hasEarpiece = hasEarpiece(context) if (hasEarpiece) { logger.d { "[enumerateDevices] Adding Earpiece (device has telephony feature)" } - devices.add(CustomAudioDevice.Earpiece()) + devices.add(StreamAudioDevice.Earpiece()) } else { logger.d { "[enumerateDevices] Skipping Earpiece (device does not have telephony feature)" } } @@ -192,23 +192,23 @@ internal class LegacyAudioDeviceManager( return devices } - override fun selectDevice(device: CustomAudioDevice): Boolean { + override fun selectDevice(device: StreamAudioDevice): Boolean { when (device) { - is CustomAudioDevice.Speakerphone -> { + is StreamAudioDevice.Speakerphone -> { stopBluetoothSco() @Suppress("DEPRECATION") audioManager.isSpeakerphoneOn = true selectedDevice = device return true } - is CustomAudioDevice.Earpiece -> { + is StreamAudioDevice.Earpiece -> { stopBluetoothSco() @Suppress("DEPRECATION") audioManager.isSpeakerphoneOn = false selectedDevice = device return true } - is CustomAudioDevice.BluetoothHeadset -> { + is StreamAudioDevice.BluetoothHeadset -> { @Suppress("DEPRECATION") audioManager.isSpeakerphoneOn = false // All Bluetooth devices detected via BluetoothHeadset profile support SCO @@ -218,7 +218,7 @@ internal class LegacyAudioDeviceManager( selectedDevice = device return true } - is CustomAudioDevice.WiredHeadset -> { + is StreamAudioDevice.WiredHeadset -> { stopBluetoothSco() @Suppress("DEPRECATION") audioManager.isSpeakerphoneOn = false @@ -235,7 +235,7 @@ internal class LegacyAudioDeviceManager( selectedDevice = null } - override fun getSelectedDevice(): CustomAudioDevice? = selectedDevice + override fun getSelectedDevice(): StreamAudioDevice? = selectedDevice override fun start() { registerLegacyListeners() @@ -389,13 +389,13 @@ internal class LegacyAudioDeviceManager( * Detects Bluetooth devices using BluetoothHeadset profile * This only returns devices that support SCO and are actually connected. */ - private fun detectBluetoothDevices(): List { + private fun detectBluetoothDevices(): List { if (bluetoothHeadset == null) { logger.d { "[detectBluetoothDevices] bluetoothHeadset is null, profile proxy not connected yet" } return emptyList() } - val devices = mutableListOf() + val devices = mutableListOf() try { val connectedDevices: List? = bluetoothHeadset?.connectedDevices @@ -426,10 +426,10 @@ internal class LegacyAudioDeviceManager( // Only add device if it's actually connected if (connectionState == BluetoothHeadset.STATE_CONNECTED) { - // Create CustomAudioDevice for the Bluetooth headset + // Create StreamAudioDevice for the Bluetooth headset // Note: We don't have AudioDeviceInfo here, but that's okay - we know it supports SCO devices.add( - CustomAudioDevice.BluetoothHeadset( + StreamAudioDevice.BluetoothHeadset( name = deviceName, audioDeviceInfo = null, // We don't have AudioDeviceInfo from headset profile ), diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt index 032fdc18042..f7379e0c768 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManager.kt @@ -30,9 +30,9 @@ internal class ModernAudioDeviceManager( ) : AudioDeviceManager { private val logger by taggedLogger(TAG) - private var selectedDevice: CustomAudioDevice? = null + private var selectedDevice: StreamAudioDevice? = null - override fun enumerateDevices(): List { + override fun enumerateDevices(): List { val androidDevices = StreamAudioManager.getAvailableCommunicationDevices(audioManager) logger.d { "[enumerateDevices] Found ${androidDevices.size} available communication devices" } @@ -46,33 +46,33 @@ internal class ModernAudioDeviceManager( } } - val customAudioDevices = mutableListOf() + val streamAudioDevices = mutableListOf() for (androidDevice in androidDevices) { - val customAudioDevice = CustomAudioDevice.fromAudioDeviceInfo(androidDevice) - if (customAudioDevice != null) { + val streamAudioDevice = StreamAudioDevice.fromAudioDeviceInfo(androidDevice) + if (streamAudioDevice != null) { logger.d { - "[enumerateDevices] Detected device: ${customAudioDevice::class.simpleName} (${customAudioDevice.name})" + "[enumerateDevices] Detected device: ${streamAudioDevice::class.simpleName} (${streamAudioDevice.name})" } - customAudioDevices.add(customAudioDevice) + streamAudioDevices.add(streamAudioDevice) } else { logger.w { - "[enumerateDevices] Could not convert AudioDeviceInfo to CustomAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" + "[enumerateDevices] Could not convert AudioDeviceInfo to StreamAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" } } } - logger.d { "[enumerateDevices] Total enumerated devices: ${customAudioDevices.size}" } - customAudioDevices.forEachIndexed { index, device -> + logger.d { "[enumerateDevices] Total enumerated devices: ${streamAudioDevices.size}" } + streamAudioDevices.forEachIndexed { index, device -> logger.d { "[enumerateDevices] Final device $index: ${device::class.simpleName} (${device.name})" } } - return customAudioDevices + return streamAudioDevices } - override fun selectDevice(device: CustomAudioDevice): Boolean { + override fun selectDevice(device: StreamAudioDevice): Boolean { val androidDevice = device.audioDeviceInfo - ?: CustomAudioDevice.toAudioDeviceInfo(device, audioManager) + ?: StreamAudioDevice.toAudioDeviceInfo(device, audioManager) logger.d { "[selectDevice] :: $device" } return if (androidDevice != null) { val success = StreamAudioManager.setCommunicationDevice(audioManager, androidDevice) @@ -90,14 +90,14 @@ internal class ModernAudioDeviceManager( selectedDevice = null } - override fun getSelectedDevice(): CustomAudioDevice? { + override fun getSelectedDevice(): StreamAudioDevice? { // Try to get from AudioManager first val currentDevice = StreamAudioManager.getCommunicationDevice(audioManager) if (currentDevice != null) { - val customAudioDevice = CustomAudioDevice.fromAudioDeviceInfo(currentDevice) - if (customAudioDevice != null) { - selectedDevice = customAudioDevice - return customAudioDevice + val streamAudioDevice = StreamAudioDevice.fromAudioDeviceInfo(currentDevice) + if (streamAudioDevice != null) { + selectedDevice = streamAudioDevice + return streamAudioDevice } } return selectedDevice diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt index 760eab30689..bf72f5be431 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt @@ -16,67 +16,87 @@ package io.getstream.video.android.core.audio +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi import com.twilio.audioswitch.AudioDevice -import kotlin.DeprecationLevel -import kotlin.ReplaceWith + +// Lazy initialization of default Twilio AudioDevice instances +private val defaultBluetoothHeadsetAudioDevice: AudioDevice by lazy { + StreamAudioDevice.Companion.createTwilioBluetoothHeadset() +} +private val defaultWiredHeadsetAudioDevice: AudioDevice by lazy { + StreamAudioDevice.Companion.createTwilioWiredHeadset() +} +private val defaultEarpieceAudioDevice: AudioDevice by lazy { + StreamAudioDevice.Companion.createTwilioEarpiece() +} +private val defaultSpeakerphoneAudioDevice: AudioDevice by lazy { + StreamAudioDevice.Companion.createTwilioSpeakerphone() +} /** - * Represents an audio device for Twilio's AudioSwitch implementation. - * - * @deprecated This class is deprecated. Use [CustomAudioDevice] when [useCustomAudioSwitch] is true. - * This class will be removed in a future version. For new code, use [CustomAudioDevice] instead. + * Represents an audio device for audio switching. + * Supports both Twilio's AudioSwitch implementation and native Android audio device management. * * @see AudioDevice + * @see AudioDeviceInfo */ -@Deprecated( - message = "StreamAudioDevice is deprecated. Use NativeStreamAudioDevice when useCustomAudioSwitch is true. " + - "This class is kept for backward compatibility with Twilio's AudioSwitch.", - replaceWith = ReplaceWith( - "CustomAudioDevice", - "io.getstream.video.android.core.audio.CustomAudioDevice", - ), - level = DeprecationLevel.WARNING, -) -sealed class StreamAudioDevice { +public sealed class StreamAudioDevice { /** The friendly name of the device.*/ - abstract val name: String + public abstract val name: String + + /** + * The Twilio AudioDevice instance. + * Used when using Twilio's AudioSwitch implementation. + */ + public abstract val audio: AudioDevice - abstract val audio: AudioDevice + /** + * The Android AudioDeviceInfo instance. + * This provides device identification and capabilities when using native Android audio management. + * @see android.media.AudioDeviceInfo + */ + public abstract val audioDeviceInfo: AudioDeviceInfo? - /** An [StreamAudioDevice] representing a Bluetooth Headset.*/ - data class BluetoothHeadset constructor( + /** A [StreamAudioDevice] representing a Bluetooth Headset.*/ + public data class BluetoothHeadset constructor( override val name: String = "Bluetooth", - override val audio: AudioDevice, + override val audio: AudioDevice = defaultBluetoothHeadsetAudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - /** An [StreamAudioDevice] representing a Wired Headset.*/ - data class WiredHeadset constructor( + /** A [StreamAudioDevice] representing a Wired Headset.*/ + public data class WiredHeadset constructor( override val name: String = "Wired Headset", - override val audio: AudioDevice, + override val audio: AudioDevice = defaultWiredHeadsetAudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - /** An [StreamAudioDevice] representing the Earpiece.*/ - data class Earpiece constructor( + /** A [StreamAudioDevice] representing the Earpiece.*/ + public data class Earpiece constructor( override val name: String = "Earpiece", - override val audio: AudioDevice, + override val audio: AudioDevice = defaultEarpieceAudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - /** An [StreamAudioDevice] representing the Speakerphone.*/ - data class Speakerphone constructor( + /** A [StreamAudioDevice] representing the Speakerphone.*/ + public data class Speakerphone constructor( override val name: String = "Speakerphone", - override val audio: AudioDevice, + override val audio: AudioDevice = defaultSpeakerphoneAudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - companion object { - + public companion object { @JvmStatic - fun StreamAudioDevice.toAudioDevice(): AudioDevice { + public fun StreamAudioDevice.toAudioDevice(): AudioDevice { return this.audio } @JvmStatic - fun AudioDevice.fromAudio(): StreamAudioDevice { + public fun AudioDevice.fromAudio(): StreamAudioDevice { return when (this) { is AudioDevice.BluetoothHeadset -> BluetoothHeadset(audio = this) is AudioDevice.WiredHeadset -> WiredHeadset(audio = this) @@ -84,5 +104,192 @@ sealed class StreamAudioDevice { is AudioDevice.Speakerphone -> Speakerphone(audio = this) } } + + /** + * Converts an Android AudioDeviceInfo to a StreamAudioDevice. + * Returns null if the device type is not supported. + * Available from API 23+ (always available since minSdk is 24). + */ + @JvmStatic + public fun fromAudioDeviceInfo(deviceInfo: AudioDeviceInfo): StreamAudioDevice? { + return when (deviceInfo.type) { + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + -> { + BluetoothHeadset( + audioDeviceInfo = deviceInfo, + ) + } + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_HEADSET, + -> { + WiredHeadset( + audioDeviceInfo = deviceInfo, + ) + } + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> { + Earpiece( + audioDeviceInfo = deviceInfo, + ) + } + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> { + Speakerphone( + audioDeviceInfo = deviceInfo, + ) + } + else -> null + } + } + + /** + * Converts a StreamAudioDevice to an AudioDeviceInfo by finding a matching device + * from the available communication devices. + * Returns null if no matching device is found. + */ + @RequiresApi(Build.VERSION_CODES.S) + @JvmStatic + public fun toAudioDeviceInfo( + streamDevice: StreamAudioDevice, + audioManager: AudioManager, + ): AudioDeviceInfo? { + // If the device already has an AudioDeviceInfo, use it + val existingInfo = streamDevice.audioDeviceInfo?.id + + // Otherwise, try to find a matching device from available devices + // For API 31+: use communication devices, for API 24-30: use all output devices + val availableDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val commDevices = StreamAudioManager.getAvailableCommunicationDevices(audioManager) + if (existingInfo != null) { + return commDevices.find { it.id == existingInfo } + } + commDevices + } else { + // For API < 31, use getDevices() to get all output devices + try { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } catch (e: Exception) { + emptyList() + } + } + + return when (streamDevice) { + is BluetoothHeadset -> { + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + } + } + is WiredHeadset -> { + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || + it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || + it.type == AudioDeviceInfo.TYPE_USB_HEADSET + } + } + is Earpiece -> { + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + } + } + is Speakerphone -> { + // For speakerphone, also check all devices if not found in communication devices + // Speakerphone might not always be in availableCommunicationDevices + availableDevices.firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + } ?: run { + // Fallback: try to get from all devices if not in communication devices + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + } + } catch (e: Exception) { + null + } + } else { + null + } + } + } + } + } + + /** + * Creates a Twilio AudioDevice.BluetoothHeadset instance using reflection. + */ + internal fun createTwilioBluetoothHeadset(): AudioDevice { + return createTwilioAudioDevice(AudioDevice.BluetoothHeadset::class.java) + } + + /** + * Creates a Twilio AudioDevice.WiredHeadset instance using reflection. + */ + internal fun createTwilioWiredHeadset(): AudioDevice { + return createTwilioAudioDevice(AudioDevice.WiredHeadset::class.java) + } + + /** + * Creates a Twilio AudioDevice.Earpiece instance using reflection. + */ + internal fun createTwilioEarpiece(): AudioDevice { + return createTwilioAudioDevice(AudioDevice.Earpiece::class.java) + } + + /** + * Creates a Twilio AudioDevice.Speakerphone instance using reflection. + */ + internal fun createTwilioSpeakerphone(): AudioDevice { + return createTwilioAudioDevice(AudioDevice.Speakerphone::class.java) + } + + /** + * Generic method to create Twilio AudioDevice instances using reflection. + * Accesses the private constructor and creates an instance. + */ + @Suppress("UNCHECKED_CAST") + private fun createTwilioAudioDevice(deviceClass: Class): T { + // Get all declared constructors + val constructors = deviceClass.declaredConstructors + if (constructors.isEmpty()) { + throw IllegalStateException("No constructor found for ${deviceClass.simpleName}") + } + + // Try each constructor with different parameter combinations + var lastException: Exception? = null + for (constructor in constructors) { + try { + constructor.isAccessible = true + val paramTypes = constructor.parameterTypes + + // Create appropriate arguments based on parameter types + val args = paramTypes.map { paramType -> + when { + paramType == String::class.java -> "" + paramType.isPrimitive -> when (paramType.name) { + "int", "long" -> 0 + "boolean" -> false + "float", "double" -> 0.0 + else -> null + } + else -> null + } + }.toTypedArray() + + @Suppress("UNCHECKED_CAST") + return constructor.newInstance(*args) as T + } catch (e: Exception) { + lastException = e + // Try next constructor + continue + } + } + + throw IllegalStateException( + "Failed to create Twilio AudioDevice instance for ${deviceClass.simpleName}. " + + "None of the constructors could be invoked.", + lastException, + ) + } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt index 598a8d579a7..34e1c9044cf 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt @@ -31,25 +31,25 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** - * Listener interface for audio device changes using NativeStreamAudioDevice. + * Listener interface for audio device changes using StreamAudioDevice. */ -internal typealias CustomAudioDeviceChangeListener = ( - devices: List, - selectedDevice: CustomAudioDevice?, +internal typealias StreamAudioDeviceChangeListener = ( + devices: List, + selectedDevice: StreamAudioDevice?, ) -> Unit /** * Custom AudioSwitch implementation using Android's AudioManager APIs. * Replaces Twilio's AudioSwitch library with native Android functionality. - * Uses NativeStreamAudioDevice for all device operations. + * Uses StreamAudioDevice for all device operations. */ internal class StreamAudioSwitch( private val context: Context, - preferredDeviceList: List>? = null, + preferredDeviceList: List>? = null, ) { private val logger by taggedLogger(TAG) - private val preferredDeviceList: List> = + private val preferredDeviceList: List> = preferredDeviceList ?: getDefaultPreferredDeviceList() private val audioManager: AudioManager = context.getSystemService( @@ -57,7 +57,7 @@ internal class StreamAudioSwitch( ) as AudioManager private val mainHandler = Handler(Looper.getMainLooper()) - private var audioDeviceChangeListener: CustomAudioDeviceChangeListener? = null + private var audioDeviceChangeListener: StreamAudioDeviceChangeListener? = null private var audioDeviceCallback: AudioDeviceCallback? = null private var isStarted = false @@ -80,20 +80,20 @@ internal class StreamAudioSwitch( logger.d { "[onAudioFocusChange] focusChange: $typeOfChange" } } - private val _availableDevices = MutableStateFlow>(emptyList()) - public val availableDevices: StateFlow> = _availableDevices.asStateFlow() + private val _availableDevices = MutableStateFlow>(emptyList()) + public val availableDevices: StateFlow> = _availableDevices.asStateFlow() - private val _selectedDeviceState = MutableStateFlow(null) - public val selectedDeviceState: StateFlow = _selectedDeviceState.asStateFlow() + private val _selectedDeviceState = MutableStateFlow(null) + public val selectedDeviceState: StateFlow = _selectedDeviceState.asStateFlow() // Track previous device before Bluetooth was selected (for fallback on Bluetooth failure) - private var previousDeviceBeforeBluetooth: CustomAudioDevice? = null + private var previousDeviceBeforeBluetooth: StreamAudioDevice? = null /** * Starts monitoring audio devices and begins device enumeration. * @param listener Callback that receives device updates */ - public fun start(listener: CustomAudioDeviceChangeListener? = null) { + public fun start(listener: StreamAudioDeviceChangeListener? = null) { synchronized(this) { if (isStarted) { logger.w { "[start] AudioSwitch already started" } @@ -170,8 +170,8 @@ internal class StreamAudioSwitch( * Selects an audio device for routing. * @param device The device to select, or null for automatic selection */ - public fun selectDevice(device: CustomAudioDevice?) { - selectCustomAudioDevice(device) + public fun selectDevice(device: StreamAudioDevice?) { + selectStreamAudioDevice(device) } /** @@ -187,25 +187,25 @@ internal class StreamAudioSwitch( /** * Gets the currently selected device. */ - public fun getSelectedDevice(): CustomAudioDevice? = _selectedDeviceState.value + public fun getSelectedDevice(): StreamAudioDevice? = _selectedDeviceState.value /** * Gets the list of available devices. */ - public fun getAvailableDevices(): List = _availableDevices.value + public fun getAvailableDevices(): List = _availableDevices.value /** * Selects a native audio device for routing. - * @param device The native device to select, or null for automatic selection + * @param device The device to select, or null for automatic selection */ - public fun selectCustomAudioDevice(device: CustomAudioDevice?) { + public fun selectStreamAudioDevice(device: StreamAudioDevice?) { synchronized(this) { if (!isStarted) { - logger.w { "[selectCustomAudioDevice] AudioSwitch not started" } + logger.w { "[selectStreamAudioDevice] AudioSwitch not started" } return } - logger.i { "[selectCustomAudioDevice] Selecting native device: $device" } + logger.i { "[selectStreamAudioDevice] Selecting native device: $device" } val deviceToSelect = device ?: selectCustomDeviceByPriority() val currentSelected = _selectedDeviceState.value @@ -214,9 +214,9 @@ internal class StreamAudioSwitch( val manager = deviceManager if (manager != null) { // If switching to Bluetooth, save the previous device for fallback - if (deviceToSelect is CustomAudioDevice.BluetoothHeadset && + if (deviceToSelect is StreamAudioDevice.BluetoothHeadset && currentSelected != null && - currentSelected !is CustomAudioDevice.BluetoothHeadset + currentSelected !is StreamAudioDevice.BluetoothHeadset ) { previousDeviceBeforeBluetooth = currentSelected logger.d { @@ -233,7 +233,7 @@ internal class StreamAudioSwitch( // Fallback to automatic selection if the requested device is not available val autoSelected = selectCustomDeviceByPriority() if (autoSelected != null && autoSelected != deviceToSelect) { - selectCustomAudioDevice(autoSelected) + selectStreamAudioDevice(autoSelected) } } } else { @@ -250,7 +250,7 @@ internal class StreamAudioSwitch( /** * Selects a device by priority from available native devices. */ - private fun selectCustomDeviceByPriority(): CustomAudioDevice? { + private fun selectCustomDeviceByPriority(): StreamAudioDevice? { val availableDevices = _availableDevices.value if (availableDevices.isEmpty()) { return null @@ -340,7 +340,7 @@ internal class StreamAudioSwitch( if (availableDevices.contains(previousDevice)) { logger.d { "[handleBluetoothConnectionFailure] Reverting to previous device: $previousDevice" } previousDeviceBeforeBluetooth = null // Clear after use - selectCustomAudioDevice(previousDevice) + selectStreamAudioDevice(previousDevice) return } else { logger.d { @@ -354,7 +354,7 @@ internal class StreamAudioSwitch( logger.d { "[handleBluetoothConnectionFailure] No previous device, using automatic selection" } val autoSelected = selectCustomDeviceByPriority() if (autoSelected != null) { - selectCustomAudioDevice(autoSelected) + selectStreamAudioDevice(autoSelected) } } @@ -435,12 +435,12 @@ internal class StreamAudioSwitch( * BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone */ @JvmStatic - fun getDefaultPreferredDeviceList(): List> { + fun getDefaultPreferredDeviceList(): List> { return listOf( - CustomAudioDevice.BluetoothHeadset::class.java, - CustomAudioDevice.WiredHeadset::class.java, - CustomAudioDevice.Earpiece::class.java, - CustomAudioDevice.Speakerphone::class.java, + StreamAudioDevice.BluetoothHeadset::class.java, + StreamAudioDevice.WiredHeadset::class.java, + StreamAudioDevice.Earpiece::class.java, + StreamAudioDevice.Speakerphone::class.java, ) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt index 11553580fd5..913cbebaae2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt @@ -28,8 +28,8 @@ import io.getstream.log.taggedLogger */ internal class StreamAudioSwitchHandler( private val context: Context, - private val preferredDeviceList: List>, - private var audioDeviceChangeListener: CustomAudioDeviceChangeListener, + private val preferredDeviceList: List>, + private var audioDeviceChangeListener: StreamAudioDeviceChangeListener, ) : AudioHandler { private val logger by taggedLogger(TAG) @@ -69,11 +69,7 @@ internal class StreamAudioSwitchHandler( } override fun selectDevice(audioDevice: StreamAudioDevice?) { - } - - override fun selectCustomAudioDevice(customAudioDevice: CustomAudioDevice?) { - logger.i { "[selectDevice] audioDevice: $customAudioDevice" } - streamAudioSwitch?.selectCustomAudioDevice(customAudioDevice) + streamAudioSwitch?.selectDevice(audioDevice) } public companion object { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index f510c3976ac..fd8b6b48654 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -21,7 +21,6 @@ import android.media.AudioAttributes import android.media.AudioManager import io.getstream.android.video.generated.models.OwnCapability import io.getstream.video.android.core.audio.AudioSwitchHandler -import io.getstream.video.android.core.audio.CustomAudioDevice import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -71,7 +70,7 @@ class MicrophoneManagerTest { // When microphoneManager.enable() // 1 - microphoneManager.select(null as CustomAudioDevice?) // 0 + microphoneManager.select(null) // 0 microphoneManager.resume() // 2, 3, Resume calls enable internally, thus two invocations microphoneManager.disable() // 4 microphoneManager.pause() // 5 diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt index ae4a5073207..37d458f8d83 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SpeakerManagerTest.kt @@ -17,7 +17,7 @@ package io.getstream.video.android.core import android.media.AudioAttributes -import io.getstream.video.android.core.audio.CustomAudioDevice +import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.mockk.every import io.mockk.mockk @@ -36,27 +36,27 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") val devices = listOf(speakerDevice, earpieceDevice) - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.customAudioDevices.value } returns devices - every { microphoneManager.selectedCustomAudioDevice.value } returns earpieceDevice + every { microphoneManager.devices.value } returns devices + every { microphoneManager.selectedDevice.value } returns earpieceDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(true, null as CustomAudioDevice?) + speakerManager.setSpeakerPhone(true, null) // Then verify { microphoneManager.enforceSetup(preferSpeaker = true, any()) } assertEquals(speakerDevice, deviceSlot.captured) - assertEquals(earpieceDevice, speakerManager.selectedBeforeSpeakerCustomAudioDevice) + assertEquals(earpieceDevice, speakerManager.selectedBeforeSpeaker) assertEquals(true, speakerManager.speakerPhoneEnabled.value) } @@ -67,24 +67,24 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") - speakerManager.selectedBeforeSpeakerCustomAudioDevice = earpieceDevice + speakerManager.selectedBeforeSpeaker = earpieceDevice - val deviceSlot = slot() + val deviceSlot = slot() val devices = listOf(speakerDevice, earpieceDevice) // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.customAudioDevices.value } returns devices - every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice + every { microphoneManager.devices.value } returns devices + every { microphoneManager.selectedDevice.value } returns speakerDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false, null as CustomAudioDevice?) + speakerManager.setSpeakerPhone(false, null) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } @@ -99,24 +99,24 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") val devices = listOf(speakerDevice, earpieceDevice) speakerManager.selectedBeforeSpeaker = null - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.customAudioDevices.value } returns devices - every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice + every { microphoneManager.devices.value } returns devices + every { microphoneManager.selectedDevice.value } returns speakerDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false, null as CustomAudioDevice?) + speakerManager.setSpeakerPhone(false) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } @@ -131,19 +131,19 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice = CustomAudioDevice.Speakerphone("test-speaker") - val earpieceDevice = CustomAudioDevice.Earpiece("test-earpiece") - val wiredHeadsetDevice = CustomAudioDevice.WiredHeadset("test-wired") + val speakerDevice = StreamAudioDevice.Speakerphone("test-speaker") + val earpieceDevice = StreamAudioDevice.Earpiece("test-earpiece") + val wiredHeadsetDevice = StreamAudioDevice.WiredHeadset("test-wired") val devices = listOf(speakerDevice, earpieceDevice, wiredHeadsetDevice) - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.customAudioDevices.value } returns devices - every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice + every { microphoneManager.devices.value } returns devices + every { microphoneManager.selectedDevice.value } returns speakerDevice every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When @@ -162,34 +162,34 @@ class SpeakerManagerTest { val microphoneManager = mockk(relaxed = true) val speakerManager = SpeakerManager(mediaManager, microphoneManager) - val speakerDevice1 = CustomAudioDevice.Speakerphone("test-speaker-1") - val speakerDevice2 = CustomAudioDevice.Speakerphone("test-speaker-2") + val speakerDevice1 = StreamAudioDevice.Speakerphone("test-speaker-1") + val speakerDevice2 = StreamAudioDevice.Speakerphone("test-speaker-2") // Only speaker devices are available val devices = listOf(speakerDevice1, speakerDevice2) // No previously selected device - speakerManager.selectedBeforeSpeakerCustomAudioDevice = null + speakerManager.selectedBeforeSpeaker = null - val deviceSlot = slot() + val deviceSlot = slot() // Set up enforceSetup to execute the lambda immediately every { microphoneManager.enforceSetup(any(), any()) } answers { secondArg<() -> Unit>().invoke() } - every { microphoneManager.customAudioDevices.value } returns devices - every { microphoneManager.selectedCustomAudioDevice.value } returns speakerDevice1 + every { microphoneManager.devices.value } returns devices + every { microphoneManager.selectedDevice.value } returns speakerDevice1 every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false, null as CustomAudioDevice?) + speakerManager.setSpeakerPhone(false, null) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } // Since we only have speakers available, verify we selected the first one verify { microphoneManager.select( - any(), + any(), ) } // Verify the select method was called assertEquals(false, speakerManager.speakerPhoneEnabled.value) From 381e5f56697b47fb7ad497881c39df376731eae0 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 23 Dec 2025 15:42:42 +0530 Subject: [PATCH 10/15] spotless --- .../main/kotlin/io/getstream/video/android/core/Call.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 00d749373cf..6d40751fc1b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -1371,10 +1371,10 @@ public class Call( } else { logger.d { "[monitorHeadset] no headset found" } - microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> - logger.d { "[monitorHeadset] before device selected" } - microphone.select(deviceBeforeHeadset) - } + microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> + logger.d { "[monitorHeadset] before device selected" } + microphone.select(deviceBeforeHeadset) + } } }.launchIn(scope) } From 03290e1745be55cce745d5c7575784025d235964 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 23 Dec 2025 15:49:37 +0530 Subject: [PATCH 11/15] api dump --- .../api/stream-video-android-core.api | 131 +++++------------- 1 file changed, 34 insertions(+), 97 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 7829907b3de..ad7dad6008a 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8032,7 +8032,7 @@ public final class io/getstream/video/android/core/MediaManagerImpl { public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; public final fun getScreenShareTrack ()Lorg/webrtc/VideoTrack; public final fun getScreenShareVideoSource ()Lorg/webrtc/VideoSource; - public final fun getuseInBuiltAudioSwitch ()Z + public final fun getUseInBuiltAudioSwitch ()Z public final fun getVideoSource ()Lorg/webrtc/VideoSource; public final fun getVideoTrack ()Lorg/webrtc/VideoTrack; public final fun setAudioTrack (Lorg/webrtc/AudioTrack;)V @@ -8105,10 +8105,8 @@ public final class io/getstream/video/android/core/MicrophoneManager { public final fun getAudioBitrateProfile ()Lkotlinx/coroutines/flow/StateFlow; public final fun getAudioUsage ()I public final fun getAudioUsageProvider ()Lkotlin/jvm/functions/Function0; - public final fun getCustomAudioDevices ()Lkotlinx/coroutines/flow/StateFlow; public final fun getDevices ()Lkotlinx/coroutines/flow/StateFlow; public final fun getMediaManager ()Lio/getstream/video/android/core/MediaManagerImpl; - public final fun getSelectedCustomAudioDevice ()Lkotlinx/coroutines/flow/StateFlow; public final fun getSelectedDevice ()Lkotlinx/coroutines/flow/StateFlow; public final fun getStatus ()Lkotlinx/coroutines/flow/StateFlow; public final fun isEnabled ()Lkotlinx/coroutines/flow/StateFlow; @@ -8117,7 +8115,6 @@ public final class io/getstream/video/android/core/MicrophoneManager { public static synthetic fun pause$default (Lio/getstream/video/android/core/MicrophoneManager;ZILjava/lang/Object;)V public final fun resume (Z)V public static synthetic fun resume$default (Lio/getstream/video/android/core/MicrophoneManager;ZILjava/lang/Object;)V - public final fun select (Lio/getstream/video/android/core/audio/CustomAudioDevice;)V public final fun select (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public final fun setAudioBitrateProfile-gIAlu-s (Lstream/video/sfu/models/AudioBitrateProfile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setEnabled (ZZ)V @@ -8400,9 +8397,7 @@ public final class io/getstream/video/android/core/SpeakerManager { public final fun setAudioUsage (I)Z public final fun setEnabled (ZZ)V public static synthetic fun setEnabled$default (Lio/getstream/video/android/core/SpeakerManager;ZZILjava/lang/Object;)V - public final fun setSpeakerPhone (ZLio/getstream/video/android/core/audio/CustomAudioDevice;)V public final fun setSpeakerPhone (ZLio/getstream/video/android/core/audio/StreamAudioDevice;)V - public static synthetic fun setSpeakerPhone$default (Lio/getstream/video/android/core/SpeakerManager;ZLio/getstream/video/android/core/audio/CustomAudioDevice;ILjava/lang/Object;)V public static synthetic fun setSpeakerPhone$default (Lio/getstream/video/android/core/SpeakerManager;ZLio/getstream/video/android/core/audio/StreamAudioDevice;ILjava/lang/Object;)V public final fun setVolume (I)V } @@ -8510,7 +8505,6 @@ public abstract interface class io/getstream/video/android/core/api/SignalServer } public abstract interface class io/getstream/video/android/core/audio/AudioHandler { - public abstract fun selectCustomAudioDevice (Lio/getstream/video/android/core/audio/CustomAudioDevice;)V public abstract fun selectDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public abstract fun start ()V public abstract fun stop ()V @@ -8519,7 +8513,6 @@ public abstract interface class io/getstream/video/android/core/audio/AudioHandl public final class io/getstream/video/android/core/audio/AudioSwitchHandler : io/getstream/video/android/core/audio/AudioHandler { public static final field Companion Lio/getstream/video/android/core/audio/AudioSwitchHandler$Companion; public fun (Landroid/content/Context;Ljava/util/List;Lkotlin/jvm/functions/Function2;)V - public fun selectCustomAudioDevice (Lio/getstream/video/android/core/audio/CustomAudioDevice;)V public final fun selectDevice (Lcom/twilio/audioswitch/AudioDevice;)V public fun selectDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public fun start ()V @@ -8529,96 +8522,29 @@ public final class io/getstream/video/android/core/audio/AudioSwitchHandler : io public final class io/getstream/video/android/core/audio/AudioSwitchHandler$Companion { } -public abstract class io/getstream/video/android/core/audio/CustomAudioDevice { - public static final field Companion Lio/getstream/video/android/core/audio/CustomAudioDevice$Companion; - public static final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice; - public abstract fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; - public abstract fun getName ()Ljava/lang/String; - public static final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/CustomAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; -} - -public final class io/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset : io/getstream/video/android/core/audio/CustomAudioDevice { - public fun ()V - public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$BluetoothHeadset; - public fun equals (Ljava/lang/Object;)Z - public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; - public fun getName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class io/getstream/video/android/core/audio/CustomAudioDevice$Companion { - public final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice; - public final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/CustomAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; -} - -public final class io/getstream/video/android/core/audio/CustomAudioDevice$Earpiece : io/getstream/video/android/core/audio/CustomAudioDevice { - public fun ()V - public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Earpiece; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$Earpiece;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Earpiece; - public fun equals (Ljava/lang/Object;)Z - public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; - public fun getName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class io/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone : io/getstream/video/android/core/audio/CustomAudioDevice { - public fun ()V - public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$Speakerphone; - public fun equals (Ljava/lang/Object;)Z - public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; - public fun getName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class io/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset : io/getstream/video/android/core/audio/CustomAudioDevice { - public fun ()V - public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/CustomAudioDevice$WiredHeadset; - public fun equals (Ljava/lang/Object;)Z - public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; - public fun getName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public abstract class io/getstream/video/android/core/audio/StreamAudioDevice { public static final field Companion Lio/getstream/video/android/core/audio/StreamAudioDevice$Companion; public static final fun fromAudio (Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice; + public static final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice; public abstract fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; + public abstract fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public abstract fun getName ()Ljava/lang/String; public static final fun toAudioDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)Lcom/twilio/audioswitch/AudioDevice; + public static final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/StreamAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; } public final class io/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset : io/getstream/video/android/core/audio/StreamAudioDevice { - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ()V + public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; + public final fun component3 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; public fun equals (Ljava/lang/Object;)Z public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -8626,46 +8552,57 @@ public final class io/getstream/video/android/core/audio/StreamAudioDevice$Bluet public final class io/getstream/video/android/core/audio/StreamAudioDevice$Companion { public final fun fromAudio (Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice; + public final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice; public final fun toAudioDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)Lcom/twilio/audioswitch/AudioDevice; + public final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/StreamAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; } public final class io/getstream/video/android/core/audio/StreamAudioDevice$Earpiece : io/getstream/video/android/core/audio/StreamAudioDevice { - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ()V + public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; + public final fun component3 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; public fun equals (Ljava/lang/Object;)Z public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone : io/getstream/video/android/core/audio/StreamAudioDevice { - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ()V + public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; + public final fun component3 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; public fun equals (Ljava/lang/Object;)Z public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset : io/getstream/video/android/core/audio/StreamAudioDevice { - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ()V + public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; + public final fun component3 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; public fun equals (Ljava/lang/Object;)Z public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; + public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; From 9c38908e4112cf64f8aa01fc8321724362a50923 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 23 Dec 2025 17:15:52 +0530 Subject: [PATCH 12/15] Deprecated audio --- .../getstream/video/android/core/audio/StreamAudioDevice.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt index bf72f5be431..2133e56d908 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt @@ -52,6 +52,10 @@ public sealed class StreamAudioDevice { * The Twilio AudioDevice instance. * Used when using Twilio's AudioSwitch implementation. */ + @Deprecated( + message = "Use audioDeviceInfo instead for native Android audio device management", + replaceWith = ReplaceWith("audioDeviceInfo"), + ) public abstract val audio: AudioDevice /** From 92a4f54ab80edb417363b851081e79ab31bfa0f4 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 23 Dec 2025 17:17:03 +0530 Subject: [PATCH 13/15] use audioDeviceInfo for checking the selected UI --- .../kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt index e2072bf64a1..ed9680a0af9 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt @@ -254,7 +254,7 @@ internal fun SettingsMenu( it, it.name, icon, - isSelected, + it.audioDeviceInfo?.id == selectedMicroPhoneDevice?.audioDeviceInfo?.id, ) } From ba59411e914fc2f0f2a8c20bd23f7fc7c1798d07 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Wed, 24 Dec 2025 12:48:09 +0530 Subject: [PATCH 14/15] Add unit tests --- .../audio/LegacyAudioDeviceManagerTest.kt | 497 +++++++++++++++++ .../audio/ModernAudioDeviceManagerTest.kt | 527 ++++++++++++++++++ 2 files changed, 1024 insertions(+) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManagerTest.kt diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManagerTest.kt new file mode 100644 index 00000000000..08b21dc3666 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManagerTest.kt @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.media.AudioDeviceInfo +import android.media.AudioManager +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LegacyAudioDeviceManagerTest { + + private lateinit var context: Context + private lateinit var audioManager: AudioManager + private lateinit var bluetoothManager: BluetoothManager + private lateinit var bluetoothAdapter: BluetoothAdapter + private lateinit var bluetoothHeadset: BluetoothHeadset + private lateinit var bluetoothDevice: BluetoothDevice + private lateinit var packageManager: PackageManager + + private var deviceChangeCallbackInvoked = false + private var bluetoothFailureCallbackInvoked = false + + private lateinit var manager: LegacyAudioDeviceManager + + @Before + fun setUp() { + context = mockk(relaxed = true) + audioManager = mockk(relaxed = true) + bluetoothManager = mockk(relaxed = true) + bluetoothAdapter = mockk(relaxed = true) + bluetoothHeadset = mockk(relaxed = true) + bluetoothDevice = mockk(relaxed = true) + packageManager = mockk(relaxed = true) + + deviceChangeCallbackInvoked = false + bluetoothFailureCallbackInvoked = false + + every { context.getSystemService(Context.BLUETOOTH_SERVICE) } returns bluetoothManager + every { bluetoothManager.adapter } returns bluetoothAdapter + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns true + + // Mock StreamAudioManager to prevent issues with API 31+ methods + mockkObject(StreamAudioManager) + // For API < 31, StreamAudioManager.getAvailableCommunicationDevices uses getDevices() + // So we delegate to the mocked getDevices() results + every { StreamAudioManager.getAvailableCommunicationDevices(audioManager) } answers { + try { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } catch (e: Exception) { + emptyList() + } + } + + manager = LegacyAudioDeviceManager( + context = context, + audioManager = audioManager, + onDeviceChange = { deviceChangeCallbackInvoked = true }, + onBluetoothConnectionFailure = { bluetoothFailureCallbackInvoked = true }, + ) + } + + @org.junit.After + fun tearDown() { + unmockkObject(StreamAudioManager) + } + + @Test + fun `enumerateDevices returns speakerphone when no other devices available`() { + // Given + every { audioManager.getDevices(any()) } returns emptyArray() + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(2) // Speakerphone and Earpiece + assertThat(devices).contains(StreamAudioDevice.Speakerphone()) + assertThat(devices).contains(StreamAudioDevice.Earpiece()) + } + + @Test + fun `enumerateDevices includes wired headset when detected via AudioDeviceInfo`() { + // Given + val wiredHeadsetDevice = mockk(relaxed = true) + every { wiredHeadsetDevice.type } returns AudioDeviceInfo.TYPE_WIRED_HEADSET + every { wiredHeadsetDevice.productName } returns "Wired Headset" + every { audioManager.getDevices(any()) } returns arrayOf(wiredHeadsetDevice) + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(3) // WiredHeadset, Speakerphone, Earpiece + assertThat(devices.any { it is StreamAudioDevice.WiredHeadset }).isTrue() + } + + @Test + fun `enumerateDevices includes wired headset when detected via isWiredHeadsetOn fallback`() { + // Given + every { audioManager.getDevices(any()) } returns emptyArray() + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns true + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(3) // WiredHeadset, Speakerphone, Earpiece + assertThat(devices.any { it is StreamAudioDevice.WiredHeadset }).isTrue() + } + + @Test + fun `enumerateDevices includes Bluetooth device when detected via AudioDeviceInfo`() { + // Given + val bluetoothDevice = mockk(relaxed = true) + every { bluetoothDevice.type } returns AudioDeviceInfo.TYPE_BLUETOOTH_SCO + every { bluetoothDevice.productName } returns "Bluetooth Headset" + every { bluetoothDevice.address } returns "00:11:22:33:44:55" + every { audioManager.getDevices(any()) } returns arrayOf(bluetoothDevice) + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(3) // BluetoothHeadset, Speakerphone, Earpiece + assertThat(devices.any { it is StreamAudioDevice.BluetoothHeadset }).isTrue() + } + + @Test + fun `enumerateDevices excludes earpiece when device has no telephony feature`() { + // Given + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns false + every { audioManager.getDevices(any()) } returns emptyArray() + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) // Only Speakerphone + assertThat(devices).contains(StreamAudioDevice.Speakerphone()) + assertThat(devices).doesNotContain(StreamAudioDevice.Earpiece()) + } + + @Test + fun `enumerateDevices deduplicates Bluetooth devices by address preferring SCO over A2DP`() { + // Given + val bluetoothScoDevice = mockk(relaxed = true) + every { bluetoothScoDevice.type } returns AudioDeviceInfo.TYPE_BLUETOOTH_SCO + every { bluetoothScoDevice.productName } returns "Bluetooth SCO" + every { bluetoothScoDevice.address } returns "00:11:22:33:44:55" + + val bluetoothA2dpDevice = mockk(relaxed = true) + every { bluetoothA2dpDevice.type } returns AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + every { bluetoothA2dpDevice.productName } returns "Bluetooth A2DP" + every { bluetoothA2dpDevice.address } returns "00:11:22:33:44:55" // Same address + + every { audioManager.getDevices(any()) } returns arrayOf(bluetoothScoDevice, bluetoothA2dpDevice) + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + val bluetoothDevices = devices.filterIsInstance() + assertThat(bluetoothDevices).hasSize(1) + assertThat( + bluetoothDevices.first().audioDeviceInfo?.type, + ).isEqualTo(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + } + + @Test + fun `selectDevice selects speakerphone and stops Bluetooth SCO`() { + // Given + val device = StreamAudioDevice.Speakerphone() + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isTrue() + @Suppress("DEPRECATION") + verify { audioManager.isSpeakerphoneOn = true } + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } + + @Test + fun `selectDevice selects earpiece and stops Bluetooth SCO`() { + // Given + val device = StreamAudioDevice.Earpiece() + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isTrue() + @Suppress("DEPRECATION") + verify { audioManager.isSpeakerphoneOn = false } + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } + + @Test + fun `selectDevice selects wired headset and stops Bluetooth SCO`() { + // Given + val device = StreamAudioDevice.WiredHeadset() + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isTrue() + @Suppress("DEPRECATION") + verify { audioManager.isSpeakerphoneOn = false } + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } + + @Test + fun `selectDevice selects Bluetooth headset and starts SCO connection`() { + // Given + val device = StreamAudioDevice.BluetoothHeadset() + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns true + every { bluetoothAdapter.getProfileProxy(any(), any(), any()) } returns true + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isTrue() + @Suppress("DEPRECATION") + verify { audioManager.isSpeakerphoneOn = false } + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } + + @Test + fun `clearDevice stops Bluetooth SCO and resets speakerphone`() { + // Given + val device = StreamAudioDevice.Speakerphone() + manager.selectDevice(device) + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + + // When + manager.clearDevice() + + // Then + @Suppress("DEPRECATION") + verify { audioManager.isSpeakerphoneOn = false } + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `getSelectedDevice returns null when no device is selected`() { + // When + val device = manager.getSelectedDevice() + + // Then + assertThat(device).isNull() + } + + @Test + fun `getSelectedDevice returns selected device`() { + // Given + val device = StreamAudioDevice.Speakerphone() + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + + // When + manager.selectDevice(device) + val selectedDevice = manager.getSelectedDevice() + + // Then + assertThat(selectedDevice).isEqualTo(device) + } + + @Test + fun `start registers legacy listeners`() { + // Given + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns true + every { bluetoothAdapter.getProfileProxy(any(), any(), any()) } returns true + + // When + manager.start() + + // Then + verify { context.registerReceiver(any(), any()) } + } + + @Test + fun `start handles Bluetooth adapter being null`() { + // Given + every { bluetoothManager.adapter } returns null + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns true + + // When + manager.start() + + // Then + // Should not crash + verify { context.registerReceiver(any(), any()) } + } + + @Test + fun `start handles Bluetooth SCO not available`() { + // Given + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns false + + // When + manager.start() + + // Then + // Should not crash and should still register headset receiver + verify { context.registerReceiver(any(), any()) } + } + + @Test + fun `start handles SecurityException when getting Bluetooth profile proxy`() { + // Given + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns true + every { + bluetoothAdapter.getProfileProxy(any(), any(), any()) + } throws SecurityException("Permission denied") + + // When + manager.start() + + // Then + // Should not crash and should fall back to AudioDeviceInfo enumeration + verify { context.registerReceiver(any(), any()) } + } + + @Test + fun `stop unregisters listeners and clears device`() { + // Given + val device = StreamAudioDevice.Speakerphone() + manager.selectDevice(device) + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns true + every { bluetoothAdapter.getProfileProxy(any(), any(), any()) } returns true + manager.start() + + // When + manager.stop() + + // Then + verify { context.unregisterReceiver(any()) } + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `stop handles exception when unregistering receiver`() { + // Given + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns true + every { bluetoothAdapter.getProfileProxy(any(), any(), any()) } returns true + manager.start() + every { + context.unregisterReceiver(any()) + } throws IllegalArgumentException("Receiver not registered") + + // When + manager.stop() + + // Then + // Should not crash + } + + @Test + fun `enumerateDevices handles exception when getting devices`() { + // Given + every { audioManager.getDevices(any()) } throws RuntimeException("Error getting devices") + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + // Should return at least speakerphone and earpiece + assertThat(devices).isNotEmpty() + assertThat(devices).contains(StreamAudioDevice.Speakerphone()) + } + + @Test + fun `enumerateDevices handles exception when checking wired headset`() { + // Given + every { audioManager.getDevices(any()) } returns emptyArray() + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } throws RuntimeException("Error checking headset") + + // When + val devices = manager.enumerateDevices() + + // Then + // Should not crash and should return at least speakerphone + assertThat(devices).isNotEmpty() + } + + @Test + fun `enumerateDevices filters out unsupported device types`() { + // Given + val unsupportedDevice = mockk(relaxed = true) + every { unsupportedDevice.type } returns AudioDeviceInfo.TYPE_HDMI + every { unsupportedDevice.productName } returns "HDMI Device" + every { audioManager.getDevices(any()) } returns arrayOf(unsupportedDevice) + @Suppress("DEPRECATION") + every { audioManager.isWiredHeadsetOn } returns false + + // When + val devices = manager.enumerateDevices() + + // Then + // Should not include unsupported device + assertThat( + devices.none { + it is StreamAudioDevice.BluetoothHeadset && it.name == "HDMI Device" + }, + ).isTrue() + assertThat(devices).contains(StreamAudioDevice.Speakerphone()) + } + + @Test + fun `selectDevice handles Bluetooth device when profile proxy is not available`() { + // Given + val device = StreamAudioDevice.BluetoothHeadset() + @Suppress("DEPRECATION") + every { audioManager.isSpeakerphoneOn } returns false + @Suppress("DEPRECATION") + every { audioManager.isBluetoothScoAvailableOffCall } returns false + + // When + val result = manager.selectDevice(device) + + // Then + // Should still return true even if SCO can't be started + assertThat(result).isTrue() + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManagerTest.kt new file mode 100644 index 00000000000..1afd631c57a --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/audio/ModernAudioDeviceManagerTest.kt @@ -0,0 +1,527 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.audio + +import android.media.AudioDeviceInfo +import android.media.AudioManager +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ModernAudioDeviceManagerTest { + + private lateinit var audioManager: AudioManager + + private lateinit var manager: ModernAudioDeviceManager + + @Before + fun setUp() { + audioManager = mockk(relaxed = true) + manager = ModernAudioDeviceManager(audioManager) + mockkObject(StreamAudioManager) + // Default mock for getCommunicationDevice to avoid NoSuchMethodError + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + } + + @org.junit.After + fun tearDown() { + unmockkObject(StreamAudioManager) + } + + @Test + fun `enumerateDevices returns empty list when no devices available`() { + // Given + every { StreamAudioManager.getAvailableCommunicationDevices(audioManager) } returns emptyList() + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).isEmpty() + } + + @Test + fun `enumerateDevices includes speakerphone when available`() { + // Given + val speakerDevice = mockk(relaxed = true) + every { speakerDevice.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + every { speakerDevice.productName } returns "Speaker" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(speakerDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.Speakerphone::class.java) + } + + @Test + fun `enumerateDevices includes earpiece when available`() { + // Given + val earpieceDevice = mockk(relaxed = true) + every { earpieceDevice.type } returns AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + every { earpieceDevice.productName } returns "Earpiece" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(earpieceDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.Earpiece::class.java) + } + + @Test + fun `enumerateDevices includes wired headset when available`() { + // Given + val wiredHeadsetDevice = mockk(relaxed = true) + every { wiredHeadsetDevice.type } returns AudioDeviceInfo.TYPE_WIRED_HEADSET + every { wiredHeadsetDevice.productName } returns "Wired Headset" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(wiredHeadsetDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.WiredHeadset::class.java) + } + + @Test + fun `enumerateDevices includes Bluetooth headset when available`() { + // Given + val bluetoothDevice = mockk(relaxed = true) + every { bluetoothDevice.type } returns AudioDeviceInfo.TYPE_BLUETOOTH_SCO + every { bluetoothDevice.productName } returns "Bluetooth Headset" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(bluetoothDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.BluetoothHeadset::class.java) + } + + @Test + fun `enumerateDevices includes multiple device types`() { + // Given + val speakerDevice = mockk(relaxed = true) + every { speakerDevice.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + every { speakerDevice.productName } returns "Speaker" + + val earpieceDevice = mockk(relaxed = true) + every { earpieceDevice.type } returns AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + every { earpieceDevice.productName } returns "Earpiece" + + val wiredHeadsetDevice = mockk(relaxed = true) + every { wiredHeadsetDevice.type } returns AudioDeviceInfo.TYPE_WIRED_HEADSET + every { wiredHeadsetDevice.productName } returns "Wired Headset" + + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(speakerDevice, earpieceDevice, wiredHeadsetDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(3) + assertThat(devices.any { it is StreamAudioDevice.Speakerphone }).isTrue() + assertThat(devices.any { it is StreamAudioDevice.Earpiece }).isTrue() + assertThat(devices.any { it is StreamAudioDevice.WiredHeadset }).isTrue() + } + + @Test + fun `enumerateDevices filters out unsupported device types`() { + // Given + val unsupportedDevice = mockk(relaxed = true) + every { unsupportedDevice.type } returns AudioDeviceInfo.TYPE_HDMI + every { unsupportedDevice.productName } returns "HDMI Device" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(unsupportedDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).isEmpty() + } + + @Test + fun `enumerateDevices handles exception when getting available devices`() { + // Given + // StreamAudioManager.getAvailableCommunicationDevices catches exceptions internally + // and returns emptyList(), so we mock it to return emptyList to simulate the exception case + every { StreamAudioManager.getAvailableCommunicationDevices(audioManager) } returns emptyList() + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).isEmpty() + } + + @Test + fun `selectDevice succeeds when device has audioDeviceInfo`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isTrue() + verify { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } + + @Test + fun `selectDevice uses toAudioDeviceInfo when device has no audioDeviceInfo`() { + // Given + val device = StreamAudioDevice.Speakerphone() + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + every { audioDeviceInfo.id } returns 1 + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(audioDeviceInfo) + // Mock fallback for Speakerphone if not found in communication devices + every { audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) } returns arrayOf(audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + // Mock getCommunicationDevice to return null so it uses the stored selectedDevice + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isTrue() + verify { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } + assertThat(manager.getSelectedDevice()).isEqualTo(device) + } + + @Test + fun `selectDevice fails when setCommunicationDevice returns false`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { false } + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isFalse() + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `selectDevice fails when toAudioDeviceInfo returns null`() { + // Given + val device = StreamAudioDevice.Speakerphone() + every { StreamAudioManager.getAvailableCommunicationDevices(audioManager) } returns emptyList() + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isFalse() + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `selectDevice handles exception when setting communication device`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { false } + + // When + val result = manager.selectDevice(device) + + // Then + assertThat(result).isFalse() + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `clearDevice clears communication device and resets selected device`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + every { StreamAudioManager.clearCommunicationDevice(audioManager) } answers { true } + manager.selectDevice(device) + + // When + manager.clearDevice() + + // Then + verify { StreamAudioManager.clearCommunicationDevice(audioManager) } + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `clearDevice handles exception when clearing communication device`() { + // Given + // Mock clearCommunicationDevice to return false (simulating failure) + // Using answers to prevent real implementation from executing + every { StreamAudioManager.clearCommunicationDevice(audioManager) } answers { false } + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + + // When + manager.clearDevice() + + // Then + // Should not crash - the implementation handles the failure gracefully + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `getSelectedDevice returns null when no device is selected`() { + // When + val device = manager.getSelectedDevice() + + // Then + assertThat(device).isNull() + } + + @Test + fun `getSelectedDevice returns selected device`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + // Mock getCommunicationDevice to return null so it uses the stored selectedDevice + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + + // When + manager.selectDevice(device) + val selectedDevice = manager.getSelectedDevice() + + // Then + assertThat(selectedDevice).isEqualTo(device) + } + + @Test + fun `getSelectedDevice gets device from AudioManager when available`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + every { audioDeviceInfo.productName } returns "Speaker" + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns audioDeviceInfo + + // When + val selectedDevice = manager.getSelectedDevice() + + // Then + assertThat(selectedDevice).isNotNull() + assertThat(selectedDevice).isInstanceOf(StreamAudioDevice.Speakerphone::class.java) + } + + @Test + fun `getSelectedDevice returns stored device when AudioManager returns null`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + manager.selectDevice(device) + + // When + val selectedDevice = manager.getSelectedDevice() + + // Then + assertThat(selectedDevice).isEqualTo(device) + } + + @Test + fun `getSelectedDevice handles exception when getting communication device`() { + // Given + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + + // When + val device = manager.getSelectedDevice() + + // Then + // Should not crash and return null + assertThat(device).isNull() + } + + @Test + fun `getSelectedDevice returns null when AudioManager device cannot be converted`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_HDMI // Unsupported type + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns audioDeviceInfo + + // When + val selectedDevice = manager.getSelectedDevice() + + // Then + assertThat(selectedDevice).isNull() + } + + @Test + fun `start does nothing for modern API`() { + // When + manager.start() + + // Then + // Should not crash - no special setup needed + } + + @Test + fun `stop clears device`() { + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + val device = StreamAudioDevice.Speakerphone(audioDeviceInfo = audioDeviceInfo) + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + every { StreamAudioManager.clearCommunicationDevice(audioManager) } answers { true } + every { StreamAudioManager.getCommunicationDevice(audioManager) } returns null + manager.selectDevice(device) + + // When + manager.stop() + + // Then + verify { StreamAudioManager.clearCommunicationDevice(audioManager) } + assertThat(manager.getSelectedDevice()).isNull() + } + + @Test + fun `enumerateDevices handles USB headset type`() { + // Given + val usbHeadsetDevice = mockk(relaxed = true) + every { usbHeadsetDevice.type } returns AudioDeviceInfo.TYPE_USB_HEADSET + every { usbHeadsetDevice.productName } returns "USB Headset" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(usbHeadsetDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.WiredHeadset::class.java) + } + + @Test + fun `enumerateDevices handles wired headphones type`() { + // Given + val wiredHeadphonesDevice = mockk(relaxed = true) + every { wiredHeadphonesDevice.type } returns AudioDeviceInfo.TYPE_WIRED_HEADPHONES + every { wiredHeadphonesDevice.productName } returns "Wired Headphones" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(wiredHeadphonesDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.WiredHeadset::class.java) + } + + @Test + fun `enumerateDevices handles Bluetooth A2DP type`() { + // Given + val bluetoothA2dpDevice = mockk(relaxed = true) + every { bluetoothA2dpDevice.type } returns AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + every { bluetoothA2dpDevice.productName } returns "Bluetooth A2DP" + every { + StreamAudioManager.getAvailableCommunicationDevices(audioManager) + } returns listOf(bluetoothA2dpDevice) + + // When + val devices = manager.enumerateDevices() + + // Then + assertThat(devices).hasSize(1) + assertThat(devices.first()).isInstanceOf(StreamAudioDevice.BluetoothHeadset::class.java) + } + + @Test + fun `selectDevice handles different device types correctly`() { + // Test each device type + val testCases = listOf( + Pair(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, StreamAudioDevice.Speakerphone()), + Pair(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, StreamAudioDevice.Earpiece()), + Pair(AudioDeviceInfo.TYPE_WIRED_HEADSET, StreamAudioDevice.WiredHeadset()), + Pair(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, StreamAudioDevice.BluetoothHeadset()), + ) + + testCases.forEach { (deviceType, device) -> + // Given + val audioDeviceInfo = mockk(relaxed = true) + every { audioDeviceInfo.type } returns deviceType + val deviceWithInfo = when (device) { + is StreamAudioDevice.Speakerphone -> StreamAudioDevice.Speakerphone( + audioDeviceInfo = audioDeviceInfo, + ) + is StreamAudioDevice.Earpiece -> StreamAudioDevice.Earpiece( + audioDeviceInfo = audioDeviceInfo, + ) + is StreamAudioDevice.WiredHeadset -> StreamAudioDevice.WiredHeadset( + audioDeviceInfo = audioDeviceInfo, + ) + is StreamAudioDevice.BluetoothHeadset -> StreamAudioDevice.BluetoothHeadset( + audioDeviceInfo = audioDeviceInfo, + ) + } + every { StreamAudioManager.setCommunicationDevice(audioManager, audioDeviceInfo) } answers { true } + + // When + val result = manager.selectDevice(deviceWithInfo) + + // Then + assertThat(result).isTrue() + assertThat(manager.getSelectedDevice()).isEqualTo(deviceWithInfo) + } + } +} From f22d2dce6d1831e590a5e9e0e75006896838ede8 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Thu, 8 Jan 2026 13:46:40 +0530 Subject: [PATCH 15/15] Removed Twilio audioSwitch library --- demo-app/build.gradle.kts | 2 - .../android/util/StreamVideoInitHelper.kt | 1 - gradle/libs.versions.toml | 3 - .../api/stream-video-android-core.api | 84 +++-------- stream-video-android-core/build.gradle.kts | 2 - .../io/getstream/video/android/core/Call.kt | 1 - .../video/android/core/MediaManager.kt | 22 +-- .../video/android/core/StreamVideoBuilder.kt | 6 - .../video/android/core/StreamVideoClient.kt | 1 - .../video/android/core/audio/AudioHandler.kt | 107 +------------ .../android/core/audio/AudioHandlerFactory.kt | 90 ----------- .../core/audio/LegacyAudioDeviceManager.kt | 2 +- .../android/core/audio/StreamAudioDevice.kt | 140 ++---------------- .../android/core/audio/StreamAudioSwitch.kt | 4 +- .../core/audio/StreamAudioSwitchHandler.kt | 2 +- .../android/core/call/state/CallAction.kt | 8 - .../android/core/MicrophoneManagerTest.kt | 4 +- 17 files changed, 46 insertions(+), 433 deletions(-) delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt diff --git a/demo-app/build.gradle.kts b/demo-app/build.gradle.kts index 63edad05124..1d2525eae26 100644 --- a/demo-app/build.gradle.kts +++ b/demo-app/build.gradle.kts @@ -290,8 +290,6 @@ dependencies { // Http implementation(libs.okhttp) - implementation(libs.audioswitch) - // Logging implementation(libs.okhttp.logging) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index e5780a33b65..0bd321a37fd 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -335,7 +335,6 @@ object StreamVideoInitHelper { telecomConfig = TelecomConfig( context.packageName, ), - useInBuiltAudioSwitch = true, ).build() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1465b02ecfd..59e365d7782 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,6 @@ coil = "2.6.0" landscapist = "2.4.2" accompanist = "0.34.0" telephoto = "0.3.0" -audioswitch = "1.2.0" libyuv = "0.36.0" wire = "4.7.0" @@ -139,8 +138,6 @@ accompanist-permission = { group = "com.google.accompanist", name = "accompanist stream-video-android-noise-cancellation = { module = "io.getstream:stream-video-android-noise-cancellation", version.ref = "streamNoiseCancellation" } telephoto = { group = "me.saket.telephoto", name = "zoomable", version.ref = "telephoto" } -audioswitch = { group = "com.twilio", name = "audioswitch", version.ref = "audioswitch"} - libyuv = { group = "io.github.crow-misia.libyuv", name = "libyuv-android", version.ref = "libyuv"} wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version.ref = "wire" } diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index ad7dad6008a..b8052254359 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8019,8 +8019,8 @@ public final class io/getstream/video/android/core/LocalStats { } public final class io/getstream/video/android/core/MediaManagerImpl { - public fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;Z)V - public synthetic fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;)V + public synthetic fun (Landroid/content/Context;Lio/getstream/video/android/core/Call;Lkotlinx/coroutines/CoroutineScope;Lorg/webrtc/EglBase$Context;ILkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun cleanup ()V public final fun getAudioSource ()Lorg/webrtc/AudioSource; public final fun getAudioTrack ()Lorg/webrtc/AudioTrack; @@ -8032,7 +8032,6 @@ public final class io/getstream/video/android/core/MediaManagerImpl { public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; public final fun getScreenShareTrack ()Lorg/webrtc/VideoTrack; public final fun getScreenShareVideoSource ()Lorg/webrtc/VideoSource; - public final fun getUseInBuiltAudioSwitch ()Z public final fun getVideoSource ()Lorg/webrtc/VideoSource; public final fun getVideoTrack ()Lorg/webrtc/VideoTrack; public final fun setAudioTrack (Lorg/webrtc/AudioTrack;)V @@ -8466,8 +8465,7 @@ public final class io/getstream/video/android/core/StreamVideoBuilder { public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZ)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZ)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;)V - public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;Z)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun build ()Lio/getstream/video/android/core/StreamVideo; } @@ -8510,40 +8508,23 @@ public abstract interface class io/getstream/video/android/core/audio/AudioHandl public abstract fun stop ()V } -public final class io/getstream/video/android/core/audio/AudioSwitchHandler : io/getstream/video/android/core/audio/AudioHandler { - public static final field Companion Lio/getstream/video/android/core/audio/AudioSwitchHandler$Companion; - public fun (Landroid/content/Context;Ljava/util/List;Lkotlin/jvm/functions/Function2;)V - public final fun selectDevice (Lcom/twilio/audioswitch/AudioDevice;)V - public fun selectDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V - public fun start ()V - public fun stop ()V -} - -public final class io/getstream/video/android/core/audio/AudioSwitchHandler$Companion { -} - public abstract class io/getstream/video/android/core/audio/StreamAudioDevice { public static final field Companion Lio/getstream/video/android/core/audio/StreamAudioDevice$Companion; - public static final fun fromAudio (Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice; public static final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice; - public abstract fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; public abstract fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public abstract fun getName ()Ljava/lang/String; - public static final fun toAudioDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)Lcom/twilio/audioswitch/AudioDevice; public static final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/StreamAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; } public final class io/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset : io/getstream/video/android/core/audio/StreamAudioDevice { public fun ()V - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun component3 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$BluetoothHeadset; public fun equals (Ljava/lang/Object;)Z - public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I @@ -8551,23 +8532,19 @@ public final class io/getstream/video/android/core/audio/StreamAudioDevice$Bluet } public final class io/getstream/video/android/core/audio/StreamAudioDevice$Companion { - public final fun fromAudio (Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/audio/StreamAudioDevice; public final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice; - public final fun toAudioDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)Lcom/twilio/audioswitch/AudioDevice; public final fun toAudioDeviceInfo (Lio/getstream/video/android/core/audio/StreamAudioDevice;Landroid/media/AudioManager;)Landroid/media/AudioDeviceInfo; } public final class io/getstream/video/android/core/audio/StreamAudioDevice$Earpiece : io/getstream/video/android/core/audio/StreamAudioDevice { public fun ()V - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun component3 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Earpiece; public fun equals (Ljava/lang/Object;)Z - public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I @@ -8576,15 +8553,13 @@ public final class io/getstream/video/android/core/audio/StreamAudioDevice$Earpi public final class io/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone : io/getstream/video/android/core/audio/StreamAudioDevice { public fun ()V - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun component3 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$Speakerphone; public fun equals (Ljava/lang/Object;)Z - public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I @@ -8593,15 +8568,13 @@ public final class io/getstream/video/android/core/audio/StreamAudioDevice$Speak public final class io/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset : io/getstream/video/android/core/audio/StreamAudioDevice { public fun ()V - public fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)V - public synthetic fun (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)V + public synthetic fun (Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun component3 ()Landroid/media/AudioDeviceInfo; - public final fun copy (Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; - public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset;Ljava/lang/String;Lcom/twilio/audioswitch/AudioDevice;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; + public final fun component2 ()Landroid/media/AudioDeviceInfo; + public final fun copy (Ljava/lang/String;Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; + public static synthetic fun copy$default (Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset;Ljava/lang/String;Landroid/media/AudioDeviceInfo;ILjava/lang/Object;)Lio/getstream/video/android/core/audio/StreamAudioDevice$WiredHeadset; public fun equals (Ljava/lang/Object;)Z - public fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; public fun getAudioDeviceInfo ()Landroid/media/AudioDeviceInfo; public fun getName ()Ljava/lang/String; public fun hashCode ()I @@ -8833,17 +8806,6 @@ public final class io/getstream/video/android/core/call/state/Reaction : io/gets public fun toString ()Ljava/lang/String; } -public final class io/getstream/video/android/core/call/state/SelectAudioDevice : io/getstream/video/android/core/call/state/CallAction { - public fun (Lcom/twilio/audioswitch/AudioDevice;)V - public final fun component1 ()Lcom/twilio/audioswitch/AudioDevice; - public final fun copy (Lcom/twilio/audioswitch/AudioDevice;)Lio/getstream/video/android/core/call/state/SelectAudioDevice; - public static synthetic fun copy$default (Lio/getstream/video/android/core/call/state/SelectAudioDevice;Lcom/twilio/audioswitch/AudioDevice;ILjava/lang/Object;)Lio/getstream/video/android/core/call/state/SelectAudioDevice; - public fun equals (Ljava/lang/Object;)Z - public final fun getAudioDevice ()Lcom/twilio/audioswitch/AudioDevice; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class io/getstream/video/android/core/call/state/Settings : io/getstream/video/android/core/call/state/CallAction { public fun (Z)V public final fun component1 ()Z diff --git a/stream-video-android-core/build.gradle.kts b/stream-video-android-core/build.gradle.kts index 109638224a7..b7a58b3900d 100644 --- a/stream-video-android-core/build.gradle.kts +++ b/stream-video-android-core/build.gradle.kts @@ -189,8 +189,6 @@ dependencies { implementation(libs.tink) implementation(libs.androidx.media.media) - // Twilio AudioSwitch - use api() so it's available to consumers who need to access audio property - api(libs.audioswitch) // unit tests testImplementation(libs.stream.result) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 6d40751fc1b..f6b9df8a89e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -348,7 +348,6 @@ public class Call( eglBaseContext = eglBase.eglBaseContext, audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, - useInBuiltAudioSwitch = clientImpl.useInBuiltAudioSwitch, ) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index 2eef17b07a8..836b8773de2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -40,9 +40,8 @@ import io.getstream.android.video.generated.models.VideoSettingsResponse import io.getstream.log.taggedLogger import io.getstream.result.extractCause import io.getstream.video.android.core.audio.AudioHandler -import io.getstream.video.android.core.audio.AudioHandlerFactory import io.getstream.video.android.core.audio.StreamAudioDevice -import io.getstream.video.android.core.audio.StreamAudioDevice.Companion.fromAudio +import io.getstream.video.android.core.audio.StreamAudioSwitchHandler import io.getstream.video.android.core.call.video.FilterVideoProcessor import io.getstream.video.android.core.camera.CameraCharacteristicsValidator import io.getstream.video.android.core.camera.DefaultCameraCharacteristicsValidator @@ -754,21 +753,10 @@ class MicrophoneManager( ) } - audioHandler = AudioHandlerFactory.create( + audioHandler = StreamAudioSwitchHandler( context = mediaManager.context, - preferredStreamAudioDeviceList = preferredDeviceList, - twilioAudioDeviceChangeListener = { devices, selected -> - logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } - - _devices.value = devices.map { it.fromAudio() } - _selectedDevice.value = selected?.fromAudio() - - setupCompleted = true - - capturedOnAudioDevicesUpdate?.invoke() - capturedOnAudioDevicesUpdate = null - }, - streamAudioDeviceChangeListener = { devices, selected -> + preferredDeviceList = preferredDeviceList, + audioDeviceChangeListener = { devices, selected -> logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } _devices.value = devices _selectedDevice.value = selected @@ -778,7 +766,6 @@ class MicrophoneManager( capturedOnAudioDevicesUpdate?.invoke() capturedOnAudioDevicesUpdate = null }, - useInBuiltAudioSwitch = mediaManager.useInBuiltAudioSwitch, ) logger.d { "[setup] Calling start on instance $audioHandler" } @@ -1267,7 +1254,6 @@ class MediaManagerImpl( @Deprecated("Use audioUsageProvider instead", replaceWith = ReplaceWith("audioUsageProvider")) val audioUsage: Int = defaultAudioUsage, val audioUsageProvider: (() -> Int) = { audioUsage }, - val useInBuiltAudioSwitch: Boolean = false, ) { internal val camera = CameraManager(this, eglBaseContext, DefaultCameraCharacteristicsValidator()) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index ebcde7e6dc8..be76766b99a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -146,11 +146,6 @@ public class StreamVideoBuilder @JvmOverloads constructor( @InternalStreamVideoApi private val enableStereoForSubscriber: Boolean = true, private val telecomConfig: TelecomConfig? = null, - /** - * If true, uses the custom StreamAudioSwitch implementation (native Android APIs). - * If false, uses Twilio's AudioSwitch library (default for backward compatibility). - */ - private val useInBuiltAudioSwitch: Boolean = false, ) { private val context: Context = context.applicationContext private val scope = UserScope(ClientScope()) @@ -280,7 +275,6 @@ public class StreamVideoBuilder @JvmOverloads constructor( enableStereoForSubscriber = enableStereoForSubscriber, telecomConfig = telecomConfig, tokenRepository = tokenRepository, - useInBuiltAudioSwitch = useInBuiltAudioSwitch, ) if (user.type == UserType.Guest) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index c01318981a1..50cbf1bfaf1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -180,7 +180,6 @@ internal class StreamVideoClient internal constructor( internal val enableStatsCollection: Boolean = true, internal val enableStereoForSubscriber: Boolean = true, internal val telecomConfig: TelecomConfig? = null, - internal val useInBuiltAudioSwitch: Boolean = false, ) : StreamVideo, NotificationHandler by streamNotificationManager { private var locationJob: Deferred>? = null diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt index 92a88b056de..b84d1fc1c56 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt @@ -16,115 +16,16 @@ package io.getstream.video.android.core.audio -import android.content.Context -import android.media.AudioManager -import android.os.Handler -import android.os.Looper -import com.twilio.audioswitch.AudioDevice -import com.twilio.audioswitch.AudioDeviceChangeListener -import com.twilio.audioswitch.AudioSwitch -import io.getstream.log.StreamLog -import io.getstream.log.taggedLogger - -public interface AudioHandler { +interface AudioHandler { /** * Called when a room is started. */ - public fun start() + fun start() /** * Called when a room is disconnected. */ - public fun stop() - - public fun selectDevice(audioDevice: StreamAudioDevice?) -} - -/** - * TODO: this class should be merged into the Microphone Manager - */ -public class AudioSwitchHandler( - private val context: Context, - private val preferredDeviceList: List>, - private var audioDeviceChangeListener: AudioDeviceChangeListener, -) : AudioHandler { - - private val logger by taggedLogger(TAG) - private var audioSwitch: AudioSwitch? = null - private val mainThreadHandler = Handler(Looper.getMainLooper()) - private var isAudioSwitchInitScheduled = false - - override fun start() { - synchronized(this) { - if (!isAudioSwitchInitScheduled) { - isAudioSwitchInitScheduled = true - mainThreadHandler.removeCallbacksAndMessages(null) - - logger.d { "[start] Posting on main" } - - mainThreadHandler.post { - logger.d { "[start] Running on main" } - - val switch = AudioSwitch( - context = context, - audioFocusChangeListener = onAudioFocusChangeListener, - preferredDeviceList = preferredDeviceList, - ) - audioSwitch = switch - switch.start(audioDeviceChangeListener) - } - } - } - } - - override fun stop() { - logger.d { "[stop] no args" } - mainThreadHandler.removeCallbacksAndMessages(null) - mainThreadHandler.post { - audioSwitch?.stop() - audioSwitch = null - } - } - - override fun selectDevice(audioDevice: StreamAudioDevice?) { - val twilioDevice = convertStreamDeviceToTwilioDevice(audioDevice) - selectDevice(twilioDevice) - } - - public fun selectDevice(audioDevice: AudioDevice?) { - logger.i { "[selectDevice] audioDevice: $audioDevice" } - audioSwitch?.selectDevice(audioDevice) - audioSwitch?.activate() - } - - /** - * Converts a StreamAudioDevice to Twilio's AudioDevice. - * Returns null if the input is null. - */ - private fun convertStreamDeviceToTwilioDevice(streamDevice: StreamAudioDevice?): AudioDevice? { - return streamDevice?.audio - } - - public companion object { - private const val TAG = "AudioSwitchHandler" - private val onAudioFocusChangeListener by lazy(LazyThreadSafetyMode.NONE) { - DefaultOnAudioFocusChangeListener() - } + fun stop() - private class DefaultOnAudioFocusChangeListener : AudioManager.OnAudioFocusChangeListener { - override fun onAudioFocusChange(focusChange: Int) { - val typeOfChange: String = when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN -> "AUDIOFOCUS_GAIN" - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> "AUDIOFOCUS_GAIN_TRANSIENT" - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE" - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK" - AudioManager.AUDIOFOCUS_LOSS -> "AUDIOFOCUS_LOSS" - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "AUDIOFOCUS_LOSS_TRANSIENT" - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK" - else -> "AUDIOFOCUS_INVALID" - } - StreamLog.i(TAG) { "[onAudioFocusChange] focusChange: $typeOfChange" } - } - } - } + fun selectDevice(audioDevice: StreamAudioDevice?) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt deleted file mode 100644 index ba4c8c2c551..00000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandlerFactory.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.core.audio - -import android.content.Context -import com.twilio.audioswitch.AudioDevice -import com.twilio.audioswitch.AudioDeviceChangeListener as TwilioAudioDeviceChangeListener - -/** - * Factory for creating AudioHandler instances. - * Supports both Twilio AudioSwitch (legacy) and custom StreamAudioSwitch implementations. - */ -internal object AudioHandlerFactory { - /** - * Creates an AudioHandler instance based on the useInBuiltAudioSwitch flag. - * - * @param context Android context - * @param preferredStreamAudioDeviceList List of preferred audio device types in priority order - * @param twilioAudioDeviceChangeListener Callback for device changes - * @param useInBuiltAudioSwitch If true, uses custom StreamAudioSwitch; if false, uses Twilio AudioSwitch - * @return An AudioHandler instance - */ - fun create( - context: Context, - preferredStreamAudioDeviceList: List>, - twilioAudioDeviceChangeListener: TwilioAudioDeviceChangeListener, - streamAudioDeviceChangeListener: StreamAudioDeviceChangeListener, - useInBuiltAudioSwitch: Boolean, - ): AudioHandler { - return if (useInBuiltAudioSwitch) { - StreamAudioSwitchHandler( - context = context, - preferredDeviceList = preferredStreamAudioDeviceList, - audioDeviceChangeListener = streamAudioDeviceChangeListener, - ) - } else { - // Twilio audio switcher - // Convert StreamAudioDevice types to Twilio AudioDevice types - val twilioPreferredDevices = preferredStreamAudioDeviceList.map { streamDeviceClass -> - when (streamDeviceClass) { - StreamAudioDevice.BluetoothHeadset::class.java -> AudioDevice.BluetoothHeadset::class.java - StreamAudioDevice.WiredHeadset::class.java -> AudioDevice.WiredHeadset::class.java - StreamAudioDevice.Earpiece::class.java -> AudioDevice.Earpiece::class.java - StreamAudioDevice.Speakerphone::class.java -> AudioDevice.Speakerphone::class.java - else -> AudioDevice.Speakerphone::class.java // fallback - } - } - - AudioSwitchHandler( - context = context, - preferredDeviceList = twilioPreferredDevices, - audioDeviceChangeListener = twilioAudioDeviceChangeListener, - ) - } - } - - /** - * Converts a Twilio AudioDevice to StreamAudioDevice. - */ - private fun convertTwilioDeviceToStreamDevice(twilioDevice: AudioDevice): StreamAudioDevice { - return when (twilioDevice) { - is AudioDevice.BluetoothHeadset -> StreamAudioDevice.BluetoothHeadset( - audio = twilioDevice, - ) - is AudioDevice.WiredHeadset -> StreamAudioDevice.WiredHeadset( - audio = twilioDevice, - ) - is AudioDevice.Earpiece -> StreamAudioDevice.Earpiece( - audio = twilioDevice, - ) - is AudioDevice.Speakerphone -> StreamAudioDevice.Speakerphone( - audio = twilioDevice, - ) - } - } -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt index 5af931b52eb..5c225fa45d2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt @@ -708,7 +708,7 @@ internal class LegacyAudioDeviceManager( } /** - * Handles Bluetooth connection failure by reverting to earpiece (matching Twilio behavior). + * Handles Bluetooth connection failure by reverting to earpiece */ private fun handleBluetoothConnectionFailure() { logger.w { "[handleBluetoothConnectionFailure] Bluetooth connection failed, reverting to earpiece" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt index 2133e56d908..daf36b2ba12 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioDevice.kt @@ -20,94 +20,49 @@ import android.media.AudioDeviceInfo import android.media.AudioManager import android.os.Build import androidx.annotation.RequiresApi -import com.twilio.audioswitch.AudioDevice - -// Lazy initialization of default Twilio AudioDevice instances -private val defaultBluetoothHeadsetAudioDevice: AudioDevice by lazy { - StreamAudioDevice.Companion.createTwilioBluetoothHeadset() -} -private val defaultWiredHeadsetAudioDevice: AudioDevice by lazy { - StreamAudioDevice.Companion.createTwilioWiredHeadset() -} -private val defaultEarpieceAudioDevice: AudioDevice by lazy { - StreamAudioDevice.Companion.createTwilioEarpiece() -} -private val defaultSpeakerphoneAudioDevice: AudioDevice by lazy { - StreamAudioDevice.Companion.createTwilioSpeakerphone() -} /** * Represents an audio device for audio switching. - * Supports both Twilio's AudioSwitch implementation and native Android audio device management. * - * @see AudioDevice * @see AudioDeviceInfo */ -public sealed class StreamAudioDevice { +sealed class StreamAudioDevice { /** The friendly name of the device.*/ - public abstract val name: String - - /** - * The Twilio AudioDevice instance. - * Used when using Twilio's AudioSwitch implementation. - */ - @Deprecated( - message = "Use audioDeviceInfo instead for native Android audio device management", - replaceWith = ReplaceWith("audioDeviceInfo"), - ) - public abstract val audio: AudioDevice + abstract val name: String /** * The Android AudioDeviceInfo instance. * This provides device identification and capabilities when using native Android audio management. * @see android.media.AudioDeviceInfo */ - public abstract val audioDeviceInfo: AudioDeviceInfo? + abstract val audioDeviceInfo: AudioDeviceInfo? /** A [StreamAudioDevice] representing a Bluetooth Headset.*/ - public data class BluetoothHeadset constructor( + data class BluetoothHeadset constructor( override val name: String = "Bluetooth", - override val audio: AudioDevice = defaultBluetoothHeadsetAudioDevice, override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() /** A [StreamAudioDevice] representing a Wired Headset.*/ - public data class WiredHeadset constructor( + data class WiredHeadset constructor( override val name: String = "Wired Headset", - override val audio: AudioDevice = defaultWiredHeadsetAudioDevice, override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() /** A [StreamAudioDevice] representing the Earpiece.*/ - public data class Earpiece constructor( + data class Earpiece constructor( override val name: String = "Earpiece", - override val audio: AudioDevice = defaultEarpieceAudioDevice, override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() /** A [StreamAudioDevice] representing the Speakerphone.*/ - public data class Speakerphone constructor( + data class Speakerphone constructor( override val name: String = "Speakerphone", - override val audio: AudioDevice = defaultSpeakerphoneAudioDevice, override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - public companion object { - @JvmStatic - public fun StreamAudioDevice.toAudioDevice(): AudioDevice { - return this.audio - } - - @JvmStatic - public fun AudioDevice.fromAudio(): StreamAudioDevice { - return when (this) { - is AudioDevice.BluetoothHeadset -> BluetoothHeadset(audio = this) - is AudioDevice.WiredHeadset -> WiredHeadset(audio = this) - is AudioDevice.Earpiece -> Earpiece(audio = this) - is AudioDevice.Speakerphone -> Speakerphone(audio = this) - } - } + companion object { /** * Converts an Android AudioDeviceInfo to a StreamAudioDevice. @@ -115,7 +70,7 @@ public sealed class StreamAudioDevice { * Available from API 23+ (always available since minSdk is 24). */ @JvmStatic - public fun fromAudioDeviceInfo(deviceInfo: AudioDeviceInfo): StreamAudioDevice? { + fun fromAudioDeviceInfo(deviceInfo: AudioDeviceInfo): StreamAudioDevice? { return when (deviceInfo.type) { AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, @@ -218,82 +173,5 @@ public sealed class StreamAudioDevice { } } } - - /** - * Creates a Twilio AudioDevice.BluetoothHeadset instance using reflection. - */ - internal fun createTwilioBluetoothHeadset(): AudioDevice { - return createTwilioAudioDevice(AudioDevice.BluetoothHeadset::class.java) - } - - /** - * Creates a Twilio AudioDevice.WiredHeadset instance using reflection. - */ - internal fun createTwilioWiredHeadset(): AudioDevice { - return createTwilioAudioDevice(AudioDevice.WiredHeadset::class.java) - } - - /** - * Creates a Twilio AudioDevice.Earpiece instance using reflection. - */ - internal fun createTwilioEarpiece(): AudioDevice { - return createTwilioAudioDevice(AudioDevice.Earpiece::class.java) - } - - /** - * Creates a Twilio AudioDevice.Speakerphone instance using reflection. - */ - internal fun createTwilioSpeakerphone(): AudioDevice { - return createTwilioAudioDevice(AudioDevice.Speakerphone::class.java) - } - - /** - * Generic method to create Twilio AudioDevice instances using reflection. - * Accesses the private constructor and creates an instance. - */ - @Suppress("UNCHECKED_CAST") - private fun createTwilioAudioDevice(deviceClass: Class): T { - // Get all declared constructors - val constructors = deviceClass.declaredConstructors - if (constructors.isEmpty()) { - throw IllegalStateException("No constructor found for ${deviceClass.simpleName}") - } - - // Try each constructor with different parameter combinations - var lastException: Exception? = null - for (constructor in constructors) { - try { - constructor.isAccessible = true - val paramTypes = constructor.parameterTypes - - // Create appropriate arguments based on parameter types - val args = paramTypes.map { paramType -> - when { - paramType == String::class.java -> "" - paramType.isPrimitive -> when (paramType.name) { - "int", "long" -> 0 - "boolean" -> false - "float", "double" -> 0.0 - else -> null - } - else -> null - } - }.toTypedArray() - - @Suppress("UNCHECKED_CAST") - return constructor.newInstance(*args) as T - } catch (e: Exception) { - lastException = e - // Try next constructor - continue - } - } - - throw IllegalStateException( - "Failed to create Twilio AudioDevice instance for ${deviceClass.simpleName}. " + - "None of the constructors could be invoked.", - lastException, - ) - } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt index 34e1c9044cf..2f3efb2147e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitch.kt @@ -145,7 +145,7 @@ internal class StreamAudioSwitch( logger.d { "[stop] Stopping AudioSwitch" } - // Deactivate audio routing (matches Twilio behavior) + // Deactivate audio routing deactivate() // Abandon audio focus @@ -431,7 +431,7 @@ internal class StreamAudioSwitch( private const val TAG = "StreamAudioSwitch" /** - * Returns the default preferred device list matching Twilio's priority: + * Returns the default preferred device list: * BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone */ @JvmStatic diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt index 913cbebaae2..64ae968f644 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt @@ -72,7 +72,7 @@ internal class StreamAudioSwitchHandler( streamAudioSwitch?.selectDevice(audioDevice) } - public companion object { + companion object { private const val TAG = "StreamAudioSwitchHandler" } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt index 3d57e60686b..dafc69085b5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt @@ -16,7 +16,6 @@ package io.getstream.video.android.core.call.state -import com.twilio.audioswitch.AudioDevice import io.getstream.video.android.core.ParticipantState /** @@ -31,13 +30,6 @@ public data class ToggleSpeakerphone( val isEnabled: Boolean, ) : CallAction -/** - * Action to select an audio device for playback. - */ -public data class SelectAudioDevice( - val audioDevice: AudioDevice, -) : CallAction - /** * Action to toggle if the camera is on or off. */ diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index fd8b6b48654..8c73c718f63 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -20,7 +20,7 @@ import android.content.Context import android.media.AudioAttributes import android.media.AudioManager import io.getstream.android.video.generated.models.OwnCapability -import io.getstream.video.android.core.audio.AudioSwitchHandler +import io.getstream.video.android.core.audio.AudioHandler import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -60,7 +60,7 @@ class MicrophoneManagerTest { every { microphoneManager.setup(any(), capture(slot)) } answers { slot.captured.invoke() } every { microphoneManager["ifAudioHandlerInitialized"]( - any<(AudioSwitchHandler) -> Unit>(), + any<(AudioHandler) -> Unit>(), ) } answers { true }