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', () => {