diff --git a/packages/client/src/devices/SpeakerManager.ts b/packages/client/src/devices/SpeakerManager.ts index 204ef4f315..8735b50581 100644 --- a/packages/client/src/devices/SpeakerManager.ts +++ b/packages/client/src/devices/SpeakerManager.ts @@ -113,14 +113,17 @@ export class SpeakerManager { * @param volume a number between 0 and 1. Set it to `undefined` to use the default volume. */ setParticipantVolume(sessionId: string, volume: number | undefined) { - if (isReactNative()) { - throw new Error( - 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details', - ); - } if (volume && (volume < 0 || volume > 1)) { throw new Error('Volume must be between 0 and 1, or undefined'); } - this.call.state.updateParticipant(sessionId, { audioVolume: volume }); + this.call.state.updateParticipant(sessionId, (p) => { + if (isReactNative() && p.audioStream) { + for (const track of p.audioStream.getAudioTracks()) { + // @ts-expect-error track._setVolume is present in react-native-webrtc + track?._setVolume(volume); + } + } + return { audioVolume: volume }; + }); } } diff --git a/packages/noise-cancellation-react-native/android/build.gradle b/packages/noise-cancellation-react-native/android/build.gradle index ba6cc38df0..9bb49b6a44 100644 --- a/packages/noise-cancellation-react-native/android/build.gradle +++ b/packages/noise-cancellation-react-native/android/build.gradle @@ -79,6 +79,19 @@ rootProject.allprojects { } } +rootProject.allprojects { + repositories { + maven { + name = 'Central Sonatype Portal Snapshots' + url = 'https://oss.sonatype.org/content/repositories/snapshots/' + content { + // This efficiently tells Gradle to only look for this specific dependency here + includeModule('io.getstream', 'stream-video-android-noise-cancellation') + } + } + } +} + repositories { mavenCentral() google() diff --git a/packages/react-native-sdk/__mocks__/react-native-incall-manager.ts b/packages/react-native-sdk/__mocks__/react-native-incall-manager.ts deleted file mode 100644 index 5bbc0f9def..0000000000 --- a/packages/react-native-sdk/__mocks__/react-native-incall-manager.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - start: jest.fn(), - stop: jest.fn(), -}; diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt index 31f8f17031..7a8d85dbb8 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt @@ -82,6 +82,7 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : }) } + @ReactMethod fun getDefaultRingtoneUrl(promise: Promise) { val defaultRingtoneUri: Uri? = @@ -131,6 +132,15 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : fun removeListeners(count: Int) { } + // This method was removed upstream in react-native 0.74+, replaced with invalidate + // We will leave this stub here for older react-native versions compatibility + // ...but it will just delegate to the new invalidate method + @Deprecated("Deprecated in Java", ReplaceWith("invalidate()")) + @Suppress("removal") + override fun onCatalystInstanceDestroy() { + invalidate() + } + override fun invalidate() { StreamVideoReactNative.clearPipListeners() reactApplicationContext.unregisterReceiver(powerReceiver) diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativePackage.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativePackage.kt index 1bf37f026f..35a85157b4 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativePackage.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativePackage.kt @@ -4,11 +4,12 @@ import com.facebook.react.ReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule class StreamVideoReactNativePackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(StreamVideoReactNativeModule(reactContext)) + return listOf(StreamVideoReactNativeModule(reactContext), StreamInCallManagerModule(reactContext)) } override fun createViewManagers(reactContext: ReactApplicationContext): List> { diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt new file mode 100644 index 0000000000..cd9a8701b0 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt @@ -0,0 +1,592 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.streamvideo.reactnative.audio + +import android.app.Activity +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.streamvideo.reactnative.audio.utils.AudioDeviceEndpointUtils +import com.streamvideo.reactnative.audio.utils.AudioFocusUtil +import com.streamvideo.reactnative.audio.utils.AudioManagerUtil +import com.streamvideo.reactnative.audio.utils.AudioManagerUtil.Companion.getAvailableAudioDevices +import com.streamvideo.reactnative.audio.utils.AudioSetupStoreUtil +import com.streamvideo.reactnative.audio.utils.CallAudioRole +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule +import com.streamvideo.reactnative.model.AudioDeviceEndpoint +import com.streamvideo.reactnative.model.AudioDeviceEndpoint.Companion.EndpointType +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +data class EndpointMaps( + // earpiece, speaker, unknown, wired_headset + val bluetoothEndpoints: HashMap, + // all bt endpoints + val nonBluetoothEndpoints: HashMap<@EndpointType Int, AudioDeviceEndpoint> +) + +class AudioDeviceManager( + private val mReactContext: ReactApplicationContext +) : AutoCloseable, AudioDeviceCallback(), AudioManager.OnAudioFocusChangeListener { + + private val mEndpointMaps by lazy { + val initialAudioDevices = getAvailableAudioDevices(mAudioManager) + val initialEndpoints = + AudioDeviceEndpointUtils.getEndpointsFromAudioDeviceInfo(initialAudioDevices) + val bluetoothEndpoints = HashMap() + val nonBluetoothEndpoints = HashMap<@EndpointType Int, AudioDeviceEndpoint>() + for (device in initialEndpoints) { + if (device.isBluetoothType()) { + bluetoothEndpoints[device.name] = device + } else { + nonBluetoothEndpoints[device.type] = device + } + } + EndpointMaps(bluetoothEndpoints, nonBluetoothEndpoints) + } + + private var cachedAvailableEndpointNamesSet = setOf() + + /** Returns the currently selected audio device. */ + private var _selectedAudioDeviceEndpoint: AudioDeviceEndpoint? = null + private var selectedAudioDeviceEndpoint: AudioDeviceEndpoint? + get() = _selectedAudioDeviceEndpoint + set(value) { + _selectedAudioDeviceEndpoint = value + // send an event to the frontend everytime this endpoint changes + sendAudioStatusEvent() + } + + // Default audio device; speaker phone for video calls or earpiece for audio only phone calls + @EndpointType + var defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER + + /** Contains the user-selected audio device which overrides the predefined selection scheme */ + @EndpointType + private var userSelectedAudioDevice: Int? = null + + private val mAudioManager = + mReactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + /** + * Indicator that we have lost audio focus. + */ + private var audioFocusLost = false + + private var audioFocusUtil = AudioFocusUtil(mAudioManager, this) + private var audioSetupStoreUtil = AudioSetupStoreUtil(mReactContext, mAudioManager, this) + + var callAudioRole: CallAudioRole = CallAudioRole.Communicator + + val bluetoothManager = BluetoothManager(mReactContext, this) + + init { + // Note that we will immediately receive a call to onDevicesAdded with the list of + // devices which are currently connected. + mAudioManager.registerAudioDeviceCallback(this, null) + } + + fun start(activity: Activity) { + runInAudioThread { + userSelectedAudioDevice = null + selectedAudioDeviceEndpoint = null + audioSetupStoreUtil.storeOriginalAudioSetup() + if (callAudioRole == CallAudioRole.Communicator) { + // Audio routing is manually controlled by the SDK in communication media mode + // and local microphone can be published + mAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL + bluetoothManager.start() + mAudioManager.registerAudioDeviceCallback(this, null) + updateAudioDeviceState() + } else { + // Audio routing is handled automatically by the system in normal media mode + // and bluetooth microphones may not work on some devices. + mAudioManager.mode = AudioManager.MODE_NORMAL + activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + } + + audioSetupStoreUtil.storeOriginalAudioSetup() + audioFocusUtil.requestFocus(callAudioRole, mReactContext) + } + } + + fun stop() { + runInAudioThread { + if (callAudioRole == CallAudioRole.Communicator) { + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.clearCommunicationDevice() + } else { + mAudioManager.setSpeakerphoneOn(false) + } + bluetoothManager.stop() + } + audioSetupStoreUtil.restoreOriginalAudioSetup() + audioFocusUtil.abandonFocus() + } + } + + fun setMicrophoneMute(enable: Boolean) { + if (enable != mAudioManager.isMicrophoneMute) { + mAudioManager.isMicrophoneMute = enable + } + } + + private fun getEndpointFromName(name: String): AudioDeviceEndpoint? { + val endpointType = AudioDeviceEndpointUtils.endpointStringToType(name) + val endpoint = when (endpointType) { + AudioDeviceEndpoint.TYPE_SPEAKER, AudioDeviceEndpoint.TYPE_EARPIECE, AudioDeviceEndpoint.TYPE_WIRED_HEADSET -> mEndpointMaps.nonBluetoothEndpoints[endpointType] + else -> mEndpointMaps.bluetoothEndpoints[name] + } + return endpoint + } + + fun setSpeakerphoneOn(enable: Boolean) { + if (enable) { + switchDeviceEndpointType(AudioDeviceEndpoint.TYPE_SPEAKER) + } else { + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.clearCommunicationDevice() + // sets the first device that is not speaker + getCurrentDeviceEndpoints().firstOrNull { + !it.isSpeakerType() + }?.also { + switchDeviceEndpointType(it.type) + } + } else { + mAudioManager.setSpeakerphoneOn(false) + } + } + } + + private fun switchDeviceEndpointType(@EndpointType deviceType: Int) { + val newDevice = AudioManagerUtil.switchDeviceEndpointType( + deviceType, + mEndpointMaps, + mAudioManager, + bluetoothManager + ) + this.selectedAudioDeviceEndpoint = newDevice + } + + fun switchDeviceFromDeviceName( + deviceName: String + ) { + Log.d(TAG, "switchDeviceFromDeviceName: deviceName = $deviceName") + Log.d( + TAG, + "switchDeviceFromDeviceName: mEndpointMaps.bluetoothEndpoints = ${mEndpointMaps.bluetoothEndpoints}" + ) + runInAudioThread { + val btDevice = mEndpointMaps.bluetoothEndpoints[deviceName] + if (btDevice != null) { + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.setCommunicationDevice(btDevice.deviceInfo) + bluetoothManager.updateDevice() + this.selectedAudioDeviceEndpoint = btDevice + } else { + switchDeviceEndpointType( + AudioDeviceEndpoint.TYPE_BLUETOOTH + ) + } + } else { + val endpointType = AudioDeviceEndpointUtils.endpointStringToType(deviceName) + switchDeviceEndpointType( + endpointType + ) + } + } + } + + override fun close() { + mAudioManager.unregisterAudioDeviceCallback(this) + } + + override fun onAudioDevicesAdded(addedDevices: Array?) { + if (addedDevices != null) { + runInAudioThread { + endpointsAddedUpdate( + AudioDeviceEndpointUtils.getEndpointsFromAudioDeviceInfo( + addedDevices.toList() + ) + ) + } + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + if (removedDevices != null) { + runInAudioThread { + endpointsRemovedUpdate( + AudioDeviceEndpointUtils.getEndpointsFromAudioDeviceInfo( + removedDevices.toList() + ) + ) + } + } + } + + private fun endpointsAddedUpdate(addedCallEndpoints: List) { + // START tracking an endpoint + var addedDevicesCount = 0 + for (maybeNewEndpoint in addedCallEndpoints) { + addedDevicesCount += maybeAddCallEndpoint(maybeNewEndpoint) + } + if (addedDevicesCount > 0) { + updateAudioDeviceState() + } + } + + private fun endpointsRemovedUpdate(removedCallEndpoints: List) { + // STOP tracking an endpoint + var removedDevicesCount = 0 + for (maybeRemovedDevice in removedCallEndpoints) { + removedDevicesCount += maybeRemoveCallEndpoint(maybeRemovedDevice) + } + if (removedDevicesCount > 0) { + updateAudioDeviceState() + } + } + + private fun getCurrentDeviceEndpoints(): List { + if (Build.VERSION.SDK_INT >= 31) { + return (mEndpointMaps.bluetoothEndpoints.values + mEndpointMaps.nonBluetoothEndpoints.values).sorted() + } else { + val btEndpoint = mEndpointMaps.bluetoothEndpoints[bluetoothManager.getDeviceName()] + if (btEndpoint != null) { + val list = mutableListOf(btEndpoint) + list.addAll(mEndpointMaps.nonBluetoothEndpoints.values) + return list.sorted() + } else { + return mEndpointMaps.nonBluetoothEndpoints.values.sorted() + } + + } + } + + private fun maybeAddCallEndpoint(endpoint: AudioDeviceEndpoint): Int { + if (endpoint.isBluetoothType()) { + if (!mEndpointMaps.bluetoothEndpoints.containsKey(endpoint.name)) { + mEndpointMaps.bluetoothEndpoints[endpoint.name] = endpoint + Log.d(TAG, "maybeAddCallEndpoint: bluetooth endpoint added: " + endpoint.name) + return 1 + } + } else { + if (!mEndpointMaps.nonBluetoothEndpoints.containsKey(endpoint.type)) { + mEndpointMaps.nonBluetoothEndpoints[endpoint.type] = endpoint + Log.d(TAG, "maybeAddCallEndpoint: non-bluetooth endpoint added: " + endpoint.name) + return 1 + } + } + return 0 + } + + private fun maybeRemoveCallEndpoint(endpoint: AudioDeviceEndpoint): Int { + // TODO:: determine if it is necessary to cleanup listeners here + if (endpoint.isBluetoothType()) { + if (mEndpointMaps.bluetoothEndpoints.containsKey(endpoint.name)) { + mEndpointMaps.bluetoothEndpoints.remove(endpoint.name) + Log.d(TAG, "maybeRemoveCallEndpoint: bluetooth endpoint removed: " + endpoint.name) + return 1 + } + } else { + if (mEndpointMaps.nonBluetoothEndpoints.containsKey(endpoint.type)) { + mEndpointMaps.nonBluetoothEndpoints.remove(endpoint.type) + Log.d( + TAG, + "maybeRemoveCallEndpoint: non-bluetooth endpoint removed: " + endpoint.name + ) + return 1 + } + } + return 0 + } + + fun muteAudioOutput() { + mAudioManager.adjustStreamVolume( + if (callAudioRole === CallAudioRole.Communicator) AudioManager.STREAM_VOICE_CALL else AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_MUTE, + AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE // Optional: prevents sound/vibration on mute + ) + } + + fun unmuteAudioOutput() { + mAudioManager.adjustStreamVolume( + if (callAudioRole === CallAudioRole.Communicator) AudioManager.STREAM_VOICE_CALL else AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_UNMUTE, + AudioManager.FLAG_SHOW_UI + ) + } + + /** + * Updates the list of available audio devices and selects a new audio device based on priority. + * + * This function performs the following actions: + * 1. Retrieves the current list of available audio device endpoints. + * 2. Compares the current list with the cached list to detect changes. + * 3. Updates the Bluetooth device state if necessary (especially for older Android platforms). + * 4. Determines the new audio device based on the following priority: + * - Wired Headset (WH) + * - Bluetooth (BT) + * - Default audio device (speaker or earpiece) + * 5. If a user has manually selected an audio device, that selection takes precedence. + * 6. Handles Bluetooth SCO (Synchronous Connection-Oriented) connection: + * - If the new device is Bluetooth and a headset is available, it attempts to start SCO audio. + * - If SCO connection fails, it reverts the Bluetooth selection and chooses an alternative device. + * - If a previous selection was Bluetooth and the new selection is not, it stops SCO audio. + * 7. Switches to the new audio device endpoint. + * 8. If the selected device or the list of available devices has changed, it sends an audio status event. + * + * The entire operation is performed on a dedicated audio thread to avoid blocking the main thread. + */ + fun updateAudioDeviceState() { + runInAudioThread { + val audioDevices = getCurrentDeviceEndpoints() + val audioDeviceNamesSet = audioDevices.map { it.name }.toSet() + val devicesChanged = if (cachedAvailableEndpointNamesSet.size != audioDevices.size) { + true + } else { + cachedAvailableEndpointNamesSet != audioDeviceNamesSet + } + cachedAvailableEndpointNamesSet = audioDeviceNamesSet + Log.d( + TAG, + ("updateAudioDeviceState() Device status: available=$audioDevices, selected=$selectedAudioDeviceEndpoint, user selected=" + endpointTypeDebug( + userSelectedAudioDevice + )) + ) + + if (devicesChanged) { + // notify the frontend that the available devices changed + this.sendAudioStatusEvent(); + } + + // Double-check if any Bluetooth headset is connected once again (useful for older android platforms) + // TODO: we can possibly remove this, to be tested on older platforms + if (bluetoothManager.bluetoothState == BluetoothManager.State.HEADSET_AVAILABLE || bluetoothManager.bluetoothState == BluetoothManager.State.HEADSET_UNAVAILABLE) { + bluetoothManager.updateDevice() + } + /** sets newAudioDevice initially to this order: WH -> BT -> default(speaker or earpiece) */ + var newAudioDevice = defaultAudioDevice + audioDevices.firstOrNull { + it.isWiredHeadsetType() || it.isBluetoothType() + }?.also { + newAudioDevice = it.type + } + var deviceSwitched = false + val userSelectedAudioDevice = this.userSelectedAudioDevice + if (userSelectedAudioDevice !== null && userSelectedAudioDevice != AudioDeviceEndpoint.TYPE_UNKNOWN) { + newAudioDevice = userSelectedAudioDevice + } + Log.d( + TAG, ("Decided newAudioDevice: ${endpointTypeDebug(newAudioDevice)}") + ) + /** To be called when BT SCO connection fails + * Will do the following: + * 1 - revert user selection if needed + * 2 - sets newAudioDevice to something other than BT + * 3 - change the bt manager to device state from sco connection state + * */ + fun revertBTSelection() { + val selectedAudioDeviceEndpoint = this.selectedAudioDeviceEndpoint + // BT connection, so revert user selection if needed + if (userSelectedAudioDevice == AudioDeviceEndpoint.TYPE_BLUETOOTH) { + this.userSelectedAudioDevice = null + } + // prev selection was not BT, but new was BT + // new can now be WiredHeadset or default if there was no selection before + if (selectedAudioDeviceEndpoint != null && selectedAudioDeviceEndpoint.type != AudioDeviceEndpoint.TYPE_UNKNOWN && selectedAudioDeviceEndpoint.type != AudioDeviceEndpoint.TYPE_BLUETOOTH) { + newAudioDevice = selectedAudioDeviceEndpoint.type + } else { + newAudioDevice = defaultAudioDevice + audioDevices.firstOrNull { + it.isWiredHeadsetType() + }?.also { + newAudioDevice = it.type + } + } + // change the bt manager to device state from sco connection state + bluetoothManager.updateDevice() + Log.d( + TAG, ("revertBTSelection newAudioDevice: ${endpointTypeDebug(newAudioDevice)}") + ) + } + + var selectedAudioDeviceEndpoint = this.selectedAudioDeviceEndpoint + if (selectedAudioDeviceEndpoint == null || newAudioDevice != selectedAudioDeviceEndpoint.type) { + // --- stop bluetooth if prev selection was bluetooth + if (selectedAudioDeviceEndpoint?.type == AudioDeviceEndpoint.TYPE_BLUETOOTH && (bluetoothManager.bluetoothState == BluetoothManager.State.SCO_CONNECTED || bluetoothManager.bluetoothState == BluetoothManager.State.SCO_CONNECTING)) { + bluetoothManager.stopScoAudio() + bluetoothManager.updateDevice() + } + + // --- start bluetooth if new is BT and we have a headset + if (newAudioDevice == AudioDeviceEndpoint.TYPE_BLUETOOTH && bluetoothManager.bluetoothState == BluetoothManager.State.HEADSET_AVAILABLE) { + // Attempt to start Bluetooth SCO audio (takes a few second to start on older platforms). + if (!bluetoothManager.startScoAudio()) { + revertBTSelection() + } + + // already selected BT device + if (bluetoothManager.bluetoothState == BluetoothManager.State.SCO_CONNECTED) { + selectedAudioDeviceEndpoint = + getEndpointFromName(bluetoothManager.getDeviceName()!!) + this.selectedAudioDeviceEndpoint = selectedAudioDeviceEndpoint + deviceSwitched = true + } else if ( + // still connecting (happens on older Android platforms) + bluetoothManager.bluetoothState == BluetoothManager.State.SCO_CONNECTING) { + // on older Android platforms + // it will call this update function again, once connected or disconnected + // so we can skip executing further + return@runInAudioThread + } + } + + /** This check is meant for older Android platforms + * it would have called this device update function again on timer execution + * after two cases + * 1 - SCO_CONNECTED or + * 2 - SCO_DISCONNECTING + * Here we see if it was disconnected then we revert to non-bluetooth selection + * */ + if (newAudioDevice == AudioDeviceEndpoint.TYPE_BLUETOOTH && selectedAudioDeviceEndpoint?.type != AudioDeviceEndpoint.TYPE_BLUETOOTH && bluetoothManager.bluetoothState == BluetoothManager.State.SCO_DISCONNECTING) { + revertBTSelection() + } + + if (newAudioDevice != selectedAudioDeviceEndpoint?.type) { + // BT sco would be already connected at this point, so no need to switch again + if (newAudioDevice != AudioDeviceEndpoint.TYPE_BLUETOOTH) { + switchDeviceEndpointType(newAudioDevice) + } + deviceSwitched = true + } + + if (deviceSwitched || devicesChanged) { + Log.d( + TAG, + ("New device status: " + "available=" + audioDevices + ", " + "selected=" + this.selectedAudioDeviceEndpoint) + ) + } + Log.d( + TAG, "--- updateAudioDeviceState done" + ) + } else { + Log.d( + TAG, "--- updateAudioDeviceState: no change" + ) + } + } + } + + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + Log.d(TAG, "Audio focus gained") + // Some other application potentially stole our audio focus + // temporarily. Restore our mode. + if (audioFocusLost) { + // removing the currently selected device store, as its untrue + selectedAudioDeviceEndpoint = null + // removing the currently selected device store will make sure a device selection is made + updateAudioDeviceState() + } + audioFocusLost = false + } + + AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + Log.d(TAG, "Audio focus lost") + audioFocusLost = true + } + } + } + + private fun audioStatusMap(): WritableMap { + val endpoint = this.selectedAudioDeviceEndpoint + val availableEndpoints = Arguments.fromList(getCurrentDeviceEndpoints().map { it.name }) + + val data = Arguments.createMap() + data.putArray("devices", availableEndpoints) + data.putString("currentEndpointType", endpointTypeDebug(endpoint?.type)) + data.putString("selectedDevice", endpoint?.name) + return data + } + + private fun sendAudioStatusEvent() { + try { + if (mReactContext.hasActiveReactInstance()) { + val payload = audioStatusMap() + Log.d(TAG, "sendAudioStatusEvent: $payload") + mReactContext.getJSModule( + DeviceEventManagerModule.RCTDeviceEventEmitter::class.java + ).emit("onAudioDeviceChanged", payload) + } else { + Log.e(TAG, "sendEvent(): reactContext is null or not having CatalystInstance yet.") + } + } catch (e: RuntimeException) { + Log.e( + TAG, + "sendEvent(): java.lang.RuntimeException: Trying to invoke JS before CatalystInstance has been set!", + e + ) + } + } + + companion object { + private val TAG: String = + StreamInCallManagerModule.TAG + ":" + AudioDeviceManager::class.java.simpleName.toString() + + /** + * Executor service for running audio-related tasks on a dedicated single thread. + * + *

This executor ensures that all audio processing, recording, playback, + * or other audio-related operations are executed sequentially and do not + * interfere with the main UI thread or other background tasks. Using a + * single-threaded executor for audio tasks helps prevent race conditions + * and simplifies synchronization when dealing with audio resources. + * + *

Tasks submitted to this executor will be processed in the order they + * are submitted. + */ + private val audioThreadExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + fun runInAudioThread(runnable: Runnable) { + audioThreadExecutor.execute(runnable) + } + + + /** + * Converts an endpoint type to a human-readable string for debugging purposes. + * + * @param endpointType The endpoint type to convert. Can be null. + * @return A string representation of the endpoint type, or "NULL" if the input is null. + */ + private fun endpointTypeDebug(@EndpointType endpointType: Int?): String { + if (endpointType == null) { + return "NULL" + } + return AudioDeviceEndpointUtils.endpointTypeToString(endpointType) + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/BluetoothManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/BluetoothManager.kt new file mode 100644 index 0000000000..18a44316ad --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/BluetoothManager.kt @@ -0,0 +1,755 @@ +/* + * Copyright 2016 The WebRTC Project Authors. All rights reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +package com.streamvideo.reactnative.audio + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothClass +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.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.streamvideo.reactnative.audio.AudioDeviceManager.Companion.runInAudioThread +import com.streamvideo.reactnative.audio.utils.AudioDeviceEndpointUtils +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule +import com.streamvideo.reactnative.model.AudioDeviceEndpoint + +class BluetoothManager( + private val mReactContext: ReactApplicationContext, + private val audioDeviceManager: AudioDeviceManager, +) { + // Bluetooth connection state. + enum class State { + // Bluetooth is not available; no adapter or Bluetooth is off. + UNINITIALIZED, + + // Bluetooth error happened when trying to start Bluetooth. + ERROR, + + // Bluetooth proxy object for the Headset profile exists, but no connected headset devices, + // SCO is not started or disconnected. + HEADSET_UNAVAILABLE, + + // Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset + // present, but SCO is not started or disconnected. + HEADSET_AVAILABLE, + + // Bluetooth audio SCO connection with remote device is closing. + SCO_DISCONNECTING, + + // Bluetooth audio SCO connection with remote device is initiated. + SCO_CONNECTING, + + // Bluetooth audio SCO connection with remote device is established. + SCO_CONNECTED + } + + private val mAudioManager = + mReactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + var bluetoothState: State = State.UNINITIALIZED + + private val btManagerPlatform: BluetoothManagerPlatform = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) BluetoothManager31PlusImpl() else BluetoothManager23PlusImpl() + + private fun updateAudioDeviceState() { + Log.d(TAG, "updateAudioDeviceState") + audioDeviceManager.updateAudioDeviceState() + } + + /** Start the listeners */ + fun start() = btManagerPlatform.start() + + /** Stop the listeners */ + fun stop() = btManagerPlatform.stop() + + /** is audio flowing through BT communication device? */ + fun isScoOn() = btManagerPlatform.isScoOn() + + /** Start audio flowing through BT communication device. */ + fun startScoAudio() = btManagerPlatform.startScoAudio() + + /** Stop audio flowing through BT communication device. */ + fun stopScoAudio() = btManagerPlatform.stopScoAudio() + + /** Check if there is a BT headset connected and update the state accordingly */ + fun updateDevice() = btManagerPlatform.updateDevice() + + fun getDeviceName() = btManagerPlatform.getDeviceName() + + abstract inner class BluetoothManagerPlatform { + + abstract fun hasPermission(): Boolean + + /** is audio flowing through BT communication device? */ + abstract fun isScoOn(): Boolean + + /** Start audio flowing through BT communication device. */ + abstract fun startScoAudio(): Boolean + + /** Check if there is a BT headset connected and update the state. */ + abstract fun updateDevice() + + /** Get the name of the connected BT device if present, otherwise null. */ + abstract fun getDeviceName(): String? + + /** Stop audio flowing through BT communication device. */ + open fun stopScoAudio(): Boolean { + Log.d( + TAG, ("stopScoAudio: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn()) + ) + return !(bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) + } + + /** Start the listeners */ + open fun start(): Boolean { + Log.d(TAG, "start") + if (!hasPermission()) { + Log.w( + TAG, "App lacks BLUETOOTH permission" + ) + return false + } + + // Ensure that the device supports use of BT SCO audio for off call use cases. + if (!mAudioManager.isBluetoothScoAvailableOffCall) { + Log.e(TAG, "Bluetooth SCO audio is not available off call") + return false + } + + return true + } + + /* Stop the listeners */ + open fun stop(): Boolean { + Log.d( + TAG, "stop: BT state=$bluetoothState" + ) + // Close down remaining BT resources. + return bluetoothState != State.UNINITIALIZED + } + } + + @RequiresApi(31) + inner class BluetoothManager31PlusImpl : BluetoothManagerPlatform() { + + private var bluetoothAudioDevice: AudioDeviceInfo? = null + + /** Get the connected BT device if present, otherwise null. + Note: this doesn't mean that the device is streaming the audio now. It is only connected. + */ + private fun getAvailableBtDevice(): AudioDeviceInfo? { + val devices = mAudioManager.availableCommunicationDevices + for (device in devices) { + val isBtDevice = + AudioDeviceEndpoint.TYPE_BLUETOOTH == AudioDeviceEndpointUtils.remapAudioDeviceTypeToCallEndpointType( + device.type + ) + if (isBtDevice) { + return device + } + } + return null + } + + private var bluetoothAudioDeviceCallback: AudioDeviceCallback = + object : AudioDeviceCallback() { + + override fun onAudioDevicesAdded(addedDevices: Array?) { + if (addedDevices != null) { + runInAudioThread { + updateDeviceList() + } + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + if (removedDevices != null) { + runInAudioThread { + updateDeviceList() + } + } + } + + fun updateDeviceList() { + val currentBtDevice = bluetoothAudioDevice + val newBtDevice: AudioDeviceInfo? = getAvailableBtDevice() + if (currentBtDevice != null && newBtDevice == null) { + bluetoothState = State.HEADSET_UNAVAILABLE + } else if (currentBtDevice == null && newBtDevice != null) { + bluetoothState = State.HEADSET_AVAILABLE + } else if (currentBtDevice != null && newBtDevice != null && currentBtDevice.id != newBtDevice.id) { + updateDevice() + } + } + } + + override fun hasPermission(): Boolean { + return mReactContext.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + } + + override fun start(): Boolean { + if (!super.start()) { + return false + } + mAudioManager.registerAudioDeviceCallback(bluetoothAudioDeviceCallback, null) + bluetoothAudioDevice = getAvailableBtDevice() + bluetoothState = + if (bluetoothAudioDevice != null) State.HEADSET_AVAILABLE else State.HEADSET_UNAVAILABLE + Log.d( + TAG, "start done: BT state=$bluetoothState" + ) + return true + } + + override fun stop(): Boolean { + if (!super.start()) { + return false + } + // Stop BT SCO connection with remote device if needed. + stopScoAudio() + mAudioManager.unregisterAudioDeviceCallback(bluetoothAudioDeviceCallback) + bluetoothState = State.UNINITIALIZED + Log.d( + TAG, "stop done: BT state=$bluetoothState" + ) + return true + } + + override fun isScoOn(): Boolean { + val communicationDevice: AudioDeviceInfo? = mAudioManager.communicationDevice + if (communicationDevice !== null) { + val isOn = + AudioDeviceEndpoint.TYPE_BLUETOOTH == AudioDeviceEndpointUtils.remapAudioDeviceTypeToCallEndpointType( + communicationDevice.type + ) + if (isOn) { + bluetoothAudioDevice = communicationDevice + return true + } + } + return false + } + + override fun startScoAudio(): Boolean { + Log.d( + TAG, ("startSco: BT state=" + bluetoothState + ", SCO is on: " + isScoOn()) + ) + val currentBtDevice = bluetoothAudioDevice + if (currentBtDevice != null) { + mAudioManager.setCommunicationDevice(currentBtDevice) + bluetoothState = State.SCO_CONNECTED + Log.d( + TAG, + "Set bluetooth audio device as communication device: id=${currentBtDevice.id} name=${currentBtDevice.productName}" + ) + return true + } + bluetoothState = State.HEADSET_UNAVAILABLE + Log.e( + TAG, "Cannot find any bluetooth SCO device to set as communication device" + ) + return false + } + + override fun updateDevice() { + if (bluetoothState == State.UNINITIALIZED) { + return + } + if (bluetoothState == State.SCO_CONNECTED) { + if (isScoOn()) { + return + } + } + bluetoothAudioDevice = getAvailableBtDevice() + val currentBtDevice = bluetoothAudioDevice + if (currentBtDevice != null) { + bluetoothState = State.HEADSET_AVAILABLE + Log.d( + TAG, ("Connected bluetooth headset: " + "name=" + currentBtDevice.productName) + ) + } else { + bluetoothState = State.HEADSET_UNAVAILABLE + } + Log.d( + TAG, "updateDevice done: BT state=$bluetoothState" + ) + } + + override fun getDeviceName(): String? { + return bluetoothAudioDevice?.productName?.toString() + } + + override fun stopScoAudio(): Boolean { + if (!super.stopScoAudio()) { + return false + } + mAudioManager.clearCommunicationDevice() + bluetoothState = State.SCO_DISCONNECTING + Log.d(TAG, "stopScoAudio done: BT state=$bluetoothState + SCO is on: ${isScoOn()}") + return true + } + } + + @Suppress("DEPRECATION") + inner class BluetoothManager23PlusImpl : BluetoothManagerPlatform() { + private var scoConnectionAttempts: Int = 0 + private var mBluetoothAdapter: BluetoothAdapter? = null + private var bluetoothHeadset: BluetoothHeadset? = null + private var bluetoothDevice: BluetoothDevice? = null + private val handler = Handler(Looper.getMainLooper()) + + private val bluetoothTimeoutRunnable = Runnable { bluetoothTimeout() } + + private val bluetoothServiceListener: BluetoothProfile.ServiceListener = + object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { + if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) { + return + } + Log.d( + TAG, "BluetoothServiceListener.onServiceConnected: BT state=$bluetoothState" + ) + // Android only supports one connected Bluetooth Headset at a time. + bluetoothHeadset = proxy as BluetoothHeadset + updateAudioDeviceState() + Log.d( + TAG, "onServiceConnected done: BT state=$bluetoothState" + ) + } + + override fun onServiceDisconnected(profile: Int) { + if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) { + return + } + Log.d( + TAG, + "BluetoothServiceListener.onServiceDisconnected: BT state=$bluetoothState" + ) + stopScoAudio() + bluetoothHeadset = null + bluetoothDevice = null + bluetoothState = State.HEADSET_UNAVAILABLE + updateAudioDeviceState() + Log.d( + TAG, "onServiceDisconnected done: BT state=$bluetoothState" + ) + } + + } + + private val bluetoothHeadsetReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (bluetoothState == State.UNINITIALIZED) { + return + } + val action = intent.action + // Change in connection state of the Headset profile. Note that the + // change does not tell us anything about whether we're streaming + // audio to BT over SCO. Typically received when user turns on a BT + // headset while audio is active using another audio device. + if (action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) { + val state = intent.getIntExtra( + BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED + ) + Log.d( + TAG, + ("BluetoothHeadsetBroadcastReceiver.onReceive: " + "a=ACTION_CONNECTION_STATE_CHANGED, " + "s=" + stateToString( + state + ) + ", " + "sb=" + isInitialStickyBroadcast + ", " + "BT state: " + bluetoothState) + ) + if (state == BluetoothHeadset.STATE_CONNECTED) { + scoConnectionAttempts = 0 + updateAudioDeviceState() + } else if (state == BluetoothHeadset.STATE_CONNECTING) { + // No action needed. + } else if (state == BluetoothHeadset.STATE_DISCONNECTING) { + // No action needed. + } else if (state == BluetoothHeadset.STATE_DISCONNECTED) { + // Bluetooth is probably powered off during the call. + stopScoAudio() + updateAudioDeviceState() + } + // Change in the audio (SCO) connection state of the Headset profile. + // Typically received after call to startScoAudio() has finalized. + } else if (action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) { + val state = intent.getIntExtra( + BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED + ) + Log.d( + TAG, + ("BluetoothHeadsetBroadcastReceiver.onReceive: " + "a=ACTION_AUDIO_STATE_CHANGED, " + "s=" + stateToString( + state + ) + ", " + "sb=" + isInitialStickyBroadcast + ", " + "BT state: " + bluetoothState) + ) + if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) { + cancelTimer() + if (bluetoothState == State.SCO_CONNECTING) { + Log.d( + TAG, "+++ Bluetooth audio SCO is now connected" + ) + bluetoothState = State.SCO_CONNECTED + scoConnectionAttempts = 0 + updateAudioDeviceState() + } else { + Log.w( + TAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED" + ) + } + } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) { + Log.d( + TAG, "+++ Bluetooth audio SCO is now connecting..." + ) + } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { + Log.d( + TAG, "+++ Bluetooth audio SCO is now disconnected" + ) + if (isInitialStickyBroadcast) { + Log.d( + TAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast." + ) + return + } + updateAudioDeviceState() + } + } + Log.d( + TAG, "onReceive done: BT state=$bluetoothState" + ) + } + + } + + override fun hasPermission(): Boolean { + return mReactContext.checkSelfPermission(Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED + } + + override fun isScoOn(): Boolean = mAudioManager.isBluetoothScoOn() + + override fun startScoAudio(): Boolean { + Log.d( + TAG, + ("startSco: BT state=" + bluetoothState + ", " + "attempts: " + scoConnectionAttempts + ", " + "SCO is on: " + isScoOn()) + ) + if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) { + Log.e(TAG, "BT SCO connection fails - no more attempts") + return false + } + if (bluetoothState == State.HEADSET_UNAVAILABLE) { + Log.e(TAG, "BT SCO connection fails - no headset available") + return false + } + + // The SCO connection establishment can take several seconds, hence we cannot rely on the + // connection to be available when the method returns but instead register to receive the + // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED. + // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED. + Log.d( + TAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED..." + ) + bluetoothState = State.SCO_CONNECTING + startTimer() + mAudioManager.startBluetoothSco() + mAudioManager.setBluetoothScoOn(true) + scoConnectionAttempts++ + Log.d( + TAG, + ("startScoAudio done: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn()) + ) + return true + } + + @SuppressLint("MissingPermission") + override fun updateDevice() { + val currBtHeadset = bluetoothHeadset + if (bluetoothState == State.UNINITIALIZED || currBtHeadset == null) { + return + } + // Get connected devices for the headset profile. Returns the set of + // devices which are in state STATE_CONNECTED. The BluetoothDevice class + // is just a thin wrapper for a Bluetooth hardware address. + val devices = getFinalConnectedDevices() + for (device in devices) { + Log.d( + TAG, + ("Connected bluetooth headset: " + "name=" + device.name + ", " + "state=" + stateToString( + currBtHeadset.getConnectionState(device) + )) + ) + } + if (devices.isEmpty()) { + bluetoothDevice = null + bluetoothState = State.HEADSET_UNAVAILABLE + Log.d(TAG, "No connected bluetooth headset") + } else { + // Always use first device in list. Android only supports one device. + val firstBtDevice = devices[0] + bluetoothDevice = firstBtDevice + bluetoothState = State.HEADSET_AVAILABLE + Log.d( + TAG, + ("Connected bluetooth headset: " + "name=" + firstBtDevice.name + ", " + "state=" + stateToString( + currBtHeadset.getConnectionState( + bluetoothDevice + ) + ) + ", SCO audio=" + currBtHeadset.isAudioConnected( + bluetoothDevice + )) + ) + } + + Log.d( + TAG, "updateDevice done: BT state=$bluetoothState" + ) + } + + @SuppressLint("MissingPermission") + override fun getDeviceName(): String? { + return bluetoothDevice?.name + } + + @SuppressLint("MissingPermission") + override fun start(): Boolean { + if (!super.start()) { + return false + } + val bluetoothManager: BluetoothManager = + mReactContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + mBluetoothAdapter = bluetoothManager.adapter + bluetoothHeadset = null + bluetoothDevice = null + scoConnectionAttempts = 0 + + // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and + // Hands-Free) proxy object and install a listener. + if (!getBluetoothProfileProxy( + mReactContext, bluetoothServiceListener + ) + ) { + Log.e( + TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed" + ) + return false + } + + // Register receivers for BluetoothHeadset change notifications. + val bluetoothHeadsetFilter = IntentFilter() + // Register receiver for change in connection state of the Headset profile. + bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) + // Register receiver for change in audio connection state of the Headset profile. + bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) + ContextCompat.registerReceiver( + mReactContext, + bluetoothHeadsetReceiver, + bluetoothHeadsetFilter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + bluetoothState = State.HEADSET_UNAVAILABLE + Log.d( + TAG, "HEADSET profile state: " + stateToString( + mBluetoothAdapter?.getProfileConnectionState( + BluetoothProfile.HEADSET + ) ?: -1 + ) + ) + + Log.d(TAG, "Bluetooth proxy for headset profile has started") + Log.d( + TAG, "start done: BT state=$bluetoothState" + ) + return true + } + + override fun stop(): Boolean { + if (!super.stop()) { + return false + } + if (mBluetoothAdapter == null) { + return false + } + // Stop BT SCO connection with remote device if needed. + stopScoAudio() + try { + mReactContext.unregisterReceiver(bluetoothHeadsetReceiver) + bluetoothHeadset?.also { + mBluetoothAdapter!!.closeProfileProxy(BluetoothProfile.HEADSET, it) + } + + } catch (exception: Exception) { + // The receiver was not registered. + // There is nothing to do in that case. + // Everything is fine. + } + cancelTimer() + return true + } + + override fun stopScoAudio(): Boolean { + if (!super.stopScoAudio()) { + return false + } + cancelTimer() + mAudioManager.stopBluetoothSco() + mAudioManager.setBluetoothScoOn(false) + + bluetoothState = State.SCO_DISCONNECTING + Log.d( + TAG, + ("stopScoAudio done: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn()) + ) + return true + } + + /** + * Called when start of the BT SCO channel takes too long time. Usually + * happens when the BT device has been turned on during an ongoing call. + */ + @SuppressLint("MissingPermission") + private fun bluetoothTimeout() { + val btHeadset = bluetoothHeadset + if (bluetoothState == State.UNINITIALIZED || btHeadset == null) { + return + } + + Log.d( + TAG, + ("bluetoothTimeout: BT state=" + bluetoothState + ", " + "attempts: " + scoConnectionAttempts + ", " + "SCO is on: " + isScoOn()) + ) + if (bluetoothState != State.SCO_CONNECTING) { + return + } + // Bluetooth SCO should be connecting; check the latest result. + var scoConnected = false + val devices: List = getFinalConnectedDevices() + if (devices.isNotEmpty()) { + bluetoothDevice = devices[0] + val currBtDevice = bluetoothDevice!! + if (btHeadset.isAudioConnected(currBtDevice)) { + Log.d( + TAG, "SCO connected with " + currBtDevice.name + ) + scoConnected = true + } else { + Log.d( + TAG, "SCO is not connected with " + currBtDevice.name + ) + } + + if (scoConnected) { + // We thought BT had timed out, but it's actually on; updating state. + bluetoothState = State.SCO_CONNECTED + scoConnectionAttempts = 0 + } else { + // Give up and "cancel" our request by calling stopBluetoothSco(). + Log.w(TAG, "BT failed to connect after timeout") + stopScoAudio() + } + } + updateAudioDeviceState() + Log.d( + TAG, "bluetoothTimeout done: BT state=$bluetoothState" + ) + } + + @SuppressLint("MissingPermission") + private fun getFinalConnectedDevices(): List { + val connectedDevices = bluetoothHeadset?.connectedDevices ?: emptyList() + val finalDevices: MutableList = ArrayList() + + Log.d(TAG, "getFinalConnectedDevices: connectedDevices=$connectedDevices") + + for (device in connectedDevices) { + val majorClass = device.bluetoothClass.majorDeviceClass + + if (majorClass == BluetoothClass.Device.Major.AUDIO_VIDEO) { + Log.d(TAG, "getFinalConnectedDevices: device=${device.name}") + finalDevices.add(device) + } + } + return finalDevices + } + + private fun getBluetoothProfileProxy( + context: Context?, listener: BluetoothProfile.ServiceListener? + ): Boolean { + try { + return mBluetoothAdapter?.getProfileProxy( + context, + listener, + BluetoothProfile.HEADSET + ) ?: false + } catch (e: Exception) { + Log.e(TAG, "gBPP: hit exception while getting bluetooth profile", e) + return false + } + } + + /** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */ + private fun startTimer() { + Log.d(TAG, "startTimer") + handler.postDelayed( + bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS.toLong() + ) + } + + /** Cancels any outstanding timer tasks. */ + private fun cancelTimer() { + Log.d(TAG, "cancelTimer") + handler.removeCallbacks(bluetoothTimeoutRunnable) + } + } + + companion object { + private val TAG: String = + StreamInCallManagerModule.TAG + ":" + BluetoothManager::class.java.simpleName.toString() + + // Timeout interval for starting or stopping audio to a Bluetooth SCO device. + private const val BLUETOOTH_SCO_TIMEOUT_MS: Int = 6000 + + // Maximum number of SCO connection attempts. + private const val MAX_SCO_CONNECTION_ATTEMPTS: Int = 10 + + private fun stateToString(state: Int): String { + return when (state) { + BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED" + BluetoothAdapter.STATE_CONNECTED -> "CONNECTED" + BluetoothAdapter.STATE_CONNECTING -> "CONNECTING" + BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING" + BluetoothAdapter.STATE_OFF -> "OFF" + BluetoothAdapter.STATE_ON -> "ON" + BluetoothAdapter.STATE_TURNING_OFF -> // Indicates the local Bluetooth adapter is turning off. Local clients should immediately + // attempt graceful disconnection of any remote links. + "TURNING_OFF" + + BluetoothAdapter.STATE_TURNING_ON -> // Indicates the local Bluetooth adapter is turning on. However local clients should wait + // for STATE_ON before attempting to use the adapter. + "TURNING_ON" + + else -> "INVALID" + } + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioDeviceEndpointUtils.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioDeviceEndpointUtils.kt new file mode 100644 index 0000000000..e55fac5ea7 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioDeviceEndpointUtils.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.streamvideo.reactnative.audio.utils + +import android.media.AudioDeviceInfo +import android.util.Log +import com.streamvideo.reactnative.model.AudioDeviceEndpoint +import com.streamvideo.reactnative.model.AudioDeviceEndpoint.Companion.EndpointType + +internal class AudioDeviceEndpointUtils { + + companion object { + const val BLUETOOTH_DEVICE_DEFAULT_NAME = "Bluetooth Device" + const val EARPIECE = "Earpiece" + const val SPEAKER = "Speaker" + const val WIRED_HEADSET = "Wired Headset" + const val UNKNOWN = "Unknown" + + private val TAG: String = AudioDeviceEndpointUtils::class.java.simpleName.toString() + + /** Gets the endpoints from AudioDeviceInfos. + * IMPORTANT: eliminates Earpiece if Headset is found + * */ + fun getEndpointsFromAudioDeviceInfo( + adiArr: List?, + ): List { + if (adiArr == null) { + return listOf() + } + val endpoints: MutableList = mutableListOf() + var foundWiredHeadset = false + val omittedDevices = StringBuilder("omitting devices =[") + adiArr.toList().forEach { audioDeviceInfo -> + val endpoint = getEndpointFromAudioDeviceInfo(audioDeviceInfo) + if (endpoint.type != AudioDeviceEndpoint.TYPE_UNKNOWN) { + if (endpoint.type == AudioDeviceEndpoint.TYPE_WIRED_HEADSET) { + foundWiredHeadset = true + } + endpoints.add(endpoint) + } else { + omittedDevices.append( + "(type=[${audioDeviceInfo.type}]," + + " name=[${audioDeviceInfo.productName}])," + ) + } + } + omittedDevices.append("]") + Log.i(TAG, omittedDevices.toString()) + if (foundWiredHeadset) { + endpoints.removeIf { it.type == AudioDeviceEndpoint.TYPE_EARPIECE } + } + // Sort by endpoint type. The first element has the highest priority! + endpoints.sort() + return endpoints + } + + + private fun getEndpointFromAudioDeviceInfo( + adi: AudioDeviceInfo, + ): AudioDeviceEndpoint { + val endpointDeviceName = remapAudioDeviceNameToEndpointDeviceName(adi) + val endpointType = remapAudioDeviceTypeToCallEndpointType(adi.type) + val newEndpoint = + AudioDeviceEndpoint( + endpointDeviceName, + endpointType, + adi, + ) + return newEndpoint + } + + internal fun remapAudioDeviceNameToEndpointDeviceName( + audioDeviceInfo: AudioDeviceInfo, + ): String { + return when (audioDeviceInfo.type) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> + EARPIECE + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> + SPEAKER + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_DEVICE, + AudioDeviceInfo.TYPE_USB_ACCESSORY, + AudioDeviceInfo.TYPE_USB_HEADSET -> + WIRED_HEADSET + else -> audioDeviceInfo.productName.toString() + } + } + + internal fun remapAudioDeviceTypeToCallEndpointType( + audioDeviceInfoType: Int + ): (@EndpointType Int) { + return when (audioDeviceInfoType) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> AudioDeviceEndpoint.TYPE_EARPIECE + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> AudioDeviceEndpoint.TYPE_SPEAKER + // Wired Headset Devices + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_DEVICE, + AudioDeviceInfo.TYPE_USB_ACCESSORY, + AudioDeviceInfo.TYPE_USB_HEADSET -> AudioDeviceEndpoint.TYPE_WIRED_HEADSET + // Bluetooth Devices + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_HEARING_AID, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER, + AudioDeviceInfo.TYPE_BLE_BROADCAST -> AudioDeviceEndpoint.TYPE_BLUETOOTH + // Everything else is defaulted to TYPE_UNKNOWN + else -> AudioDeviceEndpoint.TYPE_UNKNOWN + } + } + + fun getSpeakerEndpoint(endpoints: List): AudioDeviceEndpoint? { + for (e in endpoints) { + if (e.type == AudioDeviceEndpoint.TYPE_SPEAKER) { + return e + } + } + return null + } + + fun isBluetoothAvailable(endpoints: List): Boolean { + for (e in endpoints) { + if (e.type == AudioDeviceEndpoint.TYPE_BLUETOOTH) { + return true + } + } + return false + } + + fun isEarpieceEndpoint(endpoint: AudioDeviceEndpoint?): Boolean { + if (endpoint == null) { + return false + } + return endpoint.type == AudioDeviceEndpoint.TYPE_EARPIECE + } + + fun isSpeakerEndpoint(endpoint: AudioDeviceEndpoint?): Boolean { + if (endpoint == null) { + return false + } + return endpoint.type == AudioDeviceEndpoint.TYPE_SPEAKER + } + + fun isWiredHeadsetOrBtEndpoint(endpoint: AudioDeviceEndpoint?): Boolean { + if (endpoint == null) { + return false + } + return endpoint.type == AudioDeviceEndpoint.TYPE_BLUETOOTH || + endpoint.type == AudioDeviceEndpoint.TYPE_WIRED_HEADSET + } + + private fun isBluetoothType(type: Int): Boolean { + return type == AudioDeviceEndpoint.TYPE_BLUETOOTH + } + + fun endpointTypeToString(@EndpointType endpointType: Int): String { + return when (endpointType) { + AudioDeviceEndpoint.TYPE_EARPIECE -> EARPIECE + AudioDeviceEndpoint.TYPE_BLUETOOTH -> BLUETOOTH_DEVICE_DEFAULT_NAME + AudioDeviceEndpoint.TYPE_WIRED_HEADSET -> WIRED_HEADSET + AudioDeviceEndpoint.TYPE_SPEAKER -> SPEAKER + else -> UNKNOWN + } + } + + fun endpointStringToType(endpointName: String): Int { + return when (endpointName) { + EARPIECE -> AudioDeviceEndpoint.TYPE_EARPIECE + WIRED_HEADSET -> AudioDeviceEndpoint.TYPE_WIRED_HEADSET + SPEAKER -> AudioDeviceEndpoint.TYPE_SPEAKER + BLUETOOTH_DEVICE_DEFAULT_NAME -> AudioDeviceEndpoint.TYPE_BLUETOOTH + else -> AudioDeviceEndpoint.TYPE_UNKNOWN + } + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioFocusUtil.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioFocusUtil.kt new file mode 100644 index 0000000000..96d87e1683 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioFocusUtil.kt @@ -0,0 +1,62 @@ +package com.streamvideo.reactnative.audio.utils + +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import com.facebook.react.bridge.ReactContext +import com.oney.WebRTCModule.WebRTCModule +import org.webrtc.audio.JavaAudioDeviceModule +import org.webrtc.audio.WebRtcAudioTrackHelper + +enum class CallAudioRole { + /* high quality audio output is prioritised */ + Listener, + + /* low latency audio output is prioritised */ + Communicator +} + +class AudioFocusUtil( + private val audioManager: AudioManager, + private val audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener, +) { + + private lateinit var request: AudioFocusRequest + + + + fun requestFocus(mode: CallAudioRole, reactContext: ReactContext) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val audioAttributes = AudioAttributes.Builder() + .setUsage(if (mode == CallAudioRole.Communicator) AudioAttributes.USAGE_VOICE_COMMUNICATION else AudioAttributes.USAGE_MEDIA) + .setContentType(if (mode == CallAudioRole.Communicator) AudioAttributes.CONTENT_TYPE_SPEECH else AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + + // 1. set audio attributes to webrtc + val webRTCModule = reactContext.getNativeModule(WebRTCModule::class.java)!! + val adm = webRTCModule.audioDeviceModule as JavaAudioDeviceModule + WebRtcAudioTrackHelper.setAudioOutputAttributes(adm, audioAttributes) + + // 2. request the audio focus with the audio attributes + request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes(audioAttributes).setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(audioFocusChangeListener).build() + audioManager.requestAudioFocus(request) + } else { + audioManager.requestAudioFocus( + audioFocusChangeListener, + if (mode == CallAudioRole.Communicator) AudioManager.STREAM_VOICE_CALL else AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, + ) + } + } + + fun abandonFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager.abandonAudioFocusRequest(request) + } else { + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioManagerUtil.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioManagerUtil.kt new file mode 100644 index 0000000000..4048df468e --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioManagerUtil.kt @@ -0,0 +1,159 @@ +package com.streamvideo.reactnative.audio.utils + +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.util.Log +import androidx.annotation.DoNotInline +import androidx.annotation.RequiresApi +import com.streamvideo.reactnative.audio.BluetoothManager +import com.streamvideo.reactnative.audio.EndpointMaps +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule +import com.streamvideo.reactnative.model.AudioDeviceEndpoint +import com.streamvideo.reactnative.model.AudioDeviceEndpoint.Companion.EndpointType + + +internal class AudioManagerUtil { + companion object { + private val TAG: String = StreamInCallManagerModule.TAG + ":" + AudioManagerUtil::class.java.simpleName.toString() + + fun getAvailableAudioDevices(audioManager: AudioManager): List { + return if (Build.VERSION.SDK_INT >= 31) { + AudioManager31PlusImpl.getDevices(audioManager) + } else { + AudioManager23PlusImpl.getDevices(audioManager) + } + } + + fun isSpeakerphoneOn(audioManager: AudioManager): Boolean { + return if (Build.VERSION.SDK_INT >= 31) { + AudioManager31PlusImpl.isSpeakerphoneOn(audioManager) + } else { + AudioManager23PlusImpl.isSpeakerphoneOn(audioManager) + } + } + + /** + * Switch the device endpoint type. + * @return true if the device endpoint type is successfully switched. + */ + fun switchDeviceEndpointType(@EndpointType deviceType: Int, + endpointMaps: EndpointMaps, + audioManager: AudioManager, + bluetoothManager: BluetoothManager): AudioDeviceEndpoint? { + return if (Build.VERSION.SDK_INT >= 31) { + AudioManager31PlusImpl.switchDeviceEndpointType(deviceType, endpointMaps, audioManager, bluetoothManager) + } else { + AudioManager23PlusImpl.switchDeviceEndpointType(deviceType, endpointMaps, audioManager, bluetoothManager) + } + } + } + + @RequiresApi(31) + object AudioManager31PlusImpl { + @JvmStatic + @DoNotInline + fun getDevices(audioManager: AudioManager): List { + return audioManager.availableCommunicationDevices + } + + @JvmStatic + @DoNotInline + fun isSpeakerphoneOn(audioManager: AudioManager): Boolean { + val endpoint = AudioDeviceEndpointUtils.remapAudioDeviceTypeToCallEndpointType(audioManager.communicationDevice?.type ?: AudioDeviceInfo.TYPE_UNKNOWN) + return endpoint == AudioDeviceEndpoint.TYPE_SPEAKER + } + + @JvmStatic + @DoNotInline + fun switchDeviceEndpointType(@EndpointType deviceType: Int, + endpointMaps: EndpointMaps, + audioManager: AudioManager, + bluetoothManager: BluetoothManager): AudioDeviceEndpoint? { + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + when (deviceType) { + AudioDeviceEndpoint.TYPE_BLUETOOTH -> { + val didSwitch = bluetoothManager.startScoAudio() + if (didSwitch) { + return endpointMaps.bluetoothEndpoints[bluetoothManager.getDeviceName()] + } + return null + } + AudioDeviceEndpoint.TYPE_WIRED_HEADSET, AudioDeviceEndpoint.TYPE_EARPIECE -> { + endpointMaps.nonBluetoothEndpoints.values.firstOrNull { + it.type == deviceType + }?.let { + audioManager.setCommunicationDevice(it.deviceInfo) + bluetoothManager.updateDevice() + return endpointMaps.nonBluetoothEndpoints[deviceType] + } + return null + } + AudioDeviceEndpoint.TYPE_SPEAKER -> { + val speakerDevice = endpointMaps.nonBluetoothEndpoints[AudioDeviceEndpoint.TYPE_SPEAKER] + speakerDevice?.let { + audioManager.setCommunicationDevice(it.deviceInfo) + bluetoothManager.updateDevice() + return speakerDevice + } + return null + } + else -> { + Log.e(TAG, "switchDeviceEndpointType(): unknown device type requested") + return null + } + } + } + } + + @Suppress("DEPRECATION") + object AudioManager23PlusImpl { + @JvmStatic + @DoNotInline + fun getDevices(audioManager: AudioManager): List { + return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } + + @JvmStatic + @DoNotInline + fun isSpeakerphoneOn(audioManager: AudioManager): Boolean { + return audioManager.isSpeakerphoneOn + } + + @JvmStatic + @DoNotInline + fun switchDeviceEndpointType(@EndpointType deviceType: Int, + endpointMaps: EndpointMaps, + audioManager: AudioManager, + bluetoothManager: BluetoothManager): AudioDeviceEndpoint? { + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + when (deviceType) { + AudioDeviceEndpoint.TYPE_BLUETOOTH -> { + audioManager.setSpeakerphoneOn(false) + val didSwitch = bluetoothManager.startScoAudio() + if (didSwitch) { + // NOTE: SCO connection may fail after timeout, how to catch that on older platforms? + return endpointMaps.bluetoothEndpoints[bluetoothManager.getDeviceName()] + } + return null + } + AudioDeviceEndpoint.TYPE_WIRED_HEADSET, AudioDeviceEndpoint.TYPE_EARPIECE -> { + // NOTE: If wired headset is present, + // earpiece is always omitted even if chosen + bluetoothManager.stopScoAudio() + audioManager.setSpeakerphoneOn(false) + return endpointMaps.nonBluetoothEndpoints[deviceType] + } + AudioDeviceEndpoint.TYPE_SPEAKER -> { + bluetoothManager.stopScoAudio() + audioManager.setSpeakerphoneOn(true) + return endpointMaps.nonBluetoothEndpoints[AudioDeviceEndpoint.TYPE_SPEAKER] + } + else -> { + Log.e(TAG, "switchDeviceEndpointType(): unknown device type requested") + return null + } + } + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioSetupStoreUtil.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioSetupStoreUtil.kt new file mode 100644 index 0000000000..a112dce934 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioSetupStoreUtil.kt @@ -0,0 +1,41 @@ +package com.streamvideo.reactnative.audio.utils + +import android.media.AudioManager +import android.util.Log +import com.facebook.react.bridge.ReactContext +import com.streamvideo.reactnative.audio.AudioDeviceManager +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule.Companion.TAG + +class AudioSetupStoreUtil( + private val mReactContext: ReactContext, + private val mAudioManager: AudioManager, + private val mAudioDeviceManager: AudioDeviceManager +) { + private var isOrigAudioSetupStored = false + private var origIsSpeakerPhoneOn = false + private var origIsMicrophoneMute = false + private var origAudioMode = AudioManager.MODE_NORMAL + + fun storeOriginalAudioSetup() { + if (!isOrigAudioSetupStored) { + origAudioMode = mAudioManager.mode + origIsSpeakerPhoneOn = AudioManagerUtil.isSpeakerphoneOn(mAudioManager) + origIsMicrophoneMute = mAudioManager.isMicrophoneMute + isOrigAudioSetupStored = true + } + } + + fun restoreOriginalAudioSetup() { + if (isOrigAudioSetupStored) { + if (origIsSpeakerPhoneOn) { + mAudioDeviceManager.setSpeakerphoneOn(true) + } + mAudioManager.setMicrophoneMute(origIsMicrophoneMute) + mAudioManager.mode = origAudioMode + mReactContext.currentActivity?.apply { + volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + } + isOrigAudioSetupStored = false + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/WebRtcAudioUtils.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/WebRtcAudioUtils.kt new file mode 100644 index 0000000000..35d6c18639 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/utils/WebRtcAudioUtils.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2015 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +package com.streamvideo.reactnative.audio.utils + +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import com.facebook.react.bridge.ReactContext + +/** Utilities for implementations of `AudioDeviceModule`, mostly for logging. */ +object WebRtcAudioUtils { + /** Helper method for building a string of thread information. */ + fun threadInfo(): String { + val current = Thread.currentThread() + return "@[name=" + current.name + ", id=" + current.id + "]" + } + + /** Returns true if we're running on emulator. */ + fun runningOnEmulator(): Boolean { + // Hardware type of qemu1 is goldfish and qemu2 is ranchu. + return Build.HARDWARE == "goldfish" || Build.HARDWARE == "ranchu" + } + + /** Information about the current build, taken from system properties. */ + private fun logDeviceInfo(tag: String) { + Log.d( + tag, + (("Android SDK: " + Build.VERSION.SDK_INT) + + (", Release: " + Build.VERSION.RELEASE) + + (", Brand: " + Build.BRAND) + + (", Device: " + Build.DEVICE) + + (", Id: " + Build.ID) + + (", Hardware: " + Build.HARDWARE) + + (", Manufacturer: " + Build.MANUFACTURER) + + (", Model: " + Build.MODEL) + + (", Product: " + Build.PRODUCT)) + ) + } + + /** + * Logs information about the current audio state. The idea is to call this method when errors are + * detected to log under what conditions the error occurred. Hopefully it will provide clues to + * what might be the root cause. + */ + fun logAudioState(tag: String, reactContext: ReactContext) { + reactContext.currentActivity?.let { + Log.d(tag, "volumeControlStream: " + streamTypeToString(it.volumeControlStream)) + } + val audioManager = reactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + logDeviceInfo(tag) + logAudioStateBasic(tag, reactContext, audioManager) + logAudioStateVolume(tag, audioManager) + logAudioDeviceInfo(tag, audioManager) + } + + /** Converts AudioDeviceInfo types to local string representation. */ + private fun deviceTypeToString(type: Int): String { + return when (type) { + AudioDeviceInfo.TYPE_AUX_LINE -> "TYPE_AUX_LINE" + AudioDeviceInfo.TYPE_BLE_BROADCAST -> "TYPE_BLE_BROADCAST" + AudioDeviceInfo.TYPE_BLE_HEADSET -> "TYPE_BLE_HEADSET" + AudioDeviceInfo.TYPE_BLE_SPEAKER -> "TYPE_BLE_SPEAKER" + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "TYPE_BLUETOOTH_A2DP" + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "TYPE_BLUETOOTH_SCO" + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "TYPE_BUILTIN_EARPIECE" + AudioDeviceInfo.TYPE_BUILTIN_MIC -> "TYPE_BUILTIN_MIC" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "TYPE_BUILTIN_SPEAKER" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "TYPE_BUILTIN_SPEAKER_SAFE" + AudioDeviceInfo.TYPE_BUS -> "TYPE_BUS" + AudioDeviceInfo.TYPE_DOCK -> "TYPE_DOCK" + AudioDeviceInfo.TYPE_DOCK_ANALOG -> "TYPE_DOCK_ANALOG" + AudioDeviceInfo.TYPE_FM -> "TYPE_FM" + AudioDeviceInfo.TYPE_FM_TUNER -> "TYPE_FM_TUNER" + AudioDeviceInfo.TYPE_HDMI -> "TYPE_HDMI" + AudioDeviceInfo.TYPE_HDMI_ARC -> "TYPE_HDMI_ARC" + AudioDeviceInfo.TYPE_HDMI_EARC -> "TYPE_HDMI_EARC" + AudioDeviceInfo.TYPE_HEARING_AID -> "TYPE_HEARING_AID" + AudioDeviceInfo.TYPE_IP -> "TYPE_IP" + AudioDeviceInfo.TYPE_LINE_ANALOG -> "TYPE_LINE_ANALOG" + AudioDeviceInfo.TYPE_LINE_DIGITAL -> "TYPE_LINE_DIGITAL" + AudioDeviceInfo.TYPE_REMOTE_SUBMIX -> "TYPE_REMOTE_SUBMIX" + AudioDeviceInfo.TYPE_TELEPHONY -> "TYPE_TELEPHONY" + AudioDeviceInfo.TYPE_TV_TUNER -> "TYPE_TV_TUNER" + AudioDeviceInfo.TYPE_UNKNOWN -> "TYPE_UNKNOWN" + AudioDeviceInfo.TYPE_USB_ACCESSORY -> "TYPE_USB_ACCESSORY" + AudioDeviceInfo.TYPE_USB_DEVICE -> "TYPE_USB_DEVICE" + AudioDeviceInfo.TYPE_USB_HEADSET -> "TYPE_USB_HEADSET" + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "TYPE_WIRED_HEADPHONES" + AudioDeviceInfo.TYPE_WIRED_HEADSET -> "TYPE_WIRED_HEADSET" + else -> "TYPE_UNKNOWN($type)" + } + } + + fun audioSourceToString(source: Int): String { + return when (source) { + MediaRecorder.AudioSource.DEFAULT -> "DEFAULT" + MediaRecorder.AudioSource.MIC -> "MIC" + MediaRecorder.AudioSource.VOICE_UPLINK -> "VOICE_UPLINK" + MediaRecorder.AudioSource.VOICE_DOWNLINK -> "VOICE_DOWNLINK" + MediaRecorder.AudioSource.VOICE_CALL -> "VOICE_CALL" + MediaRecorder.AudioSource.CAMCORDER -> "CAMCORDER" + MediaRecorder.AudioSource.VOICE_RECOGNITION -> "VOICE_RECOGNITION" + MediaRecorder.AudioSource.VOICE_COMMUNICATION -> "VOICE_COMMUNICATION" + MediaRecorder.AudioSource.UNPROCESSED -> "UNPROCESSED" + MediaRecorder.AudioSource.VOICE_PERFORMANCE -> "VOICE_PERFORMANCE" + else -> "INVALID" + } + } + + fun channelMaskToString(mask: Int): String { + // For input or AudioRecord, the mask should be AudioFormat#CHANNEL_IN_MONO or + // AudioFormat#CHANNEL_IN_STEREO. AudioFormat#CHANNEL_IN_MONO is guaranteed to work on all + // devices. + return when (mask) { + AudioFormat.CHANNEL_IN_STEREO -> "IN_STEREO" + AudioFormat.CHANNEL_IN_MONO -> "IN_MONO" + else -> "INVALID" + } + } + + fun audioEncodingToString(enc: Int): String { + return when (enc) { + AudioFormat.ENCODING_INVALID -> "INVALID" + AudioFormat.ENCODING_PCM_16BIT -> "PCM_16BIT" + AudioFormat.ENCODING_PCM_8BIT -> "PCM_8BIT" + AudioFormat.ENCODING_PCM_FLOAT -> "PCM_FLOAT" + AudioFormat.ENCODING_AC3 -> "AC3" + AudioFormat.ENCODING_E_AC3 -> "AC3" + AudioFormat.ENCODING_DTS -> "DTS" + AudioFormat.ENCODING_DTS_HD -> "DTS_HD" + AudioFormat.ENCODING_MP3 -> "MP3" + else -> "Invalid encoding: $enc" + } + } + + /** Reports basic audio statistics. */ + private fun logAudioStateBasic(tag: String, context: Context, audioManager: AudioManager) { + Log.d( + tag, + ("Audio State: " + + ("audio mode: " + modeToString(audioManager.mode)) + + (", has mic: " + hasMicrophone(context)) + + (", mic muted: " + audioManager.isMicrophoneMute) + + (", music active: " + audioManager.isMusicActive) + + (", speakerphone: " + audioManager.isSpeakerphoneOn) + + (", BT SCO: " + audioManager.isBluetoothScoOn)) + ) + } + + /** Adds volume information for all possible stream types. */ + private fun logAudioStateVolume(tag: String, audioManager: AudioManager) { + val streams = intArrayOf( + AudioManager.STREAM_VOICE_CALL, + AudioManager.STREAM_MUSIC, + AudioManager.STREAM_RING, + AudioManager.STREAM_ALARM, + AudioManager.STREAM_NOTIFICATION, + AudioManager.STREAM_SYSTEM + ) + Log.d(tag, "Audio State: ") + // Some devices may not have volume controls and might use a fixed volume. + val fixedVolume = audioManager.isVolumeFixed + Log.d(tag, " fixed volume=$fixedVolume") + if (!fixedVolume) { + for (stream in streams) { + val info = StringBuilder() + info.append(" " + streamTypeToString(stream) + ": ") + info.append("volume=").append(audioManager.getStreamVolume(stream)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.append(", min=").append(audioManager.getStreamMinVolume(stream)) + } + info.append(", max=").append(audioManager.getStreamMaxVolume(stream)) + info.append(", muted=").append(audioManager.isStreamMute(stream)) + Log.d(tag, info.toString()) + } + } + } + + private fun logAudioDeviceInfo(tag: String, audioManager: AudioManager) { + val inputDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) + val outputDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + val devices = inputDevices + outputDevices + if (devices.isEmpty()) { + return + } + Log.d(tag, "Audio Devices: ") + for (device in devices) { + val info = StringBuilder() + info.append(" ").append(deviceTypeToString(device.type)) + info.append(if (device.isSource) "(in): " else "(out): ") + // An empty array indicates that the device supports arbitrary channel counts. + if (device.channelCounts.size > 0) { + info.append("channels=").append(device.channelCounts.contentToString()) + info.append(", ") + } + if (device.encodings.size > 0) { + // Examples: ENCODING_PCM_16BIT = 2, ENCODING_PCM_FLOAT = 4. + info.append("encodings=").append(device.encodings.contentToString()) + info.append(", ") + } + if (device.sampleRates.size > 0) { + info.append("sample rates=").append(device.sampleRates.contentToString()) + info.append(", ") + } + info.append("id=").append(device.id) + Log.d(tag, info.toString()) + } + } + + /** Converts media.AudioManager modes into local string representation. */ + fun modeToString(mode: Int): String { + return when (mode) { + AudioManager.MODE_IN_CALL -> "MODE_IN_CALL" + AudioManager.MODE_IN_COMMUNICATION -> "MODE_IN_COMMUNICATION" + AudioManager.MODE_NORMAL -> "MODE_NORMAL" + AudioManager.MODE_RINGTONE -> "MODE_RINGTONE" + else -> "MODE_INVALID" + } + } + + private fun streamTypeToString(stream: Int): String { + return when (stream) { + AudioManager.STREAM_VOICE_CALL -> "STREAM_VOICE_CALL" + AudioManager.STREAM_MUSIC -> "STREAM_MUSIC" + AudioManager.STREAM_RING -> "STREAM_RING" + AudioManager.STREAM_ALARM -> "STREAM_ALARM" + AudioManager.STREAM_NOTIFICATION -> "STREAM_NOTIFICATION" + AudioManager.STREAM_SYSTEM -> "STREAM_SYSTEM" + else -> "STREAM_INVALID" + } + } + + /** Returns true if the device can record audio via a microphone. */ + private fun hasMicrophone(context: Context): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_MICROPHONE) + } + +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt new file mode 100644 index 0000000000..1b8f89272c --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt @@ -0,0 +1,191 @@ +package com.streamvideo.reactnative.callmanager + +import android.util.Log +import android.view.WindowManager +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.UiThreadUtil +import com.streamvideo.reactnative.audio.AudioDeviceManager +import com.streamvideo.reactnative.audio.utils.CallAudioRole +import com.streamvideo.reactnative.audio.utils.WebRtcAudioUtils +import com.streamvideo.reactnative.model.AudioDeviceEndpoint +import java.util.Locale + + +class StreamInCallManagerModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext), LifecycleEventListener { + + private var audioManagerActivated = false + + private val mAudioDeviceManager = AudioDeviceManager(reactContext) + + + override fun getName(): String { + return TAG + } + + init { + reactContext.addLifecycleEventListener(this) + } + + // This method was removed upstream in react-native 0.74+, replaced with invalidate + // We will leave this stub here for older react-native versions compatibility + // ...but it will just delegate to the new invalidate method + @Deprecated("Deprecated in Java", ReplaceWith("invalidate()")) + @Suppress("removal") + override fun onCatalystInstanceDestroy() { + invalidate() + } + + override fun invalidate() { + mAudioDeviceManager.close() + super.invalidate() + } + + @ReactMethod + fun setAudioRole(audioRole: String) { + AudioDeviceManager.runInAudioThread { + if (audioManagerActivated) { + Log.e(TAG, "setAudioRole(): AudioManager is already activated and so Audio Role cannot be changed, current audio role is ${mAudioDeviceManager.callAudioRole}") + return@runInAudioThread + } + val role = audioRole.lowercase(Locale.getDefault()) + Log.d(TAG, "setAudioRole(): $audioRole $role") + if (role == "listener") { + mAudioDeviceManager.callAudioRole = CallAudioRole.Listener + } else { + mAudioDeviceManager.callAudioRole = CallAudioRole.Communicator + } + } + } + + @ReactMethod + fun setDefaultAudioDeviceEndpointType(endpointDeviceTypeName: String) { + AudioDeviceManager.runInAudioThread { + if (audioManagerActivated) { + Log.e(TAG, "setAudioRole(): AudioManager is already activated and so default audio device cannot be changed, current audio default device is ${mAudioDeviceManager.defaultAudioDevice}") + return@runInAudioThread + } + val endpointType = endpointDeviceTypeName.lowercase(Locale.getDefault()) + Log.d(TAG, "runInAudioThread(): $endpointDeviceTypeName $endpointType") + if (endpointType == "earpiece") { + mAudioDeviceManager.defaultAudioDevice = AudioDeviceEndpoint.TYPE_EARPIECE + } else { + mAudioDeviceManager.defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER + } + } + } + + @ReactMethod + fun start() { + AudioDeviceManager.runInAudioThread { + if (!audioManagerActivated) { + currentActivity?.let { + Log.d(TAG, "start() mAudioDeviceManager") + mAudioDeviceManager.start(it) + setKeepScreenOn(true) + audioManagerActivated = true + } + } + } + } + + @ReactMethod + fun stop() { + AudioDeviceManager.runInAudioThread { + if (audioManagerActivated) { + Log.d(TAG, "stop() mAudioDeviceManager") + mAudioDeviceManager.stop() + setMicrophoneMute(false) + setKeepScreenOn(false) + audioManagerActivated = false + } + } + } + + private fun setKeepScreenOn(enable: Boolean) { + Log.d(TAG, "setKeepScreenOn() $enable") + UiThreadUtil.runOnUiThread { + currentActivity?.let { + val window = it.window + if (enable) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + } + + @Suppress("unused") + @ReactMethod + fun setForceSpeakerphoneOn(enable: Boolean) { + if (mAudioDeviceManager.callAudioRole !== CallAudioRole.Communicator) { + Log.e(TAG, "setForceSpeakerphoneOn() is not supported when audio role is not Communicator") + return + } + mAudioDeviceManager.setSpeakerphoneOn(enable) + } + + @ReactMethod + fun setMicrophoneMute(enable: Boolean) { + mAudioDeviceManager.setMicrophoneMute(enable) + } + + @ReactMethod + fun logAudioState() { + WebRtcAudioUtils.logAudioState( + TAG, + reactApplicationContext, + ) + } + + @Suppress("unused") + @ReactMethod + fun chooseAudioDeviceEndpoint(endpointDeviceName: String) { + if (mAudioDeviceManager.callAudioRole !== CallAudioRole.Communicator) { + Log.e(TAG, "chooseAudioDeviceEndpoint() is not supported when audio role is not Communicator") + return + } + mAudioDeviceManager.switchDeviceFromDeviceName( + endpointDeviceName + ) + } + + @ReactMethod + fun muteAudioOutput() { + mAudioDeviceManager.muteAudioOutput() + } + + @ReactMethod + fun unmuteAudioOutput() { + mAudioDeviceManager.unmuteAudioOutput() + } + + override fun onHostResume() { + } + + override fun onHostPause() { + } + + override fun onHostDestroy() { + stop() + } + + @ReactMethod + fun addListener(eventName: String?) { + // Keep: Required for RN built in Event Emitter Calls. + } + + @ReactMethod + fun removeListeners(count: Int?) { + // Keep: Required for RN built in Event Emitter Calls. + } + + companion object { + const val TAG = "StreamInCallManager" + } +} + diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/model/AudioDeviceEndpoint.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/model/AudioDeviceEndpoint.kt new file mode 100644 index 0000000000..537c8cf354 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/model/AudioDeviceEndpoint.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.streamvideo.reactnative.model + +import android.media.AudioDeviceInfo +import androidx.annotation.IntDef +import androidx.annotation.RestrictTo +import com.streamvideo.reactnative.audio.utils.AudioDeviceEndpointUtils +import java.util.Objects + +/** + * Represents a single audio device endpoint. + * @param name The name of the device. Bluetooth devices have their proper names, everything else their endpoint name. See [AudioDeviceEndpointUtils.remapAudioDeviceNameToEndpointDeviceName] + * @param type The type of endpoint. + * @param deviceInfo The [AudioDeviceInfo] associated with the endpoint. + */ +public class AudioDeviceEndpoint( + public val name: String, + @EndpointType public val type: Int, + public val deviceInfo: AudioDeviceInfo, +) : Comparable { + + val deviceId = deviceInfo.id + + override fun toString(): String { + return "CallEndpoint(" + + "name=[$name]," + + "type=[${ + AudioDeviceEndpointUtils.endpointTypeToString(type)}]," + + "deviceId=[${deviceId}])" + } + + /** + * Compares this [AudioDeviceEndpoint] to the other [AudioDeviceEndpoint] for order. Returns a + * positive number if this type rank is greater than the other value. Returns a negative number + * if this type rank is less than the other value. Sort the CallEndpoint by type. Ranking them + * by: + * 1. TYPE_WIRED_HEADSET + * 2. TYPE_BLUETOOTH + * 3. TYPE_SPEAKER + * 4. TYPE_EARPIECE + * 5. TYPE_UNKNOWN If two endpoints have the same type, the name is compared to determine the + * value. + */ + override fun compareTo(other: AudioDeviceEndpoint): Int { + // sort by type + val res = this.getTypeRank().compareTo(other.getTypeRank()) + if (res != 0) { + return res + } + // break ties using alphabetic order + return this.name.toString().compareTo(other.name.toString()) + } + + override fun equals(other: Any?): Boolean { + return other is AudioDeviceEndpoint && + name == other.name && + type == other.type && deviceId == other.deviceId + } + + override fun hashCode(): Int { + return Objects.hash(name, type, deviceId) + } + + public companion object { + @RestrictTo(RestrictTo.Scope.LIBRARY) + @Retention(AnnotationRetention.SOURCE) + @IntDef( + TYPE_UNKNOWN, + TYPE_EARPIECE, + TYPE_BLUETOOTH, + TYPE_WIRED_HEADSET, + TYPE_SPEAKER, + ) + @Target(AnnotationTarget.TYPE, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER) + public annotation class EndpointType + + /** Indicates that the type of endpoint through which call media flows is unknown type. */ + public const val TYPE_UNKNOWN: Int = -1 + + /** Indicates that the type of endpoint through which call media flows is an earpiece. */ + public const val TYPE_EARPIECE: Int = 1 + + /** Indicates that the type of endpoint through which call media flows is a Bluetooth. */ + public const val TYPE_BLUETOOTH: Int = 2 + + /** + * Indicates that the type of endpoint through which call media flows is a wired headset. + */ + public const val TYPE_WIRED_HEADSET: Int = 3 + + /** Indicates that the type of endpoint through which call media flows is a speakerphone. */ + public const val TYPE_SPEAKER: Int = 4 + + } + + internal fun isBluetoothType(): Boolean { + return type == TYPE_BLUETOOTH + } + + internal fun isSpeakerType(): Boolean { + return type == TYPE_SPEAKER + } + + internal fun isWiredHeadsetType(): Boolean { + return type == TYPE_WIRED_HEADSET + } + + internal fun isEarpieceType(): Boolean { + return type == TYPE_EARPIECE + } + + private fun getTypeRank(): Int { + return when (this.type) { + TYPE_WIRED_HEADSET -> return 0 + TYPE_BLUETOOTH -> return 1 + TYPE_SPEAKER -> return 2 + TYPE_EARPIECE -> return 3 + else -> 4 + } + } +} diff --git a/packages/react-native-sdk/android/src/main/java/org/webrtc/audio/WebRtcAudioTrackHelper.kt b/packages/react-native-sdk/android/src/main/java/org/webrtc/audio/WebRtcAudioTrackHelper.kt new file mode 100644 index 0000000000..e2123914e4 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/org/webrtc/audio/WebRtcAudioTrackHelper.kt @@ -0,0 +1,12 @@ +package org.webrtc.audio + +import android.media.AudioAttributes + +object WebRtcAudioTrackHelper { + fun setAudioOutputAttributes( + adm: JavaAudioDeviceModule, + audioAttributes: AudioAttributes, + ) { + adm.audioOutput.audioAttributes = audioAttributes + } +} diff --git a/packages/react-native-sdk/ios/StreamInCallManager.m b/packages/react-native-sdk/ios/StreamInCallManager.m new file mode 100644 index 0000000000..98ac049976 --- /dev/null +++ b/packages/react-native-sdk/ios/StreamInCallManager.m @@ -0,0 +1,26 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(StreamInCallManager, RCTEventEmitter) + +RCT_EXTERN_METHOD(setAudioRole:(NSString *)audioRole) + +RCT_EXTERN_METHOD(setDefaultAudioDeviceEndpointType:(NSString *)endpointType) + +RCT_EXTERN_METHOD(start) + +RCT_EXTERN_METHOD(stop) + +RCT_EXTERN_METHOD(showAudioRoutePicker) + +RCT_EXTERN_METHOD(setForceSpeakerphoneOn:(BOOL)enable) + +RCT_EXTERN_METHOD(setMicrophoneMute:(BOOL)enable) + +RCT_EXTERN_METHOD(logAudioState) + +RCT_EXTERN_METHOD(muteAudioOutput) + +RCT_EXTERN_METHOD(unmuteAudioOutput) + +@end diff --git a/packages/react-native-sdk/ios/StreamInCallManager.swift b/packages/react-native-sdk/ios/StreamInCallManager.swift new file mode 100644 index 0000000000..dff568cec4 --- /dev/null +++ b/packages/react-native-sdk/ios/StreamInCallManager.swift @@ -0,0 +1,303 @@ +import Foundation +import React +import UIKit +import AVFoundation +import stream_react_native_webrtc +import AVKit +import MediaPlayer + +enum CallAudioRole { + case listener + case communicator +} + +enum DefaultAudioDevice { + case speaker + case earpiece +} + +@objc(StreamInCallManager) +class StreamInCallManager: RCTEventEmitter { + + private let audioSessionQueue = DispatchQueue(label: "io.getstream.rn.audioSessionQueue") + + private var audioManagerActivated = false + private var callAudioRole: CallAudioRole = .communicator + private var defaultAudioDevice: DefaultAudioDevice = .speaker + private var previousVolume: Float = 0.75 + + private struct AudioSessionState { + let category: AVAudioSession.Category + let mode: AVAudioSession.Mode + let options: AVAudioSession.CategoryOptions + } + + private var previousAudioSessionState: AudioSessionState? + + override func invalidate() { + stop() + super.invalidate() + } + + override static func requiresMainQueueSetup() -> Bool { + return false + } + + + @objc(setAudioRole:) + func setAudioRole(audioRole: String) { + audioSessionQueue.async { [self] in + if audioManagerActivated { + log("AudioManager is already activated, audio role cannot be changed.") + return + } + self.callAudioRole = audioRole.lowercased() == "listener" ? .listener : .communicator + } + } + + @objc(setDefaultAudioDeviceEndpointType:) + func setDefaultAudioDeviceEndpointType(endpointType: String) { + audioSessionQueue.async { [self] in + if audioManagerActivated { + log("AudioManager is already activated, default audio device cannot be changed.") + return + } + self.defaultAudioDevice = endpointType.lowercased() == "earpiece" ? .earpiece : .speaker + } + } + + @objc + func start() { + audioSessionQueue.async { [self] in + if audioManagerActivated { + return + } + let session = AVAudioSession.sharedInstance() + previousAudioSessionState = AudioSessionState( + category: session.category, + mode: session.mode, + options: session.categoryOptions + ) + configureAudioSession() + audioManagerActivated = true + } + } + + @objc + func stop() { + audioSessionQueue.async { [self] in + if !audioManagerActivated { + return + } + if let prev = previousAudioSessionState { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(prev.category, mode: prev.mode, options: prev.options) + } catch { + log("Error restoring previous audio session: \(error.localizedDescription)") + } + previousAudioSessionState = nil + } + audioManagerActivated = false + } + } + + private func configureAudioSession() { + let intendedCategory: AVAudioSession.Category! + let intendedMode: AVAudioSession.Mode! + let intendedOptions: AVAudioSession.CategoryOptions! + + if (callAudioRole == .listener) { + // enables high quality audio playback but disables microphone + intendedCategory = .playback + intendedMode = .default + intendedOptions = [] + } else { + intendedCategory = .playAndRecord + intendedMode = .voiceChat + + if (defaultAudioDevice == .speaker) { + // defaultToSpeaker will route to speaker if nothing else is connected + intendedOptions = [.allowBluetooth, .defaultToSpeaker] + } else { + // having no defaultToSpeaker makes sure audio goes to earpiece if nothing is connected + intendedOptions = [.allowBluetooth] + } + } + + // START: set the config that webrtc must use when it takes control + let rtcConfig = RTCAudioSessionConfiguration.webRTC() + rtcConfig.category = intendedCategory.rawValue + rtcConfig.mode = intendedMode.rawValue + rtcConfig.categoryOptions = intendedOptions + RTCAudioSessionConfiguration.setWebRTC(rtcConfig) + // END + + // START: compare current audio session with intended, and update if different + let session = RTCAudioSession.sharedInstance() + let currentCategory = session.category + let currentMode = session.mode + let currentOptions = session.categoryOptions + let currentIsActive = session.isActive + + if currentCategory != intendedCategory.rawValue || currentMode != intendedMode.rawValue || currentOptions != intendedOptions || !currentIsActive { + session.lockForConfiguration() + do { + try session.setCategory(intendedCategory, mode: intendedMode, options: intendedOptions) + try session.setActive(true) + log("configureAudioSession: setCategory success \(intendedCategory.rawValue) \(intendedMode.rawValue) \(intendedOptions.rawValue)") + } catch { + log("configureAudioSession: setCategory failed due to: \(error.localizedDescription)") + do { + try session.setMode(intendedMode) + try session.setActive(true) + log("configureAudioSession: setMode success \(intendedMode.rawValue)") + } catch { + log("configureAudioSession: Error setting mode: \(error.localizedDescription)") + } + } + session.unlockForConfiguration() + } else { + log("configureAudioSession: no change needed") + } + // END + } + + @objc(showAudioRoutePicker) + public func showAudioRoutePicker() { + guard #available(iOS 11.0, tvOS 11.0, macOS 10.15, *) else { + return + } + DispatchQueue.main.async { + // AVRoutePickerView is the default UI with a + // button that users tap to stream audio/video content to a media receiver + let routePicker = AVRoutePickerView() + // Send a touch up inside event to the button to trigger the audio route picker + (routePicker.subviews.first { $0 is UIButton } as? UIButton)? + .sendActions(for: .touchUpInside) + } + } + + @objc(setForceSpeakerphoneOn:) + func setForceSpeakerphoneOn(enable: Bool) { + let session = AVAudioSession.sharedInstance() + do { + try session.overrideOutputAudioPort(enable ? .speaker : .none) + try session.setActive(true) + } catch { + log("Error setting speakerphone: \(error)") + } + } + + @objc(setMicrophoneMute:) + func setMicrophoneMute(enable: Bool) { + log("iOS does not support setMicrophoneMute()") + } + + @objc + func logAudioState() { + let session = AVAudioSession.sharedInstance() + let logString = """ + Audio State: + Category: \(session.category.rawValue) + Mode: \(session.mode.rawValue) + Output Port: \(session.currentRoute.outputs.first?.portName ?? "N/A") + Input Port: \(session.currentRoute.inputs.first?.portName ?? "N/A") + Category Options: \(session.categoryOptions) + InputNumberOfChannels: \(session.inputNumberOfChannels) + OutputNumberOfChannels: \(session.outputNumberOfChannels) + """ + log(logString) + } + + @objc(muteAudioOutput) + func muteAudioOutput() { + DispatchQueue.main.async { [self] in + let volumeView = MPVolumeView() + + // Add to a temporary view hierarchy to make it functional + if let window = getCurrentWindow() { + volumeView.frame = CGRect(x: -1000, y: -1000, width: 1, height: 1) + window.addSubview(volumeView) + + // Give it a moment to initialize + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { + self.previousVolume = slider.value + slider.setValue(0.0, animated: false) + slider.sendActions(for: .valueChanged) + self.log("Audio output muted via slider event") + } else { + self.log("Could not find volume slider") + } + + // Remove from view hierarchy after use + volumeView.removeFromSuperview() + } + } + } + } + + @objc(unmuteAudioOutput) + func unmuteAudioOutput() { + DispatchQueue.main.async { [self] in + let volumeView = MPVolumeView() + + // Add to a temporary view hierarchy to make it functional + if let window = getCurrentWindow() { + volumeView.frame = CGRect(x: -1000, y: -1000, width: 1, height: 1) + window.addSubview(volumeView) + + // Give it a moment to initialize + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { + let targetVolume = self.previousVolume > 0 ? self.previousVolume : 0.75 + slider.setValue(targetVolume, animated: false) + slider.sendActions(for: .valueChanged) + self.log("Audio output unmuted via slider event") + } else { + self.log("Could not find volume slider") + } + + // Remove from view hierarchy after use + volumeView.removeFromSuperview() + } + } + } + } + + // MARK: - RCTEventEmitter + + override func supportedEvents() -> [String]! { + // TODO: list events that can be sent to JS + return [] + } + + @objc + override func addListener(_ eventName: String!) { + super.addListener(eventName) + } + + @objc + override func removeListeners(_ count: Double) { + super.removeListeners(count) + } + + // MARK: - Helper Methods + private func getCurrentWindow() -> UIWindow? { + if #available(iOS 13.0, *) { + return UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows + .first(where: { $0.isKeyWindow }) + } else { + return UIApplication.shared.keyWindow + } + } + + // MARK: - Logging Helper + private func log(_ message: String) { + NSLog("InCallManager: %@", message) + } + +} diff --git a/packages/react-native-sdk/ios/StreamVideoReactNative-Bridging-Header.h b/packages/react-native-sdk/ios/StreamVideoReactNative-Bridging-Header.h index 6c8f458ce1..d434561a65 100644 --- a/packages/react-native-sdk/ios/StreamVideoReactNative-Bridging-Header.h +++ b/packages/react-native-sdk/ios/StreamVideoReactNative-Bridging-Header.h @@ -12,3 +12,4 @@ #import #import #import "WebRTCModule.h" +#import "WebRTCModuleOptions.h" \ No newline at end of file diff --git a/packages/react-native-sdk/ios/StreamVideoReactNative.m b/packages/react-native-sdk/ios/StreamVideoReactNative.m index 257b0e697f..5d2c293dfa 100644 --- a/packages/react-native-sdk/ios/StreamVideoReactNative.m +++ b/packages/react-native-sdk/ios/StreamVideoReactNative.m @@ -69,7 +69,7 @@ -(instancetype)init { if ((self = [super init])) { _notificationCenter = CFNotificationCenterGetDarwinNotifyCenter(); [UIDevice currentDevice].batteryMonitoringEnabled = YES; - [self setupObserver]; + [self setupScreenshareEventObserver]; [StreamVideoReactNative initializeSharedDictionaries]; } return self; @@ -85,7 +85,7 @@ -(instancetype)init { resolve(thermalStateString); } --(void)dealloc { +-(void)invalidate { if (_busyTonePlayer) { if (_busyTonePlayer.isPlaying) { [_busyTonePlayer stop]; @@ -93,11 +93,12 @@ -(void)dealloc { _busyTonePlayer = nil; [self removeAudioInterruptionHandling]; } - [self clearObserver]; + [self clearScreenshareEventObserver]; + [super invalidate]; } --(void)setupObserver { +-(void)setupScreenshareEventObserver { CFNotificationCenterAddObserver(_notificationCenter, (__bridge const void *)(self), broadcastNotificationCallback, @@ -112,7 +113,7 @@ -(void)setupObserver { CFNotificationSuspensionBehaviorDeliverImmediately); } --(void)clearObserver { +-(void)clearScreenshareEventObserver { CFNotificationCenterRemoveObserver(_notificationCenter, (__bridge const void *)(self), (__bridge CFStringRef)kBroadcastStartedNotification, diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 93c0ba0048..ede43e52ec 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -60,7 +60,7 @@ "@react-native-firebase/app": ">=17.5.0", "@react-native-firebase/messaging": ">=17.5.0", "@stream-io/noise-cancellation-react-native": ">=0.1.0", - "@stream-io/react-native-webrtc": ">=125.4.3", + "@stream-io/react-native-webrtc": ">=125.4.4", "@stream-io/video-filters-react-native": ">=0.1.0", "expo": ">=47.0.0", "expo-build-properties": "*", @@ -69,7 +69,6 @@ "react-native": ">=0.67.0", "react-native-callkeep": ">=4.3.11", "react-native-gesture-handler": ">=2.8.0", - "react-native-incall-manager": ">=4.2.0", "react-native-reanimated": ">=2.7.0", "react-native-svg": ">=13.6.0", "react-native-voip-push-notification": ">=3.3.1" @@ -126,7 +125,7 @@ "@react-native-firebase/messaging": "^22.1.0", "@react-native/babel-preset": "^0.79.2", "@stream-io/noise-cancellation-react-native": "workspace:^", - "@stream-io/react-native-webrtc": "125.4.3", + "@stream-io/react-native-webrtc": "125.4.4", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "13.2.0", @@ -134,7 +133,6 @@ "@types/jest": "^29.5.14", "@types/lodash.merge": "^4.6.9", "@types/react": "^19.1.3", - "@types/react-native-incall-manager": "^4.0.3", "@types/react-test-renderer": "^19.1.0", "expo": "~53.0.8", "expo-build-properties": "^0.13.2", @@ -147,7 +145,6 @@ "react-native-builder-bob": "~0.23", "react-native-callkeep": "^4.3.16", "react-native-gesture-handler": "^2.25.0", - "react-native-incall-manager": "^4.2.1", "react-native-reanimated": "~3.17.5", "react-native-svg": "15.11.2", "react-native-voip-push-notification": "3.3.3", diff --git a/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx b/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx index 5636e16e27..d5d26d729c 100644 --- a/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx +++ b/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx @@ -1,12 +1,11 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { + NativeModules, + Platform, StyleSheet, View, - NativeModules, type ViewStyle, - Platform, } from 'react-native'; -import InCallManager from 'react-native-incall-manager'; import { CallParticipantsGrid, type CallParticipantsGridProps, @@ -43,6 +42,7 @@ import { type ScreenShareOverlayProps, } from '../../utility/ScreenShareOverlay'; import { RTCViewPipIOS } from './RTCViewPipIOS'; +import { getRNInCallManagerLibNoThrow } from '../../../modules/call-manager/PrevLibDetection'; export type StreamReactionType = StreamReaction & { icon: string; @@ -95,7 +95,8 @@ export type CallContentProps = Pick< */ disablePictureInPicture?: boolean; /** - * Props to set the audio mode for the InCallManager. + * @deprecated This prop is deprecated and will be removed in the future. Use `StreamInCallManager` instead. + * Props to set the audio mode for the react-native-incall-manager library * If media type is video, audio is routed by default to speaker, otherwise it is routed to earpiece. * Changing the mode on the fly is not supported. * Manually invoke `InCallManager.start({ media })` to achieve this. @@ -119,9 +120,9 @@ export const CallContent = ({ layout = 'grid', landscape = false, supportedReactions, + initialInCallManagerAudioMode = 'video', iOSPiPIncludeLocalParticipantVideo, disablePictureInPicture, - initialInCallManagerAudioMode = 'video', }: CallContentProps) => { const [ showRemoteParticipantInFloatingView, @@ -140,8 +141,6 @@ export const CallContent = ({ useAutoEnterPiPEffect(disablePictureInPicture); - const incallManagerModeRef = useRef(initialInCallManagerAudioMode); - const _remoteParticipants = useRemoteParticipants(); const remoteParticipants = useDebouncedValue(_remoteParticipants, 300); // we debounce the remote participants to avoid unnecessary rerenders that happen when participant tracks are all subscribed simultaneously const localParticipant = useLocalParticipant(); @@ -188,10 +187,15 @@ export const CallContent = ({ /** * This hook is used to handle IncallManager specs of the application. */ + const incallManagerModeRef = useRef(initialInCallManagerAudioMode); useEffect(() => { - InCallManager.start({ media: incallManagerModeRef.current }); - - return () => InCallManager.stop(); + const prevInCallManager = getRNInCallManagerLibNoThrow(); + if (prevInCallManager) { + prevInCallManager.start({ media: incallManagerModeRef.current }); + return () => { + prevInCallManager.stop(); + }; + } }, []); const handleFloatingViewParticipantSwitch = () => { diff --git a/packages/react-native-sdk/src/components/Livestream/HostLivestream/HostLivestream.tsx b/packages/react-native-sdk/src/components/Livestream/HostLivestream/HostLivestream.tsx index f5b68991a9..6b99d157a4 100644 --- a/packages/react-native-sdk/src/components/Livestream/HostLivestream/HostLivestream.tsx +++ b/packages/react-native-sdk/src/components/Livestream/HostLivestream/HostLivestream.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import InCallManager from 'react-native-incall-manager'; +import { getRNInCallManagerLibNoThrow } from '../../../modules/call-manager/PrevLibDetection'; import { useTheme } from '../../../contexts'; import { @@ -89,8 +89,13 @@ export const HostLivestream = ({ // Automatically route audio to speaker devices as relevant for watching videos. useEffect(() => { - InCallManager.start({ media: 'video' }); - return () => InCallManager.stop(); + const prevInCallManager = getRNInCallManagerLibNoThrow(); + if (prevInCallManager) { + prevInCallManager.start({ media: 'video' }); + return () => { + prevInCallManager.stop(); + }; + } }, []); const [topViewHeight, setTopViewHeight] = React.useState(); diff --git a/packages/react-native-sdk/src/components/Livestream/LivestreamControls/ViewerLivestreamControls.tsx b/packages/react-native-sdk/src/components/Livestream/LivestreamControls/ViewerLivestreamControls.tsx index 03588ba5f8..c760614574 100644 --- a/packages/react-native-sdk/src/components/Livestream/LivestreamControls/ViewerLivestreamControls.tsx +++ b/packages/react-native-sdk/src/components/Livestream/LivestreamControls/ViewerLivestreamControls.tsx @@ -10,6 +10,7 @@ import { ViewerLeaveStreamButton as DefaultViewerLeaveStreamButton, type ViewerLeaveStreamButtonProps, } from './ViewerLeaveStreamButton'; +import { callManager } from '../../../modules/call-manager'; import { useTheme } from '../../../contexts'; import { Z_INDEX } from '../../../constants'; import { @@ -18,12 +19,11 @@ import { LiveIndicator, } from '../LivestreamTopView'; import { IconWrapper, Maximize } from '../../../icons'; -import InCallManager from 'react-native-incall-manager'; import { - VolumeOff, - VolumeOn, PauseIcon, PlayIcon, + VolumeOff, + VolumeOn, } from '../../../icons/LivestreamControls'; /** @@ -104,10 +104,16 @@ export const ViewerLivestreamControls = ({ }; const toggleAudio = () => { - setIsMuted(!isMuted); - InCallManager.setForceSpeakerphoneOn(isMuted); + const shouldMute = !isMuted; + callManager.speaker.setMute(shouldMute); + setIsMuted(shouldMute); }; + useEffect(() => { + // always unmute audio output on mount for consistency + callManager.speaker.setMute(false); + }, []); + const togglePlayPause = () => { setIsPlaying(!isPlaying); showPlayPauseButtonWithTimeout(); diff --git a/packages/react-native-sdk/src/components/Livestream/LivestreamLayout/LivestreamLayout.tsx b/packages/react-native-sdk/src/components/Livestream/LivestreamLayout/LivestreamLayout.tsx index fbba8d910e..7fe6eb60e2 100644 --- a/packages/react-native-sdk/src/components/Livestream/LivestreamLayout/LivestreamLayout.tsx +++ b/packages/react-native-sdk/src/components/Livestream/LivestreamLayout/LivestreamLayout.tsx @@ -1,5 +1,9 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { hasScreenShare, SfuModels } from '@stream-io/video-client'; +import { + hasScreenShare, + SfuModels, + StreamVideoParticipant, +} from '@stream-io/video-client'; import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings'; import { StyleSheet, View, type ViewStyle } from 'react-native'; import { usePaginatedLayoutSortPreset } from '../../../hooks/usePaginatedLayoutSortPreset'; @@ -9,6 +13,7 @@ import { type VideoRendererProps, } from '../../Participant'; import type { ScreenShareOverlayProps } from '../../utility/ScreenShareOverlay'; +import { useTrackDimensions } from '../../../hooks'; /** * Props for the LivestreamLayout component. @@ -56,11 +61,6 @@ export const LivestreamLayout = ({ React.ComponentProps>['objectFit'] >(); - // no need to pass object fit for local participant as the dimensions are for remote tracks - const objectFitToBeSet = currentSpeaker?.isLocalParticipant - ? undefined - : objectFit; - const onDimensionsChange = useCallback( (d: SfuModels.VideoDimension | undefined) => { if (d) { @@ -84,48 +84,57 @@ export const LivestreamLayout = ({ livestreamLayout.container, ]} > - {VideoRenderer && hasOngoingScreenShare && presenter && (presenter.isLocalParticipant && ScreenShareOverlay ? ( ) : ( - + <> + + + ))} {VideoRenderer && !hasOngoingScreenShare && currentSpeaker && ( - + <> + + + )} ); }; -const RemoteVideoTrackDimensionsRenderLessComponent = ({ +const VideoTrackDimensionsRenderLessComponent = ({ onDimensionsChange, + participant, + trackType, }: { onDimensionsChange: (d: SfuModels.VideoDimension | undefined) => void; + participant: StreamVideoParticipant; + trackType: 'videoTrack' | 'screenShareTrack'; }) => { - const [dimension, setDimension] = useState(); - const { useCallStatsReport } = useCallStateHooks(); - const statsReport = useCallStatsReport(); - const highestFrameHeight = statsReport?.subscriberStats?.highestFrameHeight; - const highestFrameWidth = statsReport?.subscriberStats?.highestFrameWidth; - - useEffect(() => { - if (highestFrameHeight && highestFrameWidth) { - setDimension({ height: highestFrameHeight, width: highestFrameWidth }); - } - }, [highestFrameHeight, highestFrameWidth]); + const { width, height } = useTrackDimensions(participant, trackType); useEffect(() => { - onDimensionsChange(dimension); - }, [dimension, onDimensionsChange]); + onDimensionsChange({ width, height }); + }, [width, height, onDimensionsChange]); return null; }; diff --git a/packages/react-native-sdk/src/components/Livestream/ViewerLivestream/ViewerLivestream.tsx b/packages/react-native-sdk/src/components/Livestream/ViewerLivestream/ViewerLivestream.tsx index 01082ddcf7..e784fb5782 100644 --- a/packages/react-native-sdk/src/components/Livestream/ViewerLivestream/ViewerLivestream.tsx +++ b/packages/react-native-sdk/src/components/Livestream/ViewerLivestream/ViewerLivestream.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; -import InCallManager from 'react-native-incall-manager'; import { useTheme } from '../../../contexts'; import { type ViewerLivestreamTopViewProps } from '../LivestreamTopView/ViewerLivestreamTopView'; import { @@ -20,6 +19,7 @@ import { import { CallingState, hasVideo } from '@stream-io/video-client'; import { CallEndedView } from '../LivestreamPlayer/LivestreamEnded'; import { ViewerLobby } from './ViewerLobby'; +import { getRNInCallManagerLibNoThrow } from '../../../modules/call-manager/PrevLibDetection'; /** * Props for the ViewerLivestream component. @@ -102,8 +102,13 @@ export const ViewerLivestream = ({ // Automatically route audio to speaker devices as relevant for watching videos. useEffect(() => { - InCallManager.start({ media: 'video' }); - return () => InCallManager.stop(); + const prevInCallManager = getRNInCallManagerLibNoThrow(); + if (prevInCallManager) { + prevInCallManager.start({ media: 'video' }); + return () => { + prevInCallManager.stop(); + }; + } }, []); useEffect(() => { diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts index 0a9f12256a..cd733a9782 100644 --- a/packages/react-native-sdk/src/index.ts +++ b/packages/react-native-sdk/src/index.ts @@ -27,6 +27,7 @@ export * from './hooks'; export * from './theme'; export * from './utils'; export * from './translations'; +export * from './modules/call-manager'; // Overriding 'StreamVideo' and 'StreamCall' from '@stream-io/video-react-bindings' // Explicitly re-exporting to resolve ambiguity. diff --git a/packages/react-native-sdk/src/modules/call-manager/CallManager.ts b/packages/react-native-sdk/src/modules/call-manager/CallManager.ts new file mode 100644 index 0000000000..f25583be2e --- /dev/null +++ b/packages/react-native-sdk/src/modules/call-manager/CallManager.ts @@ -0,0 +1,116 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; +import { AudioDeviceStatus, StreamInCallManagerConfig } from './types'; + +const NativeManager = NativeModules.StreamInCallManager; + +const invariant = (condition: boolean, message: string) => { + if (!condition) throw new Error(message); +}; + +class AndroidCallManager { + private eventEmitter?: NativeEventEmitter; + + /** + * Get the current audio device status. + */ + getAudioDeviceStatus = async (): Promise => { + invariant(Platform.OS === 'android', 'Supported only on Android'); + return NativeManager.getAudioDeviceStatus(); + }; + + /** + * Switches the audio device to the specified endpoint. + * + * @param endpointName the device name. + */ + selectAudioDevice = (endpointName: string): void => { + invariant(Platform.OS === 'android', 'Supported only on Android'); + NativeManager.chooseAudioDeviceEndpoint(endpointName); + }; + + /** + * Register a listener for audio device changes. + * @param onChange callback to be called when the audio device changes. + */ + addAudioDeviceChangeListener = ( + onChange: (audioDeviceStatus: AudioDeviceStatus) => void, + ): (() => void) => { + invariant(Platform.OS === 'android', 'Supported only on Android'); + this.eventEmitter ??= new NativeEventEmitter(NativeManager); + const s = this.eventEmitter.addListener('onAudioDeviceChanged', onChange); + return () => s.remove(); + }; +} + +class IOSCallManager { + /** + * Will trigger the iOS device selector. + */ + showDeviceSelector = (): void => { + invariant(Platform.OS === 'ios', 'Supported only on iOS'); + NativeManager.showAudioRoutePicker(); + }; +} + +class SpeakerManager { + /** + * Mutes or unmutes the speaker. + */ + setMute = (mute: boolean): void => { + if (mute) { + NativeManager.muteAudioOutput(); + } else { + NativeManager.unmuteAudioOutput(); + } + }; + + /** + * Forces speakerphone on/off. + */ + setForceSpeakerphoneOn = (force: boolean): void => { + NativeManager.setForceSpeakerphoneOn(force); + }; +} + +export class CallManager { + android = new AndroidCallManager(); + ios = new IOSCallManager(); + speaker = new SpeakerManager(); + + /** + * Starts the in call manager. + * + * @param config.audioRole The audio role to set. It can be one of the following: + * - `'communicator'`: (Default) For use cases like video or voice calls. + * It prioritizes low latency and allows manual audio device switching. + * Audio routing is controlled by the SDK. + * - `'listener'`: For use cases like livestream viewing. + * It prioritizes high-quality stereo audio streaming. + * Audio routing is controlled by the OS, and manual switching is not supported. + * + * @param config.deviceEndpointType The default audio device endpoint type to set. It can be one of the following: + * - `'speaker'`: (Default) For normal video or voice calls. + * - `'earpiece'`: For voice-only mobile call type scenarios. + */ + start = (config?: StreamInCallManagerConfig): void => { + NativeManager.setAudioRole(config?.audioRole ?? 'communicator'); + if (config?.audioRole === 'communicator') { + const type = config.deviceEndpointType ?? 'speaker'; + NativeManager.setDefaultAudioDeviceEndpointType(type); + } + NativeManager.start(); + }; + + /** + * Stops the in call manager. + */ + stop = (): void => { + NativeManager.stop(); + }; + + /** + * For debugging purposes, will emit a log event with the current audio state. + * in the native layer. + */ + logAudioState = (): void => NativeManager.logAudioState(); +} diff --git a/packages/react-native-sdk/src/modules/call-manager/PrevLibDetection.ts b/packages/react-native-sdk/src/modules/call-manager/PrevLibDetection.ts new file mode 100644 index 0000000000..ccb9e4ba88 --- /dev/null +++ b/packages/react-native-sdk/src/modules/call-manager/PrevLibDetection.ts @@ -0,0 +1,27 @@ +import { getLogger } from '@stream-io/video-client'; + +declare class RNInCallManagerLib { + start(setup?: { + auto?: boolean; + media?: 'video' | 'audio'; + ringback?: string; + }): void; + + stop(setup?: { busytone?: string }): void; +} + +let rnInCallManagerLib: RNInCallManagerLib | undefined; + +try { + rnInCallManagerLib = require('react-native-incall-manager').default; +} catch {} + +export function getRNInCallManagerLibNoThrow() { + if (rnInCallManagerLib) { + getLogger(['getRNInCallManagerLibNoThrow'])( + 'debug', + 'react-native-incall-manager library is not required to be installed from 1.22.0 version of the @stream-io/video-react-native-sdk. Please check the migration documentation at https://getstream.io/video/docs/react-native/migration-guides/1.22.0/ for more details.', + ); + } + return rnInCallManagerLib; +} diff --git a/packages/react-native-sdk/src/modules/call-manager/index.ts b/packages/react-native-sdk/src/modules/call-manager/index.ts new file mode 100644 index 0000000000..d7c371a372 --- /dev/null +++ b/packages/react-native-sdk/src/modules/call-manager/index.ts @@ -0,0 +1,5 @@ +import { CallManager } from './CallManager'; + +export * from './types'; + +export const callManager = new CallManager(); diff --git a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts new file mode 100644 index 0000000000..d1fc9bbf0c --- /dev/null +++ b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts @@ -0,0 +1,80 @@ +import 'react-native'; +import type { NativeModule } from 'react-native'; +import type { AudioDeviceStatus, AudioRole, DeviceEndpointType } from './types'; + +export interface CallManager extends NativeModule { + /** + * Sets the audio role for the call. This should be done before calling **start()**. + * + * @param role The audio role to set. It can be one of the following: + * - `'communicator'`: (Default) For use cases like video or voice calls. + * It prioritizes low latency and allows manual audio device switching. + * Audio routing is controlled by the SDK. + * - `'listener'`: For use cases like livestream viewing. + * It prioritizes high-quality stereo audio streaming. + * Audio routing is controlled by the OS and manual switching is not supported. + */ + setAudioRole: (role: AudioRole) => void; + + /** + * Sets the default audio device endpoint type for the call. This should be done before calling **start()**. + * @param type The default audio device endpoint type to set. It can be one of the following: + * - `'speaker'`: (Default) For normal video or voice calls. + * - `'earpiece'`: For voice only mobile call type scenarios. + */ + setDefaultAudioDeviceEndpointType: (type: DeviceEndpointType) => void; + + /** + * Choose an audio device endpoint. + * @param endpointName - The name of the audio device endpoint to choose. + */ + chooseAudioDeviceEndpoint: (endpoint: string) => void; + + /** + * Get the current audio device status. + * @returns The audio device status. + */ + getAudioDeviceStatus: () => Promise; + + /** + * Shows the iOS audio route picker. + */ + showAudioRoutePicker: () => void; + + /** + * Start the in call manager. + */ + start: () => void; + + /** + * Stop the in call manager. + */ + stop: () => void; + + /** + * Mutes the speaker + */ + muteAudioOutput: () => void; + + /** + * Unmutes the speaker + */ + unmuteAudioOutput: () => void; + + /** + * Forces speakerphone on/off. + */ + setForceSpeakerphoneOn: (boolean) => void; + + /** + * Log the current audio state natively. + * Meant for debugging purposes. + */ + logAudioState: () => void; +} + +declare module 'react-native' { + interface NativeModulesStatic { + StreamInCallManager: CallManager; + } +} diff --git a/packages/react-native-sdk/src/modules/call-manager/types.ts b/packages/react-native-sdk/src/modules/call-manager/types.ts new file mode 100644 index 0000000000..52567da61a --- /dev/null +++ b/packages/react-native-sdk/src/modules/call-manager/types.ts @@ -0,0 +1,25 @@ +export type AudioDeviceEndpointType = + | 'Bluetooth Device' + | 'Earpiece' + | 'Speaker' + | 'Wired Headset' + | 'Unknown' + | (string & {}); + +export type AudioDeviceStatus = { + devices: string[]; + currentEndpointType: AudioDeviceEndpointType; + selectedDevice: string; +}; + +export type AudioRole = 'communicator' | 'listener'; +export type DeviceEndpointType = 'speaker' | 'earpiece'; + +export type StreamInCallManagerConfig = + | { + audioRole: 'communicator'; + deviceEndpointType?: DeviceEndpointType; + } + | { + audioRole: 'listener'; + }; diff --git a/sample-apps/react-native/dogfood/android/app/build.gradle b/sample-apps/react-native/dogfood/android/app/build.gradle index 737a452fc6..708b257ee9 100644 --- a/sample-apps/react-native/dogfood/android/app/build.gradle +++ b/sample-apps/react-native/dogfood/android/app/build.gradle @@ -118,7 +118,6 @@ android { dependencies { implementation (project(':react-native-callkeep')) - implementation (project(':react-native-incall-manager')) // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index be63a13018..48350e33d7 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -1878,8 +1878,6 @@ PODS: - React-logger (= 0.79.2) - React-perflogger (= 0.79.2) - React-utils (= 0.79.2) - - ReactNativeIncallManager (4.2.1): - - React-Core - RNCallKeep (4.3.16): - React - RNCClipboard (1.16.2): @@ -2291,7 +2289,7 @@ PODS: - ReactCommon/turbomodule/core - stream-react-native-webrtc - Yoga - - stream-react-native-webrtc (125.4.3): + - stream-react-native-webrtc (125.4.4): - React-Core - StreamWebRTC (~> 125.6422.070) - stream-video-react-native (1.21.2): @@ -2407,7 +2405,6 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - - ReactNativeIncallManager (from `../node_modules/react-native-incall-manager`) - RNCallKeep (from `../node_modules/react-native-callkeep`) - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - "RNCPushNotificationIOS (from `../node_modules/@react-native-community/push-notification-ios`)" @@ -2587,8 +2584,6 @@ EXTERNAL SOURCES: :path: build/generated/ios ReactCommon: :path: "../node_modules/react-native/ReactCommon" - ReactNativeIncallManager: - :path: "../node_modules/react-native-incall-manager" RNCallKeep: :path: "../node_modules/react-native-callkeep" RNCClipboard: @@ -2705,7 +2700,6 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 04d5eb15eb46be6720e17a4a7fa92940a776e584 ReactCodegen: 041559ba76d00f6680dfa0916b3c791f4babe5ea ReactCommon: 1511ef100f1afa4c199fe52fe7a8d2529a41429a - ReactNativeIncallManager: dccd3e7499caa3bb73d3acfedf4fb0360f1a87d5 RNCallKeep: 1930a01d8caf48f018be4f2db0c9f03405c2f977 RNCClipboard: 055cd8f50702b9ebc5a58f7622e0cda073878d21 RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440 @@ -2722,7 +2716,7 @@ SPEC CHECKSUMS: stream-chat-react-native: 1803cedc0bf16361b6762e8345f9256e26c60e6f stream-io-noise-cancellation-react-native: 39911e925efffe7cee4462d6bbc4b1d33de5beae stream-io-video-filters-react-native: 6894b6ac20d55f26858a6729d60adf6e73bd2398 - stream-react-native-webrtc: b7076764940085a0450a6551f452e7f5a713f42f + stream-react-native-webrtc: 460795039c3aa0c83c882fe2cc59f5ebae3f6a18 stream-video-react-native: 04b9aa84f92e5f0e5d54529d056a4b40a3c8d902 StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 StreamWebRTC: a50ebd8beba4def8f4e378b4895824c3520f9889 @@ -2731,4 +2725,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: aa62ba474533b73121c2068a13a8b909b17efbaa -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index d1d2f9a412..52956f1ff9 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -11,7 +11,7 @@ "setup": "yarn && yarn build:react-native:deps && npx pod-install" }, "dependencies": { - "@gorhom/bottom-sheet": "4.6.4", + "@gorhom/bottom-sheet": "5.1.6", "@notifee/react-native": "9.1.8", "@react-native-clipboard/clipboard": "^1.16.2", "@react-native-community/netinfo": "^11.4.1", @@ -21,7 +21,7 @@ "@react-navigation/native": "^7.0", "@react-navigation/native-stack": "^7.1", "@stream-io/noise-cancellation-react-native": "workspace:^", - "@stream-io/react-native-webrtc": "125.4.3", + "@stream-io/react-native-webrtc": "125.4.4", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "axios": "^1.8.1", @@ -34,7 +34,6 @@ "react-native-gesture-handler": "^2.25.0", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^7.2.3", - "react-native-incall-manager": "4.2.1", "react-native-mmkv": "3.2.0", "react-native-permissions": "^5.4.0", "react-native-reanimated": "~3.17.5", @@ -63,7 +62,6 @@ "@rnx-kit/metro-config": "^1.3.3", "@rnx-kit/metro-resolver-symlinks": "^0.1.22", "@types/react": "^19.1.3", - "@types/react-native-incall-manager": "^4.0.3", "@types/react-native-video": "^5.0.20", "typescript": "^5.8.3" }, diff --git a/sample-apps/react-native/dogfood/src/assets/AudioOutput.tsx b/sample-apps/react-native/dogfood/src/assets/AudioOutput.tsx new file mode 100644 index 0000000000..af5ceff9e6 --- /dev/null +++ b/sample-apps/react-native/dogfood/src/assets/AudioOutput.tsx @@ -0,0 +1,20 @@ + + +; + +import React from 'react'; +import { Svg, Path } from 'react-native-svg'; + +import { ColorValue } from 'react-native'; + +type Props = { + color: ColorValue; + size: number; +}; + +//https://fonts.google.com/icons?selected=Material+Symbols+Outlined:media_output:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=speaker&icon.size=24&icon.color=%23e3e3e3 +export const AudioOutput = ({ color, size }: Props) => ( + + + +); diff --git a/sample-apps/react-native/dogfood/src/assets/audio-routes/bluetooth_connected_24dp.png b/sample-apps/react-native/dogfood/src/assets/audio-routes/bluetooth_connected_24dp.png new file mode 100644 index 0000000000..69815f9b8c Binary files /dev/null and b/sample-apps/react-native/dogfood/src/assets/audio-routes/bluetooth_connected_24dp.png differ diff --git a/sample-apps/react-native/dogfood/src/assets/audio-routes/call_24dp.png b/sample-apps/react-native/dogfood/src/assets/audio-routes/call_24dp.png new file mode 100644 index 0000000000..7a96d13ad2 Binary files /dev/null and b/sample-apps/react-native/dogfood/src/assets/audio-routes/call_24dp.png differ diff --git a/sample-apps/react-native/dogfood/src/assets/audio-routes/close_24dp.png b/sample-apps/react-native/dogfood/src/assets/audio-routes/close_24dp.png new file mode 100644 index 0000000000..2d111393fd Binary files /dev/null and b/sample-apps/react-native/dogfood/src/assets/audio-routes/close_24dp.png differ diff --git a/sample-apps/react-native/dogfood/src/assets/audio-routes/headphones_24dp.png b/sample-apps/react-native/dogfood/src/assets/audio-routes/headphones_24dp.png new file mode 100644 index 0000000000..b2af420025 Binary files /dev/null and b/sample-apps/react-native/dogfood/src/assets/audio-routes/headphones_24dp.png differ diff --git a/sample-apps/react-native/dogfood/src/assets/audio-routes/volume_up_24dp.png b/sample-apps/react-native/dogfood/src/assets/audio-routes/volume_up_24dp.png new file mode 100644 index 0000000000..5868a6bff2 Binary files /dev/null and b/sample-apps/react-native/dogfood/src/assets/audio-routes/volume_up_24dp.png differ diff --git a/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx b/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx index efdd75a979..c5206c9737 100644 --- a/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx +++ b/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx @@ -7,6 +7,7 @@ import React, { } from 'react'; import { CallContent, + callManager, NoiseCancellationProvider, useBackgroundFilters, useCall, @@ -76,6 +77,13 @@ export const ActiveCall = ({ }); }, [call]); + useEffect(() => { + callManager.start(); + return () => { + callManager.stop(); + }; + }, []); + useEffect(() => { const unsub = call?.on('call.moderation_blur', () => { applyVideoBlurFilter('heavy'); diff --git a/sample-apps/react-native/dogfood/src/components/AndroidAudioRoutePickerDrawer.tsx b/sample-apps/react-native/dogfood/src/components/AndroidAudioRoutePickerDrawer.tsx new file mode 100644 index 0000000000..032cf81b15 --- /dev/null +++ b/sample-apps/react-native/dogfood/src/components/AndroidAudioRoutePickerDrawer.tsx @@ -0,0 +1,261 @@ +import { + AudioDeviceEndpointType, + AudioDeviceStatus, + callManager, + useTheme, +} from '@stream-io/video-react-native-sdk'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Animated, + Easing, + FlatList, + Image, + Modal, + PanResponder, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + TouchableWithoutFeedback, + useWindowDimensions, + View, +} from 'react-native'; +import { BOTTOM_CONTROLS_HEIGHT } from '../constants'; + +type DrawerProps = { + isVisible: boolean; + onClose: () => void; +}; + +const endpointNameToIconImage = (endPointName: AudioDeviceEndpointType) => { + switch (endPointName) { + case 'Speaker': + return require('../assets/audio-routes/volume_up_24dp.png'); + case 'Earpiece': + return require('../assets/audio-routes/call_24dp.png'); + case 'Wired Headset': + return require('../assets/audio-routes/headphones_24dp.png'); + default: + return require('../assets/audio-routes/bluetooth_connected_24dp.png'); + } +}; + +export const AndroidAudioRoutePickerDrawer: React.FC = ({ + isVisible, + onClose, +}) => { + const screenHeight = useWindowDimensions().height; + const drawerHeight = screenHeight * 0.8; + const styles = useStyles(); + + const [audioDeviceStatus, setAudioDeviceStatus] = + useState(); + + useEffect(() => { + callManager.android.getAudioDeviceStatus().then(setAudioDeviceStatus); + return callManager.android.addAudioDeviceChangeListener( + setAudioDeviceStatus, + ); + }, []); + + const audioRoutes = audioDeviceStatus?.devices ?? []; + const selectedAudioDeviceName = audioDeviceStatus?.selectedDevice; + + // negative offset is needed so the drawer component start above the bottom controls + const offset = -BOTTOM_CONTROLS_HEIGHT; + + const translateY = useRef( + new Animated.Value(drawerHeight + offset), + ).current; + + const SNAP_TOP = offset; + const SNAP_BOTTOM = (drawerHeight + offset) / 2; + const getClosestSnapPoint = (y: number) => { + const points = [SNAP_TOP, SNAP_BOTTOM]; + return points.reduce((prev, curr) => + Math.abs(curr - y) < Math.abs(prev - y) ? curr : prev, + ); + }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + translateY.setOffset(translateY._value); + translateY.setValue(0); + }, + onPanResponderMove: (_, gestureState) => { + translateY.setValue(gestureState.dy); + }, + onPanResponderRelease: () => { + translateY.flattenOffset(); + const currentPosition = translateY._value; + const snapPoint = getClosestSnapPoint(currentPosition); + + if (snapPoint === SNAP_BOTTOM) { + onClose(); + } else { + Animated.spring(translateY, { + toValue: snapPoint, + useNativeDriver: true, + bounciness: 4, + }).start(); + } + }, + }), + ).current; + + useEffect(() => { + if (isVisible) { + callManager.android.getAudioDeviceStatus().then(setAudioDeviceStatus); + Animated.spring(translateY, { + toValue: SNAP_TOP, + useNativeDriver: true, + bounciness: 4, + }).start(); + } else { + Animated.timing(translateY, { + toValue: SNAP_BOTTOM, + duration: 300, + useNativeDriver: true, + }).start(); + } + }, [isVisible, SNAP_BOTTOM, SNAP_TOP, translateY]); + + const elasticAnimRef = useRef(new Animated.Value(0.5)); + + const handleOptionPress = (route: string) => { + callManager.android.selectAudioDevice(route); + Animated.timing(elasticAnimRef.current, { + toValue: 0.2, + duration: 150, + useNativeDriver: true, + easing: Easing.linear, + }).start(onClose); + }; + + const dragIndicator = ( + + + + ); + + return ( + + + + + + {dragIndicator} + item} + renderItem={({ item }) => ( + handleOptionPress(item)} + > + + {item} + {item === selectedAudioDeviceName && ( + // Checkmark for selected item + )} + + )} + /> + + + + + + ); +}; + +const useStyles = () => { + const { + theme: { colors, variants }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + optionContainer: { + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: 1, + borderColor: colors.sheetTertiary, + padding: variants.spacingSizes.md, + marginBottom: variants.spacingSizes.xs, + }, + routeIcon: { + width: 24, + height: 24, + marginHorizontal: 8, + }, + selectedIcon: { + marginLeft: 'auto', // Push checkmark to the right + color: colors.iconSuccess, + fontSize: 20, + fontWeight: 'bold', + }, + overlay: { + flex: 1, + justifyContent: 'flex-end', + }, + safeArea: { + flex: 1, + justifyContent: 'flex-end', + }, + container: { + backgroundColor: colors.sheetPrimary, + borderTopLeftRadius: variants.borderRadiusSizes.lg, + borderTopRightRadius: variants.borderRadiusSizes.lg, + padding: variants.spacingSizes.md, + maxHeight: '80%', + maxWidth: 500, + }, + dragIndicator: { + width: '100%', + height: variants.spacingSizes.xs, + alignItems: 'center', + justifyContent: 'center', + marginBottom: variants.spacingSizes.md, + }, + dragIndicatorBar: { + width: 36, + height: 5, + backgroundColor: colors.buttonSecondary, + borderRadius: 2, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.sheetTertiary, + borderRadius: variants.borderRadiusSizes.lg, + paddingHorizontal: variants.spacingSizes.md, + height: variants.roundButtonSizes.lg, + backgroundColor: colors.buttonSecondary, + marginBottom: variants.spacingSizes.xs, + }, + label: { + fontSize: variants.fontSizes.lg, + color: colors.iconPrimary, + fontWeight: '600', + }, + }), + [variants, colors], + ); +}; diff --git a/sample-apps/react-native/dogfood/src/components/CallControlls/MoreActionsButton.tsx b/sample-apps/react-native/dogfood/src/components/CallControlls/MoreActionsButton.tsx index e9734d4e59..084dfd1d56 100644 --- a/sample-apps/react-native/dogfood/src/components/CallControlls/MoreActionsButton.tsx +++ b/sample-apps/react-native/dogfood/src/components/CallControlls/MoreActionsButton.tsx @@ -1,14 +1,24 @@ import React, { useEffect, useRef, useState } from 'react'; import { CallControlsButton, + callManager, OwnCapability, useCall, useCallStateHooks, - useTheme, - useScreenshot, useNoiseCancellation, + useScreenshot, + useTheme, } from '@stream-io/video-react-native-sdk'; -import { Text, Modal, Image, TouchableOpacity } from 'react-native'; +import { + Alert, + Image, + Modal, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import { IconWrapper } from '@stream-io/video-react-native-sdk/src/icons'; import MoreActions from '../../assets/MoreActions'; import { BottomControlsDrawer, DrawerOption } from '../BottomControlsDrawer'; @@ -24,7 +34,8 @@ import Stats from '../../assets/Stats'; import ClosedCaptions from '../../assets/ClosedCaptions'; import Screenshot from '../../assets/Screenshot'; import Hearing from '../../assets/Hearing'; -import { View, Alert, StyleSheet } from 'react-native'; +import { AudioOutput } from '../../assets/AudioOutput'; +import { AndroidAudioRoutePickerDrawer } from '../AndroidAudioRoutePickerDrawer'; /** * The props for the More Actions Button in the Call Controls. @@ -54,6 +65,10 @@ export const MoreActionsButton = ({ setEnabled: setNoiseCancellationEnabled, } = useNoiseCancellation(); const [isDrawerVisible, setIsDrawerVisible] = useState(false); + const [ + isAndroidAudioRoutePickerDrawerVisible, + setIsAndroidAudioRoutePickerDrawerVisible, + ] = useState(false); const [showCallStats, setShowCallStats] = useState(false); const [feedbackModalVisible, setFeedbackModalVisible] = useState(false); const [screenshotModalVisible, setScreenshotModalVisible] = useState(false); @@ -114,6 +129,15 @@ export const MoreActionsButton = ({ }); }; + const showAudioRoutePicker = async () => { + if (Platform.OS === 'ios') { + callManager.ios.showDeviceSelector(); + } else { + setIsAndroidAudioRoutePickerDrawerVisible(true); + } + setIsDrawerVisible(false); + }; + const getScreenshotOfDominantSpeaker = async () => { let speaker = dominantSpeaker; if (!speaker) { @@ -210,10 +234,25 @@ export const MoreActionsButton = ({ ), onPress: getScreenshotOfDominantSpeaker, }, + { + id: '5', + label: 'Show Audio Route Picker', + icon: ( + + + + ), + onPress: () => { + showAudioRoutePicker(); + }, + }, ...(isSupported && deviceSupportsAdvancedAudioProcessing ? [ { - id: '5', + id: '6', label: isNoiseCancellationEnabled ? 'Disable noise cancellation' : 'Enable noise cancellation', @@ -232,7 +271,7 @@ export const MoreActionsButton = ({ ...(canToggle ? [ { - id: '6', + id: '7', label: getCaptionsLabel(), icon: ( @@ -270,6 +309,14 @@ export const MoreActionsButton = ({ style={moreActionsButton} color={buttonColor} > + {Platform.OS === 'android' && ( + { + setIsAndroidAudioRoutePickerDrawerVisible(false); + }} + /> + )} { diff --git a/sample-apps/react-native/dogfood/src/screens/AudioRoom/Room.tsx b/sample-apps/react-native/dogfood/src/screens/AudioRoom/Room.tsx index 407e821e21..302478c36e 100644 --- a/sample-apps/react-native/dogfood/src/screens/AudioRoom/Room.tsx +++ b/sample-apps/react-native/dogfood/src/screens/AudioRoom/Room.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { CallingState, + callManager, OwnCapability, SfuModels, useCall, @@ -8,7 +9,6 @@ import { } from '@stream-io/video-react-native-sdk'; import { StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import InCallManager from 'react-native-incall-manager'; import { ControlsPanel } from '../../components/AudioRoom/ControlsPanel'; import { PermissionRequestsPanel } from '../../components/AudioRoom/PermissionRequestsPanel'; import { ParticipantsPanel } from '../../components/AudioRoom/ParticipantsPanel'; @@ -19,10 +19,9 @@ export default function Room({ onClose }: { onClose: () => void }) { const callingState = useCallCallingState(); const call = useCall(); - // Automatically route audio to ear piece. useEffect(() => { - InCallManager.start({ media: 'audio' }); - return () => InCallManager.stop(); + callManager.start(); + return () => callManager.stop(); }, []); // when the component unmounts, leave the call diff --git a/sample-apps/react-native/dogfood/src/screens/LiveStream/HostLiveStream.tsx b/sample-apps/react-native/dogfood/src/screens/LiveStream/HostLiveStream.tsx index c0d24ca1ff..32ee8787b4 100644 --- a/sample-apps/react-native/dogfood/src/screens/LiveStream/HostLiveStream.tsx +++ b/sample-apps/react-native/dogfood/src/screens/LiveStream/HostLiveStream.tsx @@ -1,7 +1,8 @@ import { Call, - StreamCall, + callManager, HostLivestream, + StreamCall, useConnectedUser, useStreamVideoClient, } from '@stream-io/video-react-native-sdk'; @@ -74,6 +75,13 @@ export const HostLiveStreamScreen = ({ getOrCreateCall(); }, [call, connectedUser, navigation]); + useEffect(() => { + callManager.start(); + return () => { + callManager.stop(); + }; + }, []); + const CustomHostLivestreamMediaControls = useCallback(() => { return ( { + callManager.start({ audioRole: 'listener' }); + return () => { + callManager.stop(); + }; + }, []); /** * Note: Here we provide the `StreamCall` component again. This is done, so that the call used, is created by the anonymous user. */ - return ( - - - - ); + return ; }; export const ViewLiveStreamScreen = ({ diff --git a/sample-apps/react-native/expo-video-sample/package.json b/sample-apps/react-native/expo-video-sample/package.json index 490b022528..e62a537242 100644 --- a/sample-apps/react-native/expo-video-sample/package.json +++ b/sample-apps/react-native/expo-video-sample/package.json @@ -19,7 +19,7 @@ "@react-native-firebase/app": "^23", "@react-native-firebase/messaging": "^23", "@stream-io/noise-cancellation-react-native": "workspace:^", - "@stream-io/react-native-webrtc": "125.4.3", + "@stream-io/react-native-webrtc": "125.4.4", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "expo": "^54.0.0", @@ -36,7 +36,6 @@ "react-native": "0.81.4", "react-native-callkeep": "^4.3.16", "react-native-gesture-handler": "~2.28.0", - "react-native-incall-manager": "^4.2.1", "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -49,7 +48,6 @@ "@rnx-kit/metro-config": "^2.1", "@rnx-kit/metro-resolver-symlinks": "^0.1.36", "@types/react": "~19.1.10", - "@types/react-native-incall-manager": "^4.0.3", "typescript": "~5.9.2" }, "private": true, diff --git a/sample-apps/react-native/ringing-tutorial/package.json b/sample-apps/react-native/ringing-tutorial/package.json index da3bcf75fb..22924ee510 100644 --- a/sample-apps/react-native/ringing-tutorial/package.json +++ b/sample-apps/react-native/ringing-tutorial/package.json @@ -23,7 +23,7 @@ "@react-native-firebase/messaging": "^22.1.0", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", - "@stream-io/react-native-webrtc": "125.4.3", + "@stream-io/react-native-webrtc": "125.4.4", "@stream-io/video-react-native-sdk": "workspace:^", "expo": "^53.0.8", "expo-blur": "~14.1.4", @@ -44,7 +44,6 @@ "react-native": "0.79.2", "react-native-callkeep": "^4.3.16", "react-native-gesture-handler": "^2.25.0", - "react-native-incall-manager": "^4.2.1", "react-native-reanimated": "~3.17.5", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.10.0", diff --git a/yarn.lock b/yarn.lock index e12a454b7a..bb7674ee16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4138,9 +4138,9 @@ __metadata: languageName: node linkType: hard -"@gorhom/bottom-sheet@npm:4.6.4": - version: 4.6.4 - resolution: "@gorhom/bottom-sheet@npm:4.6.4" +"@gorhom/bottom-sheet@npm:5.1.6": + version: 5.1.6 + resolution: "@gorhom/bottom-sheet@npm:5.1.6" dependencies: "@gorhom/portal": "npm:1.0.14" invariant: "npm:^2.2.4" @@ -4149,14 +4149,14 @@ __metadata: "@types/react-native": "*" react: "*" react-native: "*" - react-native-gesture-handler: ">=1.10.1" - react-native-reanimated: ">=2.2.0" + react-native-gesture-handler: ">=2.16.1" + react-native-reanimated: ">=3.16.0" peerDependenciesMeta: "@types/react": optional: true "@types/react-native": optional: true - checksum: 10/a437c5490c13c69100a7b605039a51064847b82202a985ec09acd7c6e6c39e735380c5e612b212c6466d95e3e3817e3ef50f54d18d489fcb2f1294370b4b5616 + checksum: 10/321d85abac24cfab5ce6c77623381c73d95a09d8b1d95c0e7e968540af5c9d1bd3369b669459df483a962f65a363ea929940f006a1b4f3acf8f4b31500c4863f languageName: node linkType: hard @@ -8495,11 +8495,10 @@ __metadata: "@rnx-kit/metro-config": "npm:^2.1" "@rnx-kit/metro-resolver-symlinks": "npm:^0.1.36" "@stream-io/noise-cancellation-react-native": "workspace:^" - "@stream-io/react-native-webrtc": "npm:125.4.3" + "@stream-io/react-native-webrtc": "npm:125.4.4" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:~19.1.10" - "@types/react-native-incall-manager": "npm:^4.0.3" expo: "npm:^54.0.0" expo-build-properties: "npm:~1.0.9" expo-constants: "npm:~18.0.9" @@ -8514,7 +8513,6 @@ __metadata: react-native: "npm:0.81.4" react-native-callkeep: "npm:^4.3.16" react-native-gesture-handler: "npm:~2.28.0" - react-native-incall-manager: "npm:^4.2.1" react-native-reanimated: "npm:~4.1.0" react-native-safe-area-context: "npm:~5.6.0" react-native-screens: "npm:~4.16.0" @@ -8623,6 +8621,19 @@ __metadata: languageName: node linkType: hard +"@stream-io/react-native-webrtc@npm:125.4.4": + version: 125.4.4 + resolution: "@stream-io/react-native-webrtc@npm:125.4.4" + dependencies: + base64-js: "npm:1.5.1" + debug: "npm:4.3.4" + event-target-shim: "npm:6.0.2" + peerDependencies: + react-native: ">=0.73.0" + checksum: 10/a29be364f4b4e3312e9f9ba2c124bd6f973a00a86936c73504ef9246ccb3ffdbf977c42d919833f8b5de2dff7b842f80f00be6e3bdb916416d925639ec273f74 + languageName: node + linkType: hard + "@stream-io/stream-video-react-tutorial@workspace:sample-apps/react/stream-video-react-tutorial": version: 0.0.0-use.local resolution: "@stream-io/stream-video-react-tutorial@workspace:sample-apps/react/stream-video-react-tutorial" @@ -8817,7 +8828,7 @@ __metadata: "@babel/core": "npm:^7.27.1" "@babel/preset-env": "npm:^7.27.2" "@babel/runtime": "npm:^7.27.1" - "@gorhom/bottom-sheet": "npm:4.6.4" + "@gorhom/bottom-sheet": "npm:5.1.6" "@notifee/react-native": "npm:9.1.8" "@react-native-clipboard/clipboard": "npm:^1.16.2" "@react-native-community/cli": "npm:18.0.0" @@ -8836,11 +8847,10 @@ __metadata: "@rnx-kit/metro-config": "npm:^1.3.3" "@rnx-kit/metro-resolver-symlinks": "npm:^0.1.22" "@stream-io/noise-cancellation-react-native": "workspace:^" - "@stream-io/react-native-webrtc": "npm:125.4.3" + "@stream-io/react-native-webrtc": "npm:125.4.4" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:^19.1.3" - "@types/react-native-incall-manager": "npm:^4.0.3" "@types/react-native-video": "npm:^5.0.20" axios: "npm:^1.8.1" react: "npm:19.0.0" @@ -8852,7 +8862,6 @@ __metadata: react-native-gesture-handler: "npm:^2.25.0" react-native-haptic-feedback: "npm:^2.3.3" react-native-image-picker: "npm:^7.2.3" - react-native-incall-manager: "npm:4.2.1" react-native-mmkv: "npm:3.2.0" react-native-permissions: "npm:^5.4.0" react-native-reanimated: "npm:~3.17.5" @@ -8889,7 +8898,7 @@ __metadata: "@react-navigation/native": "npm:^7.0.14" "@rnx-kit/metro-config": "npm:^1.3.17" "@rnx-kit/metro-resolver-symlinks": "npm:^0.1.36" - "@stream-io/react-native-webrtc": "npm:125.4.3" + "@stream-io/react-native-webrtc": "npm:125.4.4" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:^19.1.3" expo: "npm:^53.0.8" @@ -8911,7 +8920,6 @@ __metadata: react-native: "npm:0.79.2" react-native-callkeep: "npm:^4.3.16" react-native-gesture-handler: "npm:^2.25.0" - react-native-incall-manager: "npm:^4.2.1" react-native-reanimated: "npm:~3.17.5" react-native-safe-area-context: "npm:5.4.0" react-native-screens: "npm:~4.10.0" @@ -8937,7 +8945,7 @@ __metadata: "@react-native-firebase/messaging": "npm:^22.1.0" "@react-native/babel-preset": "npm:^0.79.2" "@stream-io/noise-cancellation-react-native": "workspace:^" - "@stream-io/react-native-webrtc": "npm:125.4.3" + "@stream-io/react-native-webrtc": "npm:125.4.4" "@stream-io/video-client": "workspace:*" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-bindings": "workspace:*" @@ -8947,7 +8955,6 @@ __metadata: "@types/jest": "npm:^29.5.14" "@types/lodash.merge": "npm:^4.6.9" "@types/react": "npm:^19.1.3" - "@types/react-native-incall-manager": "npm:^4.0.3" "@types/react-test-renderer": "npm:^19.1.0" expo: "npm:~53.0.8" expo-build-properties: "npm:^0.13.2" @@ -8962,7 +8969,6 @@ __metadata: react-native-builder-bob: "npm:~0.23" react-native-callkeep: "npm:^4.3.16" react-native-gesture-handler: "npm:^2.25.0" - react-native-incall-manager: "npm:^4.2.1" react-native-reanimated: "npm:~3.17.5" react-native-svg: "npm:15.11.2" react-native-url-polyfill: "npm:1.3.0" @@ -8979,7 +8985,7 @@ __metadata: "@react-native-firebase/app": ">=17.5.0" "@react-native-firebase/messaging": ">=17.5.0" "@stream-io/noise-cancellation-react-native": ">=0.1.0" - "@stream-io/react-native-webrtc": ">=125.4.3" + "@stream-io/react-native-webrtc": ">=125.4.4" "@stream-io/video-filters-react-native": ">=0.1.0" expo: ">=47.0.0" expo-build-properties: "*" @@ -8988,7 +8994,6 @@ __metadata: react-native: ">=0.67.0" react-native-callkeep: ">=4.3.11" react-native-gesture-handler: ">=2.8.0" - react-native-incall-manager: ">=4.2.0" react-native-reanimated: ">=2.7.0" react-native-svg: ">=13.6.0" react-native-voip-push-notification: ">=3.3.1" @@ -9551,13 +9556,6 @@ __metadata: languageName: node linkType: hard -"@types/react-native-incall-manager@npm:^4.0.3": - version: 4.0.3 - resolution: "@types/react-native-incall-manager@npm:4.0.3" - checksum: 10/3e2ee89ebd26bdbb05d97406d3874d3a03226db49dee04b3dc225b129e0ae8da4728245a9a8ceb554b60743d30f02f0a6f15e0945b3ad879f71b7c0ac4019fc6 - languageName: node - linkType: hard - "@types/react-native-video@npm:^5.0.20": version: 5.0.20 resolution: "@types/react-native-video@npm:5.0.20" @@ -23130,15 +23128,6 @@ __metadata: languageName: node linkType: hard -"react-native-incall-manager@npm:4.2.1, react-native-incall-manager@npm:^4.2.1": - version: 4.2.1 - resolution: "react-native-incall-manager@npm:4.2.1" - peerDependencies: - react-native: ">=0.40.0" - checksum: 10/fdcfb9fc5c3b9b8e15d7f9b0834618ef0b6b024b15f42c5f41f3ed7c09a21b72b73c3b5d6dd05d5ff18402856ee8726c7a173acd2df86bfe4c43afa7a2111197 - languageName: node - linkType: hard - "react-native-is-edge-to-edge@npm:1.1.7, react-native-is-edge-to-edge@npm:^1.1.6": version: 1.1.7 resolution: "react-native-is-edge-to-edge@npm:1.1.7"