diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx
index b9d3964c7..689473969 100644
--- a/packages/audiodocs/docs/system/audio-manager.mdx
+++ b/packages/audiodocs/docs/system/audio-manager.mdx
@@ -2,7 +2,7 @@
sidebar_position: 1
---
-import { Optional, ReadOnly, IOS, Experimental } from '@site/src/components/Badges';
+import { Optional, ReadOnly, IOS, Android, Experimental } from '@site/src/components/Badges';
# AudioManager
@@ -44,7 +44,7 @@ function App() {
## Methods
-### `setAudioSessionOptions`
+### `setAudioSessionOptions`
:::warning AVAudioSession Compatibility
Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API (for example, explicitly setting `allowBluetoothA2DP` alongside the `playback` category) will cause the configuration to fail. This can result in a `SessionActivationError` and total audio silence.
@@ -58,7 +58,9 @@ Always verify valid category and option combinations in [Apple's AVAudioSession
#### Returns `undefined`.
-### `setAudioSessionActivity` {#setaudiosessionactivity}
+On Android 12 (API 31) or later, set `androidMode: 'inCommunication'` before activating the session to request Android's communication audio mode. The optional `androidCommunicationDevice` requests `'speaker'`, `'earpiece'`, or `'systemDefault'` as the initial route. Existing Android behavior is unchanged when `androidMode` is omitted.
+
+### `setAudioSessionActivity` {#setaudiosessionactivity}
| Parameter | Type | Description |
| :---: | :---: | :---- |
@@ -68,6 +70,24 @@ Always verify valid category and option combinations in [Apple's AVAudioSession
Deactivating the session while a recording is in progress or paused stops that recording first, since an inactive session would corrupt its output. The finalized files are then available through [`AudioRecorder.consumeLastRecordingResult()`](../inputs/audio-recorder.mdx#consumelastrecordingresult). Prefer calling [`AudioRecorder.stop()`](../inputs/audio-recorder.mdx#stop) yourself before deactivating, so the file info arrives through its promise.
+With Android `inCommunication` mode, activation requests transient focus using voice-communication/speech attributes, enters `MODE_IN_COMMUNICATION`, and applies the configured initial communication-device preference. Deactivation clears the request, abandons that focus, and restores the prior mode. Activation rejects below API 31 or if Android denies focus or the requested route. After a permanent focus loss, deactivate the session before starting a new one; the library does not reacquire focus automatically.
+
+### `setCommunicationDevice`
+
+| Parameter | Type | Description |
+| :---: | :---: | :---- |
+| `device` | [`CommunicationDevice`](./audio-manager.mdx#communicationdevice) | Requests an Android communication route while an active `inCommunication` session owns the audio mode. |
+
+#### Returns `Promise`.
+
+Available on Android 12 (API 31) or later. The promise rejects if no communication session is active, the requested built-in device is unavailable, or Android rejects the request. Resolution means Android accepted the request; use `getCommunicationDevice()` and `routeChange` to observe the selected route. A runtime request applies only to the active session; the next activation uses `androidCommunicationDevice` from `setAudioSessionOptions()` again.
+
+### `getCommunicationDevice`
+
+#### Returns `Promise`.
+
+Returns the device Android currently selected for communication, including an accessory chosen by the system. It does not return the requested preference.
+
### `disableSessionManagement`
#### Returns `undefined`.
@@ -238,10 +258,24 @@ interface SessionOptions {
iosAllowHaptics?: boolean;
// Has no effect when using PlaybackNotificationManager as it takes over the "Now playing" controls
iosNotifyOthersOnDeactivation?: boolean;
+ androidMode?: 'inCommunication';
+ androidCommunicationDevice?: CommunicationDevice;
}
```
+### `CommunicationDevice`
+
+
+Type definitions
+```typescript
+type CommunicationDevice = 'speaker' | 'earpiece' | 'systemDefault';
+```
+
+
+:::info
+`androidInputPreset: 'voiceCommunication'` on `AudioRecorder` requests the Android capture path. `androidOutputProfile: 'voiceCommunication'` on `AudioContext` classifies Oboe playback as voice communication. `androidMode: 'inCommunication'` owns Android focus, mode, and communication-device routing. These APIs request platform voice-processing facilities; acoustic echo cancellation, noise suppression, and automatic gain control remain device- and route-dependent.
+:::
### `SystemEventName`
diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt
index 5411f005d..abed8cee1 100644
--- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt
+++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt
@@ -93,6 +93,7 @@ class AudioAPIModule(
override fun invalidate() {
reactContext.get()?.removeLifecycleEventListener(this)
+ MediaSessionManager.cleanup()
// Cleanup foreground service manager
ForegroundServiceManager.cleanup()
}
@@ -105,7 +106,13 @@ class AudioAPIModule(
enabled: Boolean,
promise: Promise?,
) {
- promise?.resolve(null)
+ MediaSessionManager.setAudioSessionActivity(enabled) { error ->
+ if (error == null) {
+ promise?.resolve(null)
+ } else {
+ promise?.reject("E_COMMUNICATION_SESSION", error)
+ }
+ }
}
override fun setAudioSessionOptions(
@@ -114,8 +121,10 @@ class AudioAPIModule(
options: ReadableArray?,
allowHaptics: Boolean,
notifyOthersOnDeactivation: Boolean,
+ androidMode: String?,
+ androidCommunicationDevice: String?,
) {
- // noting to do here
+ MediaSessionManager.setAudioSessionOptions(androidMode, androidCommunicationDevice)
}
override fun disableSessionManagement() {
@@ -126,17 +135,14 @@ class AudioAPIModule(
focusType: String?,
enabled: Boolean,
) {
- if (!enabled) {
- MediaSessionManager.abandonAudioFocus()
- return
- }
- when (focusType) {
- "gain" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN)
- "gainTransient" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
- "gainTransientMayDuck" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
- "gainTransientExclusive" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
- else -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN)
- }
+ val focus =
+ when (focusType) {
+ "gainTransient" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
+ "gainTransientMayDuck" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
+ "gainTransientExclusive" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
+ else -> AudioManager.AUDIOFOCUS_GAIN
+ }
+ MediaSessionManager.observeAudioInterruptions(focus, enabled)
}
override fun activelyReclaimSession(enabled: Boolean) {
@@ -181,6 +187,36 @@ class AudioAPIModule(
promise?.resolve(null)
}
+ override fun setCommunicationDevice(
+ device: String?,
+ promise: Promise?,
+ ) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ promise?.reject("E_UNSUPPORTED", "Communication-device routing requires Android 12 (API 31) or later")
+ return
+ }
+ if (device == null) {
+ promise?.reject("E_INVALID_COMMUNICATION_DEVICE", "A communication device is required")
+ return
+ }
+
+ MediaSessionManager.setCommunicationDevice(device) { error ->
+ if (error == null) {
+ promise?.resolve(null)
+ } else {
+ promise?.reject("E_COMMUNICATION_DEVICE", error)
+ }
+ }
+ }
+
+ override fun getCommunicationDevice(promise: Promise?) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ promise?.reject("E_UNSUPPORTED", "Communication-device routing requires Android 12 (API 31) or later")
+ return
+ }
+ MediaSessionManager.getCommunicationDevice { device -> promise?.resolve(device) }
+ }
+
// Notification system methods
@RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS)
override fun showNotification(
diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt
index b9c463666..a375169b3 100644
--- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt
+++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt
@@ -1,9 +1,12 @@
package com.swmansion.audioapi.system
+import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
+import android.os.Handler
import android.util.Log
+import androidx.annotation.RequiresApi
import com.swmansion.audioapi.AudioAPIModule
import java.lang.ref.WeakReference
import java.util.HashMap
@@ -11,11 +14,23 @@ import java.util.HashMap
class AudioFocusListener(
private val audioManager: WeakReference,
private val audioAPIModule: WeakReference,
+ private val mainHandler: Handler,
+ private val isCommunicationSessionActive: () -> Boolean,
) : AudioManager.OnAudioFocusChangeListener {
private var focusRequest: AudioFocusRequest? = null
+ private var hasLegacyFocusRequest = false
private var isTransientLoss: Boolean = false
+ private var communicationFocusRequest: AudioFocusRequest? = null
override fun onAudioFocusChange(focusChange: Int) {
+ val hasCommunicationFocus = communicationFocusRequest != null
+ if (focusRequest == null && !hasLegacyFocusRequest && !hasCommunicationFocus) {
+ return
+ }
+ if (hasCommunicationFocus && !isCommunicationSessionActive()) {
+ return
+ }
+
Log.d("AudioFocusListener", "onAudioFocusChange: $focusChange")
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
@@ -49,30 +64,80 @@ class AudioFocusListener(
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
+ isTransientLoss = communicationFocusRequest != null
audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody(AudioEvent.DUCK.ordinal, emptyMap())
}
}
}
fun requestAudioFocus(focus: Int) {
+ if (communicationFocusRequest != null) {
+ return
+ }
+ abandonAudioFocus()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ hasLegacyFocusRequest = false
this.focusRequest =
AudioFocusRequest
.Builder(focus)
- .setOnAudioFocusChangeListener(this)
+ .setOnAudioFocusChangeListener(this, mainHandler)
.build()
audioManager.get()?.requestAudioFocus(focusRequest!!)
} else {
- audioManager.get()?.requestAudioFocus(this, AudioManager.STREAM_MUSIC, focus)
+ val result = audioManager.get()?.requestAudioFocus(this, AudioManager.STREAM_MUSIC, focus)
+ hasLegacyFocusRequest = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
}
fun abandonAudioFocus() {
+ if (communicationFocusRequest != null) {
+ return
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this.focusRequest != null) {
audioManager.get()?.abandonAudioFocusRequest(focusRequest!!)
+ focusRequest = null
} else {
audioManager.get()?.abandonAudioFocus(this)
+ hasLegacyFocusRequest = false
+ }
+ isTransientLoss = false
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun requestCommunicationAudioFocus(): Boolean {
+ if (communicationFocusRequest != null) {
+ return true
}
+
+ abandonAudioFocus()
+
+ val request =
+ AudioFocusRequest
+ .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+ .setAudioAttributes(
+ AudioAttributes
+ .Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build(),
+ ).setAcceptsDelayedFocusGain(false)
+ .setWillPauseWhenDucked(true)
+ .setOnAudioFocusChangeListener(this, mainHandler)
+ .build()
+
+ val result = audioManager.get()?.requestAudioFocus(request)
+ if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+ communicationFocusRequest = request
+ return true
+ }
+ return false
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun abandonCommunicationAudioFocus() {
+ communicationFocusRequest?.let { audioManager.get()?.abandonAudioFocusRequest(it) }
+ communicationFocusRequest = null
+ isTransientLoss = false
}
}
diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt
new file mode 100644
index 000000000..ee4575da0
--- /dev/null
+++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt
@@ -0,0 +1,99 @@
+package com.swmansion.audioapi.system
+
+import android.media.AudioDeviceCallback
+import android.media.AudioDeviceInfo
+import android.media.AudioManager
+import android.os.Build
+import android.os.Handler
+import androidx.annotation.RequiresApi
+import java.util.concurrent.Executor
+
+@RequiresApi(Build.VERSION_CODES.S)
+fun registerCommunicationDeviceCallbacks(
+ audioManager: AudioManager,
+ mainHandler: Handler,
+ onRouteChange: (String) -> Unit,
+): Any = CommunicationDeviceCallbacks.register(audioManager, mainHandler, onRouteChange)
+
+@RequiresApi(Build.VERSION_CODES.S)
+fun unregisterCommunicationDeviceCallbacks(callbacks: Any) {
+ CommunicationDeviceCallbacks.unregister(callbacks)
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+private object CommunicationDeviceCallbacks {
+ fun register(
+ audioManager: AudioManager,
+ mainHandler: Handler,
+ onRouteChange: (String) -> Unit,
+ ): Any = Registration(audioManager, mainHandler, onRouteChange).also { it.register() }
+
+ fun unregister(callbacks: Any) {
+ (callbacks as Registration).unregister()
+ }
+
+ private class Registration(
+ private val audioManager: AudioManager,
+ private val mainHandler: Handler,
+ private val onRouteChange: (String) -> Unit,
+ ) {
+ private val deviceChangedListener =
+ AudioManager.OnCommunicationDeviceChangedListener {
+ onRouteChange("Override")
+ }
+ private val deviceCallback =
+ object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array) {
+ onRouteChange("NewDeviceAvailable")
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array) {
+ onRouteChange("OldDeviceUnavailable")
+ }
+ }
+ private val mainExecutor = Executor { command -> mainHandler.post(command) }
+ private var deviceChangedListenerRegistered = false
+ private var audioDeviceCallbackRegistered = false
+
+ fun register() {
+ try {
+ audioManager.addOnCommunicationDeviceChangedListener(mainExecutor, deviceChangedListener)
+ deviceChangedListenerRegistered = true
+ audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
+ audioDeviceCallbackRegistered = true
+ } catch (error: Exception) {
+ try {
+ unregister()
+ } catch (_: Exception) {
+ // The activation transaction still reports its original failure.
+ }
+ throw error
+ }
+ }
+
+ fun unregister() {
+ var failure: Exception? = null
+ if (deviceChangedListenerRegistered) {
+ deviceChangedListenerRegistered = false
+ try {
+ audioManager.removeOnCommunicationDeviceChangedListener(deviceChangedListener)
+ } catch (error: Exception) {
+ failure = error
+ }
+ }
+ if (audioDeviceCallbackRegistered) {
+ audioDeviceCallbackRegistered = false
+ try {
+ audioManager.unregisterAudioDeviceCallback(deviceCallback)
+ } catch (error: Exception) {
+ if (failure == null) {
+ failure = error
+ }
+ }
+ }
+ if (failure != null) {
+ throw failure
+ }
+ }
+ }
+}
diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt
index d29d8c360..84ace4ba3 100644
--- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt
+++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt
@@ -9,6 +9,9 @@ import android.content.pm.PackageManager
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.annotation.RequiresPermission
import androidx.core.app.ActivityCompat
@@ -37,6 +40,16 @@ object MediaSessionManager {
private lateinit var volumeChangeListener: VolumeChangeListener
private lateinit var playbackNotificationReceiver: PlaybackNotificationReceiver
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var androidMode: String? = null
+ private var configuredCommunicationDevice: String? = null
+ private var previousAudioMode: Int? = null
+ private var communicationSessionActive = false
+
+ // The API-31 helper is intentionally opaque here so API-30 startup does not
+ // need to resolve its Android-12-only callback types.
+ private var communicationDeviceCallbacks: Any? = null
+
// New notification system
private lateinit var notificationRegistry: NotificationRegistry
@@ -77,7 +90,9 @@ object MediaSessionManager {
}
this.audioFocusListener =
- AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule)
+ AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule, mainHandler) {
+ communicationSessionActive
+ }
this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule)
// Initialize new notification system
@@ -97,12 +112,179 @@ object MediaSessionManager {
return sampleRate.toDouble()
}
- fun requestAudioFocus(focus: Int) {
- audioFocusListener.requestAudioFocus(focus)
+ fun setAudioSessionOptions(
+ androidMode: String?,
+ androidCommunicationDevice: String?,
+ ) {
+ runOnMain {
+ this.androidMode = androidMode?.takeIf { it.isNotEmpty() }
+ this.configuredCommunicationDevice = androidCommunicationDevice?.takeIf { it.isNotEmpty() }
+ }
}
- fun abandonAudioFocus() {
- audioFocusListener.abandonAudioFocus()
+ fun setAudioSessionActivity(
+ enabled: Boolean,
+ onComplete: (String?) -> Unit,
+ ) {
+ runOnMain {
+ onComplete(setAudioSessionActivityOnMain(enabled))
+ }
+ }
+
+ fun observeAudioInterruptions(
+ focus: Int,
+ enabled: Boolean,
+ ) {
+ runOnMain {
+ if (communicationSessionActive) {
+ return@runOnMain
+ }
+ if (enabled) {
+ audioFocusListener.requestAudioFocus(focus)
+ } else {
+ audioFocusListener.abandonAudioFocus()
+ }
+ }
+ }
+
+ fun setCommunicationDevice(
+ device: String,
+ onComplete: (String?) -> Unit,
+ ) {
+ runOnMain {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ onComplete("Android communication devices require Android 12 (API 31) or later")
+ return@runOnMain
+ }
+ if (!communicationSessionActive) {
+ onComplete("An Android communication session is not active")
+ return@runOnMain
+ }
+ onComplete(requestCommunicationDevice(device))
+ }
+ }
+
+ fun getCommunicationDevice(onComplete: (ReadableMap?) -> Unit) {
+ runOnMain {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ onComplete(null)
+ return@runOnMain
+ }
+ onComplete(audioManager.communicationDevice?.let(::toReadableDeviceInfo))
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ private fun activateCommunicationSession(): String? {
+ if (communicationSessionActive) {
+ return null
+ }
+
+ previousAudioMode = audioManager.mode
+ if (!audioFocusListener.requestCommunicationAudioFocus()) {
+ previousAudioMode = null
+ return "Android denied voice-communication audio focus"
+ }
+
+ return try {
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
+ communicationDeviceCallbacks =
+ registerCommunicationDeviceCallbacks(audioManager, mainHandler, ::emitRouteChange)
+ configuredCommunicationDevice?.let { device ->
+ requestCommunicationDevice(device)?.let { error -> throw IllegalStateException(error) }
+ }
+ communicationSessionActive = true
+ null
+ } catch (error: Exception) {
+ deactivateCommunicationSession()
+ error.message ?: "Could not activate Android communication audio session"
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ private fun deactivateCommunicationSession() {
+ if (!communicationSessionActive && previousAudioMode == null) {
+ return
+ }
+
+ communicationSessionActive = false
+ val callbacks = communicationDeviceCallbacks
+ communicationDeviceCallbacks = null
+ if (callbacks != null) {
+ try {
+ unregisterCommunicationDeviceCallbacks(callbacks)
+ } catch (error: Exception) {
+ Log.w("MediaSessionManager", "Could not unregister communication-device callbacks", error)
+ }
+ }
+ audioManager.clearCommunicationDevice()
+ audioFocusListener.abandonCommunicationAudioFocus()
+ previousAudioMode?.let { audioManager.mode = it }
+ previousAudioMode = null
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ private fun requestCommunicationDevice(device: String): String? {
+ if (device == "systemDefault") {
+ audioManager.clearCommunicationDevice()
+ return null
+ }
+
+ val deviceType =
+ when (device) {
+ "speaker" -> AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
+ "earpiece" -> AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
+ else -> return "Unsupported communication device: $device"
+ }
+ val target =
+ audioManager.availableCommunicationDevices.firstOrNull { it.type == deviceType }
+ ?: return "Requested communication device is unavailable: $device"
+
+ if (!audioManager.setCommunicationDevice(target)) {
+ return "Android rejected requested communication device: $device"
+ }
+ return null
+ }
+
+ private fun setAudioSessionActivityOnMain(enabled: Boolean): String? {
+ if (!enabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ deactivateCommunicationSession()
+ }
+ return null
+ }
+ if (androidMode != "inCommunication") {
+ return null
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ return "Android communication sessions require Android 12 (API 31) or later"
+ }
+ return activateCommunicationSession()
+ }
+
+ fun cleanup() {
+ runOnMain {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ deactivateCommunicationSession()
+ }
+ }
+ }
+
+ private fun emitRouteChange(reason: String) {
+ if (communicationSessionActive) {
+ audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody(
+ AudioEvent.ROUTE_CHANGE.ordinal,
+ mapOf("reason" to reason),
+ )
+ }
+ }
+
+ private fun runOnMain(action: () -> Unit) {
+ if (Looper.myLooper() == mainHandler.looper) {
+ action()
+ } else {
+ mainHandler.post(action)
+ }
}
fun activelyReclaimSession(enabled: Boolean) {
@@ -244,6 +426,14 @@ object MediaSessionManager {
return devicesInfo
}
+ @RequiresApi(Build.VERSION_CODES.S)
+ private fun toReadableDeviceInfo(device: AudioDeviceInfo): ReadableMap =
+ Arguments.createMap().apply {
+ putString("id", device.id.toString())
+ putString("name", device.productName.toString())
+ putString("category", parseDeviceCategory(device))
+ }
+
@RequiresApi(Build.VERSION_CODES.O)
fun parseDeviceCategory(device: AudioDeviceInfo): String =
when (device.type) {
diff --git a/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java b/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java
index 5f2eb276e..7ed9dca7d 100644
--- a/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java
+++ b/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java
@@ -53,7 +53,7 @@ public NativeAudioAPIModuleSpec(ReactApplicationContext reactContext) {
@ReactMethod
@DoNotStrip
- public abstract void setAudioSessionOptions(String category, String mode, ReadableArray options, boolean allowHaptics, boolean notifyOthersOnDeactivation);
+ public abstract void setAudioSessionOptions(String category, String mode, ReadableArray options, boolean allowHaptics, boolean notifyOthersOnDeactivation, String androidMode, String androidCommunicationDevice);
@ReactMethod
@DoNotStrip
@@ -95,6 +95,14 @@ public NativeAudioAPIModuleSpec(ReactApplicationContext reactContext) {
@DoNotStrip
public abstract void setInputDevice(String deviceId, Promise promise);
+ @ReactMethod
+ @DoNotStrip
+ public abstract void setCommunicationDevice(String device, Promise promise);
+
+ @ReactMethod
+ @DoNotStrip
+ public abstract void getCommunicationDevice(Promise promise);
+
@ReactMethod
@DoNotStrip
public abstract void showNotification(String type, String key, ReadableMap options, Promise promise);
diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm
index d4c527d80..806f57e6a 100644
--- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm
+++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm
@@ -167,7 +167,8 @@ - (dispatch_queue_t)methodQueue
RCT_EXPORT_METHOD(
setAudioSessionOptions : (NSString *)category mode : (NSString *)mode options : (NSArray *)
options allowHaptics : (BOOL)allowHaptics notifyOthersOnDeactivation : (BOOL)
- notifyOthersOnDeactivation)
+ notifyOthersOnDeactivation androidMode : (NSString *)
+ androidMode androidCommunicationDevice : (NSString *)androidCommunicationDevice)
{
if (!self.audioSessionManager.shouldManageSession) {
[self.audioSessionManager setShouldManageSession:true];
@@ -250,6 +251,19 @@ - (dispatch_queue_t)methodQueue
});
}
+RCT_EXPORT_METHOD(
+ setCommunicationDevice : (NSString *)device resolve : (RCTPromiseResolveBlock)
+ resolve reject : (RCTPromiseRejectBlock)reject)
+{
+ reject(@"E_PLATFORM", @"Communication-device selection is only available on Android", nil);
+}
+
+RCT_EXPORT_METHOD(
+ getCommunicationDevice : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
+{
+ reject(@"E_PLATFORM", @"Communication-device selection is only available on Android", nil);
+}
+
RCT_EXPORT_METHOD(disableSessionManagement)
{
[self.audioSessionManager disableSessionManagement];
diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts
index d54330ca1..f165f6478 100644
--- a/packages/react-native-audio-api/src/mock/index.ts
+++ b/packages/react-native-audio-api/src/mock/index.ts
@@ -1082,6 +1082,14 @@ class AudioManagerMock {
static observeVolumeChanges(_observe: boolean): void {}
+ static setCommunicationDevice(_device: string): Promise {
+ return Promise.resolve();
+ }
+
+ static getCommunicationDevice(): Promise {
+ return Promise.resolve(null);
+ }
+
static addSystemEventListener(
_event: string,
_callback: (event: { value: number }) => void
diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts
index 2085f8dee..c16f281ee 100644
--- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts
+++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts
@@ -1,7 +1,11 @@
'use strict';
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
-import { AudioDevicesInfo, PermissionStatus } from '../system/types';
+import {
+ AudioDeviceInfo,
+ AudioDevicesInfo,
+ PermissionStatus,
+} from '../system/types';
type OptionsMap = { [key: string]: string | boolean | number | undefined };
type NotificationOpResponse = { success: boolean; error?: string };
@@ -24,7 +28,9 @@ interface Spec extends TurboModule {
mode: string,
options: Array,
allowHaptics: boolean,
- notifyOthersOnDeactivation: boolean
+ notifyOthersOnDeactivation: boolean,
+ androidMode: string,
+ androidCommunicationDevice: string
): void;
disableSessionManagement(): void;
@@ -43,6 +49,8 @@ interface Spec extends TurboModule {
// Audio devices
getDevicesInfo(): Promise;
setInputDevice(deviceId: string): Promise;
+ setCommunicationDevice(device: string): Promise;
+ getCommunicationDevice(): Promise;
// Notification system
showNotification(
diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts
index 6f2393201..0e33c4ef4 100644
--- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts
+++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts
@@ -1,5 +1,6 @@
import { TurboModule } from 'react-native';
import {
+ AudioDeviceInfo,
AudioDevicesInfo,
AudioFocusType,
PermissionStatus,
@@ -24,7 +25,9 @@ interface Spec extends TurboModule {
mode: string,
options: Array,
allowHaptics: boolean,
- notifyOthersOnDeactivation: boolean
+ notifyOthersOnDeactivation: boolean,
+ androidMode: string,
+ androidCommunicationDevice: string
): void;
disableSessionManagement(): void;
@@ -42,6 +45,8 @@ interface Spec extends TurboModule {
// Audio devices
getDevicesInfo(): Promise;
setInputDevice(deviceId: string): Promise;
+ setCommunicationDevice(device: string): Promise;
+ getCommunicationDevice(): Promise;
// New notification system
showNotification(
@@ -84,6 +89,10 @@ const NativeAudioAPIModule: Spec = {
currentOutputs: [],
}),
setInputDevice: mockAsync(undefined),
+ setCommunicationDevice: () =>
+ Promise.reject(new Error('setCommunicationDevice is not supported on web')),
+ getCommunicationDevice: () =>
+ Promise.reject(new Error('getCommunicationDevice is not supported on web')),
showNotification: mockAsync({ success: true }),
hideNotification: mockAsync({ success: true }),
isNotificationActive: mockAsync(false),
diff --git a/packages/react-native-audio-api/src/system/AudioManager.ts b/packages/react-native-audio-api/src/system/AudioManager.ts
index ace424da3..ace421676 100644
--- a/packages/react-native-audio-api/src/system/AudioManager.ts
+++ b/packages/react-native-audio-api/src/system/AudioManager.ts
@@ -5,6 +5,8 @@ import { parseNativeError } from './errors';
import {
AudioDevicesInfo,
AudioFocusType,
+ AudioDeviceInfo,
+ CommunicationDevice,
IAudioManager,
PermissionStatus,
SessionOptions,
@@ -43,7 +45,9 @@ class AudioManager implements IAudioManager {
options.iosMode ?? '',
options.iosOptions ?? [],
options.iosAllowHaptics ?? false,
- options.iosNotifyOthersOnDeactivation ?? true
+ options.iosNotifyOthersOnDeactivation ?? true,
+ options.androidMode ?? '',
+ options.androidCommunicationDevice ?? ''
);
}
@@ -129,6 +133,22 @@ class AudioManager implements IAudioManager {
async setInputDevice(deviceId: string): Promise {
await NativeAudioAPIModule.setInputDevice(deviceId);
}
+
+ /**
+ * Requests an Android communication device while an `inCommunication` session
+ * is active. `systemDefault` clears the explicit request.
+ */
+ async setCommunicationDevice(device: CommunicationDevice): Promise {
+ await NativeAudioAPIModule.setCommunicationDevice(device);
+ }
+
+ /**
+ * Returns Android's currently selected communication device, including a
+ * route selected by the system after an accessory change.
+ */
+ async getCommunicationDevice(): Promise {
+ return NativeAudioAPIModule.getCommunicationDevice();
+ }
}
export default new AudioManager();
diff --git a/packages/react-native-audio-api/src/system/types.ts b/packages/react-native-audio-api/src/system/types.ts
index d010ed95e..fac8f2e9e 100644
--- a/packages/react-native-audio-api/src/system/types.ts
+++ b/packages/react-native-audio-api/src/system/types.ts
@@ -40,6 +40,10 @@ export type AudioFocusType =
| 'gainTransientExclusive'
| 'gainTransientMayDuck';
+export type AndroidAudioMode = 'inCommunication';
+
+export type CommunicationDevice = 'speaker' | 'earpiece' | 'systemDefault';
+
export interface SessionOptions {
iosMode?: IOSMode;
iosOptions?: IOSOption[];
@@ -53,6 +57,16 @@ export interface SessionOptions {
* "Now playing" controls.
*/
iosNotifyOthersOnDeactivation?: boolean;
+ /**
+ * Opts into Android's communication audio mode. Android 12 (API 31) or later
+ * is required when this option is used.
+ */
+ androidMode?: AndroidAudioMode;
+ /**
+ * Initial Android communication-device preference. `systemDefault` clears an
+ * explicit preference and lets Android choose the route.
+ */
+ androidCommunicationDevice?: CommunicationDevice;
}
export type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted';
@@ -91,4 +105,6 @@ export interface IAudioManager {
checkNotificationPermissions(): Promise;
getDevicesInfo(): Promise;
setInputDevice(deviceId: string): Promise;
+ setCommunicationDevice(device: CommunicationDevice): Promise;
+ getCommunicationDevice(): Promise;
}
diff --git a/packages/react-native-audio-api/src/web-system/AudioManager.ts b/packages/react-native-audio-api/src/web-system/AudioManager.ts
index f6cbd6a4c..faf675c13 100644
--- a/packages/react-native-audio-api/src/web-system/AudioManager.ts
+++ b/packages/react-native-audio-api/src/web-system/AudioManager.ts
@@ -1,4 +1,8 @@
-import type { IAudioManager, PermissionStatus } from '../system/types';
+import type {
+ CommunicationDevice,
+ IAudioManager,
+ PermissionStatus,
+} from '../system/types';
const mockAsync =
(value: T) =>
@@ -24,6 +28,12 @@ class AudioManager implements IAudioManager {
requestNotificationPermissions = mockAsync('Granted' as PermissionStatus);
checkNotificationPermissions = mockAsync('Granted' as PermissionStatus);
setInputDevice = mockAsync(undefined);
+ setCommunicationDevice = (_device: CommunicationDevice) =>
+ Promise.reject(new Error('setCommunicationDevice is not supported on web'));
+
+ getCommunicationDevice = () =>
+ Promise.reject(new Error('getCommunicationDevice is not supported on web'));
+
getDevicesInfo = mockAsync({
availableInputs: [],
availableOutputs: [],
diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts
index 55f83c494..26245985c 100644
--- a/packages/react-native-audio-api/tests/mock.test.ts
+++ b/packages/react-native-audio-api/tests/mock.test.ts
@@ -204,7 +204,6 @@ describe('React Native Audio API Mocks', () => {
expect(queueSource.onbufferended).toBe(callback);
});
});
-
});
describe('AudioRecorder', () => {
@@ -362,6 +361,15 @@ describe('React Native Audio API Mocks', () => {
MockAPI.AudioManager.removeSystemEventListener(listener)
).not.toThrow();
});
+
+ it('should provide communication-device methods', async () => {
+ await expect(
+ MockAPI.AudioManager.setCommunicationDevice('speaker')
+ ).resolves.toBeUndefined();
+ await expect(
+ MockAPI.AudioManager.getCommunicationDevice()
+ ).resolves.toBeNull();
+ });
});
describe('Notification Managers', () => {