diff --git a/package.json b/package.json index cd439badcb..4f2539c4af 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "build:video-filters-react-native": "yarn workspace @stream-io/video-filters-react-native run build", "build:noise-cancellation-react-native": "yarn workspace @stream-io/noise-cancellation-react-native run build", "build:react:deps": "yarn workspaces foreach -Apv --topological-dev --include 'packages/{client,react-{sdk,bindings},styling,{video,audio}-filters-web}' run build", - "build:react-native:deps": "yarn workspaces foreach -Apv --topological-dev --include 'packages/{client,react-bindings,{video-filters,noise-cancellation}-react-native,react-native-sdk}' run build", + "build:react-native:deps": "yarn workspaces foreach -Apv --topological-dev --include 'packages/{client,react-bindings,audio-filters-web,{video-filters,noise-cancellation}-react-native,react-native-sdk}' run build", "build:vercel": "yarn build:react:deps && yarn build:react:dogfood", "start:egress": "yarn workspace @stream-io/egress-composite start", "build:egress": "yarn workspace @stream-io/egress-composite build", 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 index e55fac5ea7..408746b1d6 100644 --- 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 @@ -44,7 +44,22 @@ internal class AudioDeviceEndpointUtils { val endpoints: MutableList = mutableListOf() var foundWiredHeadset = false val omittedDevices = StringBuilder("omitting devices =[") - adiArr.toList().forEach { audioDeviceInfo -> + adiArr.forEach { audioDeviceInfo -> + /** + * Only devices in a sink role can be selected via AudioManager#setCommunicationDevice + * (API 31+). We enforce this at the endpoint construction level as a safety net + * because AudioDeviceCallback can surface devices that aren't valid call endpoints. + */ + val isInvalidCallEndpoint = !audioDeviceInfo.isSink + + if (isInvalidCallEndpoint) { + omittedDevices.append( + "(type=[${audioDeviceInfo.type}]," + + " name=[${audioDeviceInfo.productName}])," + ) + return@forEach + } + val endpoint = getEndpointFromAudioDeviceInfo(audioDeviceInfo) if (endpoint.type != AudioDeviceEndpoint.TYPE_UNKNOWN) { if (endpoint.type == AudioDeviceEndpoint.TYPE_WIRED_HEADSET) { @@ -115,11 +130,8 @@ internal class AudioDeviceEndpointUtils { 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 + AudioDeviceInfo.TYPE_BLE_HEADSET -> AudioDeviceEndpoint.TYPE_BLUETOOTH // Everything else is defaulted to TYPE_UNKNOWN else -> AudioDeviceEndpoint.TYPE_UNKNOWN } 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 index 4048df468e..c4762a1be9 100644 --- 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 @@ -25,6 +25,39 @@ internal class AudioManagerUtil { } } + /** + * Safe wrapper around [AudioManager.setCommunicationDevice] to avoid crashing the app on + * OEM-specific edge cases. On API 31+, Android requires that: + * - the device is a sink (output), and + * - the device is among [AudioManager.availableCommunicationDevices]. + */ + @RequiresApi(31) + fun setCommunicationDeviceSafely( + audioManager: AudioManager, + deviceInfo: AudioDeviceInfo, + ): Boolean { + + if (!deviceInfo.isSink) { + Log.w(TAG, "setCommunicationDeviceSafely: rejecting non-sink device type=${deviceInfo.type}, id=${deviceInfo.id}") + return false + } + + val available = audioManager.availableCommunicationDevices + val isAvailable = available.any { it.id == deviceInfo.id } + if (!isAvailable) { + Log.w(TAG, "setCommunicationDeviceSafely: device not in availableCommunicationDevices type=${deviceInfo.type}, id=${deviceInfo.id}") + return false + } + + return try { + audioManager.setCommunicationDevice(deviceInfo) + true + } catch (e: IllegalArgumentException) { + Log.w(TAG, "setCommunicationDeviceSafely: failed type=${deviceInfo.type}, id=${deviceInfo.id}", e) + false + } + } + fun isSpeakerphoneOn(audioManager: AudioManager): Boolean { return if (Build.VERSION.SDK_INT >= 31) { AudioManager31PlusImpl.isSpeakerphoneOn(audioManager) @@ -83,7 +116,7 @@ internal class AudioManagerUtil { endpointMaps.nonBluetoothEndpoints.values.firstOrNull { it.type == deviceType }?.let { - audioManager.setCommunicationDevice(it.deviceInfo) + setCommunicationDeviceSafely(audioManager, it.deviceInfo) bluetoothManager.updateDevice() return endpointMaps.nonBluetoothEndpoints[deviceType] } @@ -92,7 +125,7 @@ internal class AudioManagerUtil { AudioDeviceEndpoint.TYPE_SPEAKER -> { val speakerDevice = endpointMaps.nonBluetoothEndpoints[AudioDeviceEndpoint.TYPE_SPEAKER] speakerDevice?.let { - audioManager.setCommunicationDevice(it.deviceInfo) + setCommunicationDeviceSafely(audioManager, it.deviceInfo) bluetoothManager.updateDevice() return speakerDevice }