1diff --git a/android/src/main/java/expo/modules/audio/AudioModule.kt b/android/src/main/java/expo/modules/audio/AudioModule.kt
2index 6c81fce3dd518d3b82e3280940a001c7be84dd0a..16125c679c870e2eae48f8db953dedd3fc842c3d 100644
3--- a/android/src/main/java/expo/modules/audio/AudioModule.kt
4+++ b/android/src/main/java/expo/modules/audio/AudioModule.kt
5@@ -677,7 +677,7 @@ class AudioModule : Module() {
6 }
7 }
9- Function("pause") { player: AudioPlayer ->
10+ Function("pause") { player: AudioPlayer, /* deactivateSession - iOS only */ _: Boolean? ->
11 runOnMain {
12 player.isPaused = false
13 player.ref.pause()
14@@ -824,10 +824,14 @@ class AudioModule : Module() {
15 return@Function recorder.getAvailableInputs(audioManager)
16 }
18- Function("setInput") { recorder: AudioRecorder, input: String ->
19+ AsyncFunction("setInput") Coroutine { recorder: AudioRecorder, input: String ->
20 recorder.setInput(input, audioManager)
21 }
23+ Function("cancelInputSelection") { recorder: AudioRecorder ->
24+ recorder.cancelInputSelection()
25+ }
26+
27 Function("startRecordingAtTime") { recorder: AudioRecorder, seconds: Double ->
28 checkRecordingPermission()
29 if (recorder.isPrepared) {
30diff --git a/android/src/main/java/expo/modules/audio/AudioRecorder.kt b/android/src/main/java/expo/modules/audio/AudioRecorder.kt
31index 2ff4a474fdea3efa652436a8a3960c3669ad298b..1ced279991294d3d81cb20fa339ca73e5d6d6c01 100644
32--- a/android/src/main/java/expo/modules/audio/AudioRecorder.kt
33+++ b/android/src/main/java/expo/modules/audio/AudioRecorder.kt
34@@ -19,6 +19,9 @@ import expo.modules.kotlin.exception.CodedException
35 import expo.modules.kotlin.exception.Exceptions
36 import expo.modules.kotlin.sharedobjects.SharedObject
37 import kotlinx.coroutines.Job
38+import kotlinx.coroutines.CancellationException
39+import kotlinx.coroutines.currentCoroutineContext
40+import kotlinx.coroutines.job
41 import kotlinx.coroutines.delay
42 import kotlinx.coroutines.launch
43 import java.io.File
44@@ -42,12 +45,19 @@ class AudioRecorder(
45 var isPrepared = false
47 private var recorder: MediaRecorder? = null
48+ private val inputRoute = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
49+ RecordingInputRoute(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
50+ } else {
51+ null
52+ }
53 val id = UUID.randomUUID().toString()
54 var startTime = 0L
55 var isRecording = false
56 var isPaused = false
57 private var isPausedBySystem = false
58 private var recordingTimerJob: Job? = null
59+ private var inputSelectionJob: Job? = null
60+ private var inputSelectionCanceled = false
61 private var durationLimitMillis: Long? = null
62 var useForegroundService = false
64@@ -90,6 +100,7 @@ class AudioRecorder(
65 throw AudioRecorderAlreadyPreparedException()
66 }
68+ synchronized(this) { inputSelectionCanceled = false }
69 val recordingOptions = options ?: this.options
70 val mediaRecorder = createRecorder(recordingOptions)
71 recorder = mediaRecorder
72@@ -245,10 +256,15 @@ class AudioRecorder(
73 }
75 private fun reset() {
76+ cancelInputSelection()
77 recordingTimerJob?.cancel()
78 recordingTimerJob = null
80- recorder?.release()
81+ try {
82+ recorder?.release()
83+ } finally {
84+ inputRoute?.close()
85+ }
86 recorder = null
87 isRecording = false
88 isPaused = false
89@@ -468,55 +484,68 @@ class AudioRecorder(
90 throw DeviceInfoNotFoundException()
91 }
93- return getMapFromDeviceInfo(deviceInfo)
94+ return getMapFromDeviceInfo(deviceInfo, recordingInputs(audioManager))
95 }
97 private fun hasRecordingPermissions() =
98 ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
100- fun getAvailableInputs(audioManager: AudioManager) =
101- audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).mapNotNull { deviceInfo ->
102- val type = deviceInfo.type
103- if (type == AudioDeviceInfo.TYPE_BUILTIN_MIC || type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
104- getMapFromDeviceInfo(deviceInfo)
105- } else {
106- null
107- }
108+ fun getAvailableInputs(audioManager: AudioManager): List<Bundle> {
109+ val inputs = recordingInputs(audioManager)
110+ return inputs.map { getMapFromDeviceInfo(it, inputs) }
111+ }
112+
113+ suspend fun setInput(uid: String, audioManager: AudioManager) {
114+ val job = currentCoroutineContext().job
115+ synchronized(this) {
116+ if (inputSelectionCanceled) throw CancellationException("Input selection canceled")
117+ inputSelectionJob = job
118+ }
119+ try {
120+ setInputDevice(uid, audioManager)
121+ } finally {
122+ synchronized(this) { if (inputSelectionJob === job) inputSelectionJob = null }
123 }
124+ }
125+
126+ fun cancelInputSelection() {
127+ val job = synchronized(this) {
128+ inputSelectionCanceled = true
129+ inputSelectionJob
130+ }
131+ job?.cancel()
132+ }
134 @Suppress("DEPRECATION")
135- fun setInput(uid: String, audioManager: AudioManager) {
136- val deviceInfo: AudioDeviceInfo? = getDeviceInfoFromUid(uid, audioManager)
137+ private suspend fun setInputDevice(uid: String, audioManager: AudioManager) {
138+ val deviceInfo = getDeviceInfoFromUid(uid, audioManager) ?: throw PreferredInputNotFoundException()
140 if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
141 throw SetAudioInputNotSupportedException()
142 }
144- if (deviceInfo != null && deviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
145- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
146- audioManager.setCommunicationDevice(deviceInfo)
147- } else {
148+ try {
149+ if (inputRoute != null) {
150+ inputRoute.select(deviceInfo)
151+ } else if (deviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
152 audioManager.startBluetoothSco()
153- }
154- } else {
155- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
156- audioManager.clearCommunicationDevice()
157 } else {
158 audioManager.stopBluetoothSco()
159 }
160- }
161-
162- val success = recorder?.setPreferredDevice(deviceInfo)
163- if (success == false) {
164- throw PreferredInputNotFoundException()
165+ val source = getDeviceInfoFromUid(uid, audioManager)?.takeIf { it.isSource }
166+ if (recorder?.setPreferredDevice(source) != true) {
167+ throw PreferredInputNotFoundException()
168+ }
169+ } catch (error: Exception) {
170+ inputRoute?.close()
171+ throw error
172 }
173 }
175 private fun getDeviceInfoFromUid(uid: String, audioManager: AudioManager): AudioDeviceInfo? {
176- val id = uid.toInt()
177- val audioDevices: Array<AudioDeviceInfo> = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
178+ val audioDevices = recordingInputs(audioManager)
179 for (device in audioDevices) {
180- if (device.id == id) {
181+ if (recordingInputUid(device, audioDevices) == uid) {
182 return device
183 }
184 }
185diff --git a/android/src/main/java/expo/modules/audio/AudioUtils.kt b/android/src/main/java/expo/modules/audio/AudioUtils.kt
186index 81819c1d771bdeec093bb35a65a5e1e1af0cf9d7..b52d0cd0622c233b45115b90057c1765337e4297 100644
187--- a/android/src/main/java/expo/modules/audio/AudioUtils.kt
188+++ b/android/src/main/java/expo/modules/audio/AudioUtils.kt
189@@ -2,6 +2,8 @@ package expo.modules.audio
191 import android.content.Context
192 import android.media.AudioDeviceInfo
193+import android.media.AudioManager
194+import android.os.Build
195 import android.os.Bundle
196 import androidx.annotation.OptIn
197 import androidx.media3.common.Player
198@@ -17,22 +19,57 @@ fun ensureDirExists(dir: File): File {
199 return dir
200 }
202-fun getMapFromDeviceInfo(deviceInfo: AudioDeviceInfo): Bundle {
203+fun getMapFromDeviceInfo(deviceInfo: AudioDeviceInfo, inputs: List<AudioDeviceInfo>): Bundle {
204 val map = Bundle()
205- val type = when (deviceInfo.type) {
206- AudioDeviceInfo.TYPE_BUILTIN_MIC -> "MicrophoneBuiltIn"
207- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothSCO"
208- AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2DP"
209- AudioDeviceInfo.TYPE_TELEPHONY -> "Telephony"
210- AudioDeviceInfo.TYPE_WIRED_HEADSET -> "MicrophoneWired"
211- else -> "Unknown device type"
212- }
213+ val type = recordingInputType(deviceInfo.type) ?: "Unknown device type"
214 map.putString("name", deviceInfo.productName.toString())
215 map.putString("type", type)
216- map.putString("uid", deviceInfo.id.toString())
217+ map.putString("uid", recordingInputUid(deviceInfo, inputs))
218 return map
219 }
221+fun recordingInputs(manager: AudioManager): List<AudioDeviceInfo> {
222+ val inputs = manager.getDevices(AudioManager.GET_DEVICES_INPUTS).filter {
223+ recordingInputType(it.type) != null
224+ }
225+ val bluetooth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
226+ manager.availableCommunicationDevices.filter { isBluetoothInput(it.type) }
227+ } else {
228+ emptyList()
229+ }
230+ // Some Bluetooth microphone ports appear only after the communication route is activated.
231+ return (inputs + bluetooth).distinctBy {
232+ if (isBluetoothInput(it.type)) recordingInputUid(it) else "port:${it.id}"
233+ }
234+}
235+
236+internal fun isBluetoothInput(type: Int) = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
237+ type == AudioDeviceInfo.TYPE_BLE_HEADSET
238+
239+internal fun recordingInputType(type: Int): String? = when (type) {
240+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> "MicrophoneBuiltIn"
241+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothSCO"
242+ AudioDeviceInfo.TYPE_BLE_HEADSET -> "BluetoothLE"
243+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "MicrophoneWired"
244+ AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET -> "USBAudio"
245+ AudioDeviceInfo.TYPE_LINE_ANALOG, AudioDeviceInfo.TYPE_LINE_DIGITAL -> "LineIn"
246+ else -> null
247+}
248+
249+fun recordingInputUid(device: AudioDeviceInfo, inputs: List<AudioDeviceInfo> = emptyList()): String {
250+ val base = recordingInputBaseUid(device)
251+ val ambiguous = !isBluetoothInput(device.type) && inputs.any {
252+ it.id != device.id && recordingInputBaseUid(it) == base
253+ }
254+ return if (ambiguous) "$base:port:${device.id}" else base
255+}
256+
257+private fun recordingInputBaseUid(device: AudioDeviceInfo): String {
258+ // Port IDs and USB addresses (ALSA card numbers) change on reconnect; Bluetooth addresses don't.
259+ val stable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isBluetoothInput(device.type)
260+ return "android:${device.type}:${if (stable) device.address.ifBlank { device.productName } else device.productName}"
261+}
262+
263 @OptIn(UnstableApi::class)
264 fun buildBasicMediaSession(context: Context, player: Player, onPlay: () -> Unit): MediaSession {
265 return MediaSession.Builder(context, PlayRequestingPlayer(player, onPlay))
266diff --git a/android/src/main/java/expo/modules/audio/RecordingInputRoute.kt b/android/src/main/java/expo/modules/audio/RecordingInputRoute.kt
267new file mode 100644
268index 0000000000000000000000000000000000000000..372ab7554df73f043b9cb832a6b1d769d2ed69bf
269--- /dev/null
270+++ b/android/src/main/java/expo/modules/audio/RecordingInputRoute.kt
271@@ -0,0 +1,53 @@
272+package expo.modules.audio
273+
274+import android.media.AudioDeviceInfo
275+import android.media.AudioManager
276+import androidx.annotation.RequiresApi
277+import java.io.Closeable
278+import java.util.concurrent.Executor
279+import kotlinx.coroutines.CompletableDeferred
280+import kotlinx.coroutines.withTimeout
281+
282+@RequiresApi(31)
283+class RecordingInputRoute(private val manager: AudioManager) : Closeable {
284+ private var previousMode: Int? = null
285+
286+ suspend fun select(input: AudioDeviceInfo) {
287+ if (!isBluetoothInput(input.type)) {
288+ close()
289+ return
290+ }
291+ // Communication routing takes the headset's output; Android pairs its microphone.
292+ val output = manager.availableCommunicationDevices.firstOrNull {
293+ it.isSink && it.type == input.type &&
294+ if (input.address.isNotBlank()) it.address == input.address else it.productName == input.productName
295+ } ?: throw PreferredInputNotFoundException()
296+ val ready = CompletableDeferred<Unit>()
297+ val listener = AudioManager.OnCommunicationDeviceChangedListener {
298+ if (it?.id == output.id) ready.complete(Unit)
299+ }
300+ manager.addOnCommunicationDeviceChangedListener(Executor { it.run() }, listener)
301+ try {
302+ if (previousMode == null) previousMode = manager.mode
303+ manager.mode = AudioManager.MODE_IN_COMMUNICATION
304+ if (!manager.setCommunicationDevice(output)) throw PreferredInputNotFoundException()
305+ if (manager.communicationDevice?.id == output.id) ready.complete(Unit)
306+ withTimeout(5_000) { ready.await() }
307+ } catch (error: Exception) {
308+ close()
309+ throw error
310+ } finally {
311+ manager.removeOnCommunicationDeviceChangedListener(listener)
312+ }
313+ }
314+
315+ override fun close() {
316+ val mode = previousMode ?: return
317+ previousMode = null
318+ try {
319+ manager.clearCommunicationDevice()
320+ } finally {
321+ if (manager.mode == AudioManager.MODE_IN_COMMUNICATION) manager.mode = mode
322+ }
323+ }
324+}
325diff --git a/android/src/test/java/expo/modules/audio/RecordingInputIdentityTest.kt b/android/src/test/java/expo/modules/audio/RecordingInputIdentityTest.kt
326new file mode 100644
327index 0000000000000000000000000000000000000000..4dd0567e2170770e9b0f6a17dc4581bc769d5c6a
328--- /dev/null
329+++ b/android/src/test/java/expo/modules/audio/RecordingInputIdentityTest.kt
330@@ -0,0 +1,35 @@
331+package expo.modules.audio
332+
333+import android.media.AudioDeviceInfo
334+import android.media.AudioManager
335+import io.mockk.every
336+import io.mockk.mockk
337+import org.junit.Assert.*
338+import org.junit.Test
339+
340+class RecordingInputIdentityTest {
341+ private fun device(id: Int) = mockk<AudioDeviceInfo> {
342+ every { this@mockk.id } returns id
343+ every { type } returns AudioDeviceInfo.TYPE_USB_DEVICE
344+ every { productName } returns "USB Microphone"
345+ every { address } returns "card=$id;device=0;"
346+ }
347+
348+ @Test
349+ fun identicalUsbMicrophonesRemainSelectable() {
350+ val first = device(1)
351+ val second = device(2)
352+ val manager = mockk<AudioManager> {
353+ every { getDevices(AudioManager.GET_DEVICES_INPUTS) } returns arrayOf(first, second)
354+ }
355+ val inputs = recordingInputs(manager)
356+ assertEquals(2, inputs.size)
357+ val firstUid = recordingInputUid(first, inputs)
358+ val secondUid = recordingInputUid(second, inputs)
359+ assertNotEquals(firstUid, secondUid)
360+ assertSame(first, inputs.single { recordingInputUid(it, inputs) == firstUid })
361+ assertSame(second, inputs.single { recordingInputUid(it, inputs) == secondUid })
362+ val reconnected = device(99)
363+ assertEquals(recordingInputUid(first, listOf(first)), recordingInputUid(reconnected, listOf(reconnected)))
364+ }
365+}
366diff --git a/build/AudioModule.types.d.ts b/build/AudioModule.types.d.ts
367index 0d63c9b949cfae7a101902fc3bb70fcf46094f68..a312b8fcdaf728220db3d6c04d92fd78a4aa101c 100644
368--- a/build/AudioModule.types.d.ts
369+++ b/build/AudioModule.types.d.ts
370@@ -8,6 +8,8 @@ import type { AudioStream } from './AudioStream.types';
371 */
372 export declare class NativeAudioModule extends NativeModule {
373 setIsAudioActiveAsync(active: boolean): Promise<void>;
374+ /** iOS only: protect the shared session from delayed expo-audio deactivation. */
375+ setAudioSessionOwner?(owned: boolean): void;
376 setAudioModeAsync(category: Partial<AudioMode>): Promise<void>;
377 requestRecordingPermissionsAsync(): Promise<PermissionResponse>;
378 requestNotificationPermissionsAsync(): Promise<PermissionResponse>;
379@@ -135,8 +137,9 @@ export declare class AudioPlayer extends SharedObject<AudioEvents> {
380 play(): void;
381 /**
382 * Pauses the player.
383+ * @param deactivateSession On iOS, pass false when handing the shared session to another player.
384 */
385- pause(): void;
386+ pause(deactivateSession?: boolean): void;
387 /**
388 * Replaces the current audio source with a new one.
389 */
390diff --git a/ios/AudioModule.swift b/ios/AudioModule.swift
391index 42ce9e8f213ebc243db25ef836456757b0daeb09..b422c3c10349b14803d5e9d7afeed617e76e09af 100644
392--- a/ios/AudioModule.swift
393+++ b/ios/AudioModule.swift
394@@ -6,6 +6,7 @@ public class AudioModule: Module {
396 private let sessionQueue = DispatchQueue(label: "expo.modules.audio.session", qos: .userInitiated)
397 private var sessionIsActive = false
398+ private var hasAudioSessionOwner = false
399 private var audioSessionActivityKeepers = Set<String>()
400 private let audioEnabled = OSAllocatedUnfairLock(initialState: true)
402@@ -41,6 +42,16 @@ public class AudioModule: Module {
403 try setIsAudioActive(isActive)
404 }
406+ // Video shares AVAudioSession but is absent from this module's registry.
407+ Function("setAudioSessionOwner") { (owned: Bool) in
408+ self.sessionQueue.sync {
409+ self.hasAudioSessionOwner = owned
410+ if !owned {
411+ self.deactivateSession()
412+ }
413+ }
414+ }
415+
416 AsyncFunction("requestRecordingPermissionsAsync") { (promise: Promise) in
417 #if os(iOS)
418 appContext?.permissions?.askForPermission(
419@@ -259,9 +270,9 @@ public class AudioModule: Module {
420 }
421 }
423- Function("pause") { player in
424+ Function("pause") { (player: AudioPlayer, deactivateSession: Bool?) in
425 player.ref.pause()
426- if !player.keepAudioSessionActive {
427+ if deactivateSession != false && !player.keepAudioSessionActive {
428 self.deactivateSession()
429 }
430 }
431@@ -957,6 +968,7 @@ public class AudioModule: Module {
432 sessionQueue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
433 guard let self,
434 self.sessionIsActive,
435+ !self.hasAudioSessionOwner,
436 self.audioSessionActivityKeepers.isEmpty,
437 !self.isSessionInUse else {
438 return
439diff --git a/ios/AudioRecorder.swift b/ios/AudioRecorder.swift
440index 198fe6dc95b564ff3916657f14ea67fc44d2e424..5b396114bbeb5e1c99da54f4e6d2376ad978dfa0 100644
441--- a/ios/AudioRecorder.swift
442+++ b/ios/AudioRecorder.swift
443@@ -285,16 +285,16 @@ class AudioRecorder: SharedRef<AVAudioRecorder>, RecordingResultHandler {
444 }
446 func didFinish(_ recorder: AVAudioRecorder, successfully flag: Bool) {
447- // Update internal state when recording finishes automatically (e.g., from recordForDuration)
448- currentState = .stopped
449+ // Update internal state when AVAudioRecorder finishes or fails.
450+ currentState = flag ? .stopped : .error
451 resetDurationTracking()
452 durationLimitSeconds = nil
454 emit(event: recordingStatus, payload: [
455 "id": id,
456 "isFinished": true,
457- "hasError": false,
458- "error": nil,
459+ "hasError": !flag,
460+ "error": flag ? nil : "Recording failed",
461 "url": recorder.url.absoluteString
462 ])
463 }
464diff --git a/src/AudioModule.types.ts b/src/AudioModule.types.ts
465index 3194a8ebd1b91793d03a1692c563b5d2e83c2738..d8822a6c2ad8a581275e2ca428f4b5d0ed961be2 100644
466--- a/src/AudioModule.types.ts
467+++ b/src/AudioModule.types.ts
468@@ -24,6 +24,8 @@ import type { AudioStream } from './AudioStream.types';
469 */
470 export declare class NativeAudioModule extends NativeModule {
471 setIsAudioActiveAsync(active: boolean): Promise<void>;
472+ /** iOS only: protect the shared session from delayed expo-audio deactivation. */
473+ setAudioSessionOwner?(owned: boolean): void;
474 setAudioModeAsync(category: Partial<AudioMode>): Promise<void>;
475 requestRecordingPermissionsAsync(): Promise<PermissionResponse>;
476 requestNotificationPermissionsAsync(): Promise<PermissionResponse>;
477@@ -175,8 +177,9 @@ export declare class AudioPlayer extends SharedObject<AudioEvents> {
479 /**
480 * Pauses the player.
481+ * @param deactivateSession On iOS, pass false when handing the shared session to another player.
482 */
483- pause(): void;
484+ pause(deactivateSession?: boolean): void;
486 /**
487 * Replaces the current audio source with a new one.