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/ui/menu/SettingsMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt index 5871b425807..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 @@ -216,11 +216,45 @@ internal fun SettingsMenu( 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 + val isSelected = when { + selected == null -> false + else -> { + // First try to compare by audioDeviceInfo ID if both have it + val itInfoId = when (it) { + 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 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) { + // Both have audioDeviceInfo, compare by ID + itInfoId == selectedInfoId + } else { + // Fall back to type comparison + when { + 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 + } + } + } + } AudioDeviceUiState( it, it.name, icon, - it.audio.name == selectedMicroPhoneDevice?.audio?.name, + it.audioDeviceInfo?.id == selectedMicroPhoneDevice?.audioDeviceInfo?.id, ) } 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 d794f23ed83..b8052254359 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8503,85 +8503,79 @@ 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 selectDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)V public abstract fun start ()V 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 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 abstract fun getAudio ()Lcom/twilio/audioswitch/AudioDevice; + public static final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice; + 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;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 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 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 public fun toString ()Ljava/lang/String; } 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 toAudioDevice (Lio/getstream/video/android/core/audio/StreamAudioDevice;)Lcom/twilio/audioswitch/AudioDevice; + public final fun fromAudioDeviceInfo (Landroid/media/AudioDeviceInfo;)Lio/getstream/video/android/core/audio/StreamAudioDevice; + 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;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 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 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 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;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 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 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 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;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 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 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 public fun toString ()Ljava/lang/String; @@ -8812,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 fa87e3d674e..b7a58b3900d 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) 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 13907dbe567..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 @@ -342,12 +342,13 @@ 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 }, + ) } } @@ -1351,7 +1352,9 @@ 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: ${ + microphone.nonHeadsetFallbackDevice + }" } val bluetoothHeadset = 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..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 @@ -36,15 +36,12 @@ 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.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.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 @@ -635,7 +632,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) { @@ -739,27 +736,30 @@ 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, + ) + } + + audioHandler = StreamAudioSwitchHandler( 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, audioDeviceChangeListener = { devices, selected -> logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } - - _devices.value = devices.map { it.fromAudio() } - _selectedDevice.value = selected?.fromAudio() + _devices.value = devices + _selectedDevice.value = selected setupCompleted = true @@ -782,9 +782,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." } } 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..953c44b7f87 --- /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 StreamAudioDevice 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: StreamAudioDevice): Boolean + + /** + * Clears the current device selection. + */ + fun clearDevice() + + /** + * Gets the currently selected device. + */ + fun getSelectedDevice(): StreamAudioDevice? + + /** + * 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..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,100 +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() -} - -/** - * 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 - } - } - - public fun selectDevice(audioDevice: AudioDevice?) { - logger.i { "[selectDevice] audioDevice: $audioDevice" } - audioSwitch?.selectDevice(audioDevice) - audioSwitch?.activate() - } - - 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/LegacyAudioDeviceManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/LegacyAudioDeviceManager.kt new file mode 100644 index 00000000000..5c225fa45d2 --- /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: StreamAudioDevice? = 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 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 + } + // 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: ${streamAudioDevice.name}, type=${androidDevice.type}, address=$address" + } + 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: ${streamAudioDevice.name}, address=$address" + } + bluetoothDevicesByAddress[address] = streamAudioDevice + } 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: ${streamAudioDevice::class.simpleName} (${streamAudioDevice.name})" + } + devices.add(streamAudioDevice) + } + } + } else { + logger.w { + "[enumerateDevices] Could not convert AudioDeviceInfo to StreamAudioDevice: 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 StreamAudioDevice.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(StreamAudioDevice.WiredHeadset()) + } + } + + // Add speakerphone - always available + if (devices.none { it is StreamAudioDevice.Speakerphone }) { + logger.d { "[enumerateDevices] Adding Speakerphone (always available)" } + devices.add(StreamAudioDevice.Speakerphone()) + } + + // Add earpiece only if device has telephony feature (is a phone) + if (devices.none { it is StreamAudioDevice.Earpiece }) { + val hasEarpiece = hasEarpiece(context) + if (hasEarpiece) { + logger.d { "[enumerateDevices] Adding Earpiece (device has telephony feature)" } + devices.add(StreamAudioDevice.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: StreamAudioDevice): Boolean { + when (device) { + is StreamAudioDevice.Speakerphone -> { + stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = true + selectedDevice = device + return true + } + is StreamAudioDevice.Earpiece -> { + stopBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + selectedDevice = device + return true + } + is StreamAudioDevice.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 StreamAudioDevice.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(): StreamAudioDevice? = 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 StreamAudioDevice for the Bluetooth headset + // Note: We don't have AudioDeviceInfo here, but that's okay - we know it supports SCO + devices.add( + StreamAudioDevice.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 + */ + 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..f7379e0c768 --- /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: StreamAudioDevice? = 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 streamAudioDevices = mutableListOf() + + for (androidDevice in androidDevices) { + val streamAudioDevice = StreamAudioDevice.fromAudioDeviceInfo(androidDevice) + if (streamAudioDevice != null) { + logger.d { + "[enumerateDevices] Detected device: ${streamAudioDevice::class.simpleName} (${streamAudioDevice.name})" + } + streamAudioDevices.add(streamAudioDevice) + } else { + logger.w { + "[enumerateDevices] Could not convert AudioDeviceInfo to StreamAudioDevice: type=${androidDevice.type}, name=${androidDevice.productName}" + } + } + } + + logger.d { "[enumerateDevices] Total enumerated devices: ${streamAudioDevices.size}" } + streamAudioDevices.forEachIndexed { index, device -> + logger.d { "[enumerateDevices] Final device $index: ${device::class.simpleName} (${device.name})" } + } + + return streamAudioDevices + } + + override fun selectDevice(device: StreamAudioDevice): Boolean { + val androidDevice = device.audioDeviceInfo + ?: StreamAudioDevice.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(): StreamAudioDevice? { + // Try to get from AudioManager first + val currentDevice = StreamAudioManager.getCommunicationDevice(audioManager) + if (currentDevice != null) { + val streamAudioDevice = StreamAudioDevice.fromAudioDeviceInfo(currentDevice) + if (streamAudioDevice != null) { + selectedDevice = streamAudioDevice + return streamAudioDevice + } + } + 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..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 @@ -16,53 +16,161 @@ package io.getstream.video.android.core.audio -import com.twilio.audioswitch.AudioDevice +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi +/** + * Represents an audio device for audio switching. + * + * @see AudioDeviceInfo + */ sealed class StreamAudioDevice { /** The friendly name of the device.*/ abstract val name: String - abstract val audio: AudioDevice + /** + * The Android AudioDeviceInfo instance. + * This provides device identification and capabilities when using native Android audio management. + * @see android.media.AudioDeviceInfo + */ + abstract val audioDeviceInfo: AudioDeviceInfo? - /** An [StreamAudioDevice] representing a Bluetooth Headset.*/ + /** A [StreamAudioDevice] representing a Bluetooth Headset.*/ data class BluetoothHeadset constructor( override val name: String = "Bluetooth", - override val audio: AudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - /** An [StreamAudioDevice] representing a Wired Headset.*/ + /** A [StreamAudioDevice] representing a Wired Headset.*/ data class WiredHeadset constructor( override val name: String = "Wired Headset", - override val audio: AudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - /** An [StreamAudioDevice] representing the Earpiece.*/ + /** A [StreamAudioDevice] representing the Earpiece.*/ data class Earpiece constructor( override val name: String = "Earpiece", - override val audio: AudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() - /** An [StreamAudioDevice] representing the Speakerphone.*/ + /** A [StreamAudioDevice] representing the Speakerphone.*/ data class Speakerphone constructor( override val name: String = "Speakerphone", - override val audio: AudioDevice, + override val audioDeviceInfo: AudioDeviceInfo? = null, ) : StreamAudioDevice() companion object { + /** + * 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 - fun StreamAudioDevice.toAudioDevice(): AudioDevice { - return this.audio + 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 - 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) + 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 + } + } + } } } } 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..2f3efb2147e --- /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 StreamAudioDevice. + */ +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 StreamAudioDevice 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: StreamAudioDeviceChangeListener? = 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: StreamAudioDevice? = null + + /** + * Starts monitoring audio devices and begins device enumeration. + * @param listener Callback that receives device updates + */ + public fun start(listener: StreamAudioDeviceChangeListener? = 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 + 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: StreamAudioDevice?) { + selectStreamAudioDevice(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(): StreamAudioDevice? = _selectedDeviceState.value + + /** + * Gets the list of available devices. + */ + public fun getAvailableDevices(): List = _availableDevices.value + + /** + * Selects a native audio device for routing. + * @param device The device to select, or null for automatic selection + */ + public fun selectStreamAudioDevice(device: StreamAudioDevice?) { + synchronized(this) { + if (!isStarted) { + logger.w { "[selectStreamAudioDevice] AudioSwitch not started" } + return + } + + logger.i { "[selectStreamAudioDevice] 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 StreamAudioDevice.BluetoothHeadset && + currentSelected != null && + currentSelected !is StreamAudioDevice.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) { + selectStreamAudioDevice(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(): StreamAudioDevice? { + 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 + selectStreamAudioDevice(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) { + selectStreamAudioDevice(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: + * BluetoothHeadset -> WiredHeadset -> Earpiece -> Speakerphone + */ + @JvmStatic + fun getDefaultPreferredDeviceList(): List> { + return listOf( + 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 new file mode 100644 index 00000000000..64ae968f644 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/StreamAudioSwitchHandler.kt @@ -0,0 +1,78 @@ +/* + * 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: StreamAudioDeviceChangeListener, +) : 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?) { + streamAudioSwitch?.selectDevice(audioDevice) + } + + 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 44007a375f7..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 } @@ -79,7 +79,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()) } } 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..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,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() @@ -53,7 +51,7 @@ class SpeakerManagerTest { every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(true) + speakerManager.setSpeakerPhone(true, null) // Then verify { microphoneManager.enforceSetup(preferSpeaker = true, any()) } @@ -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 @@ -87,7 +84,7 @@ class SpeakerManagerTest { every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false) + speakerManager.setSpeakerPhone(false, null) // Then verify { microphoneManager.enforceSetup(preferSpeaker = false, any()) } @@ -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) @@ -188,12 +182,16 @@ class SpeakerManagerTest { every { microphoneManager.select(capture(deviceSlot)) } answers { Unit } // When - speakerManager.setSpeakerPhone(false) + 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()) } // Verify the select method was called + verify { + microphoneManager.select( + any(), + ) + } // Verify the select method was called assertEquals(false, speakerManager.speakerPhoneEnabled.value) } 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) + } + } +}