React-Native WebRTC 앱에서 Android 오디오 Route 제어: 네이티브 모듈 개발기
시작하며
개발 중인 서비스에서는 WebRTC 기반의 영상 통화 기능을 제공하고 있으며, 통화 중 다음과 같은 기능이 필요했습니다.
오디오 디바이스(Bluetooth, 유선, 스피커) 간 자동 전환 및 수동 제어
화면 꺼짐 방지
초기에는 react-native-incall-manager 라이브러리를 사용하여 이 기능을 구현했습니다. 그러나 이 라이브러리는 Android 12(API 31) 이상부터 android.permission.BLUETOOTH_CONNECT
권한 없이 Bluetooth 기기의 연결 상태를 확인하거나 제어할 수 없습니다.
문제는 이 권한 요청 모달이 아래와 같이 블루투스 관련이라는 표시가 없어 사용자에게 거부당할 가능성이 높다는 것입니다. 사용자 거부 시 Bluetooth 기기 연결 인식이 안됩니다.

이로 인해 Bluetooth 기기가 연결되었음에도 인식되지 않거나, 오디오 라우팅이 동작하지 않는 문제가 발생했습니다.
이에 따라, BluetoothAdapter
를 직접 사용하지 않고, 대신 AudioManager
를 활용한 오디오 라우팅 방식으로 전환하였습니다.
AudioManager
를 통해 system-level에서 인식된 블루투스 오디오 기기에 대해서는 android.permission.BLUETOOTH_CONNECT
권한 없이도 라우팅이 가능합니다.
네이티브 모듈 작성 – AudioDeviceModule
React Native 앱에서 Android의 오디오 디바이스 상태를 감지하고, Bluetooth/Wired/Speaker 디바이스로 오디오 라우팅을 제어하기 위해 커스텀 네이티브 모듈인 AudioDeviceModule을 구현했습니다.
AudioDeviceCallback으로 디바이스 감지
Android에서는 오디오 디바이스의 연결/해제 이벤트를 감지하기 위해 AudioDeviceCallback
을 등록할 수 있습니다.
@TargetApi(Build.VERSION_CODES.M)
private final AudioDeviceCallback audioDeviceCallback = new AudioDeviceCallback() {
@Override
public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
Log.d("AudioDeviceModule", "onAudioDevicesAdded: " + addedDevices.length);
AudioDeviceInfo[] allDevices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
applyPreferredAudioRouteAndSelect(allDevices);
}
@Override
public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
Log.d("AudioDeviceModule", "onAudioDevicesRemoved: " + removedDevices.length);
AudioDeviceInfo[] allDevices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
applyPreferredAudioRouteAndSelect(allDevices);
}
};
addedDevices / removedDevices에는 이벤트 시점에 추가/제거된 디바이스 목록이 들어옵니다.
하지만 오디오 라우팅 우선순위(bluetooth > wired > speaker)를 일관되게 유지하기 위해, 이벤트 발생 시마다 전체 디바이스 목록을 조회하여 라우팅을 결정하도록 구성했습니다.
오디오 라우팅 로직: 우선순위 기반 설정
라우팅 우선순위는 다음과 같습니다
Bluetooth SCO
Bluetooth A2DP
Wired (유선 헤드셋/USB)
Built-in Speaker
private AudioDeviceInfo applyPreferredAudioRouteAndSelect(AudioDeviceInfo[] devices) {
Log.d("AudioRouting", "Applying preferred audio route...");
AudioManager audioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE);
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
AudioDeviceInfo bluetoothSco = null;
AudioDeviceInfo bluetoothA2dp = null;
AudioDeviceInfo wired = null;
AudioDeviceInfo speaker = null;
for (int i = devices.length - 1; i >= 0; i--) {
AudioDeviceInfo device = devices[i];
if (!device.isSink()) {
continue;
}
Log.d("AudioRouting", "Detected output device: " +
getDeviceTypeString(device.getType()) + " / ID: " + device.getId() +
" / Name: " + device.getProductName());
int type = device.getType();
if (type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && bluetoothSco == null) {
bluetoothSco = device;
} else if (type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP && bluetoothA2dp == null) {
bluetoothA2dp = device;
} else if ((type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
type == AudioDeviceInfo.TYPE_USB_HEADSET ||
type == AudioDeviceInfo.TYPE_USB_DEVICE) && wired == null) {
wired = device;
} else if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER && speaker == null) {
speaker = device;
}
}
try {
if (bluetoothSco != null) {
stopBluetoothIfOn(audioManager);
audioManager.setSpeakerphoneOn(false);
audioManager.startBluetoothSco();
audioManager.setBluetoothScoOn(true);
Log.d("AudioRouting", "Bluetooth SCO route applied: " + bluetoothSco.getProductName());
return bluetoothSco;
}
if (bluetoothA2dp != null) {
stopBluetoothIfOn(audioManager);
audioManager.setSpeakerphoneOn(false);
Log.d("AudioRouting", "Bluetooth A2DP route applied: " + bluetoothA2dp.getProductName());
return bluetoothA2dp;
}
if (wired != null) {
audioManager.setSpeakerphoneOn(false);
stopBluetoothIfOn(audioManager);
Log.d("AudioRouting", "Wired route applied: " + wired.getProductName());
return wired;
}
if (speaker != null) {
stopBluetoothIfOn(audioManager);
audioManager.setSpeakerphoneOn(true);
Log.d("AudioRouting", "Speaker route applied: " + speaker.getProductName());
return speaker;
}
} catch (Exception e) {
Log.e("AudioRouting", "Error while applying preferred route", e);
}
Log.w("AudioRouting", "No suitable output device found");
return null;
}
private void stopBluetoothIfOn(AudioManager audioManager) {
if (audioManager.isBluetoothScoOn()) {
audioManager.stopBluetoothSco();
audioManager.setBluetoothScoOn(false);
}
}
마무리
처음엔 Android OS가 오디오 라우팅을 자동으로 잘 처리해줄 줄 알았는데, WebRTC 기반의 통화 앱에서는 그렇지 않더라고요. 특히 Bluetooth 기기가 연결되었다고 해서 바로 통화용 SCO 오디오가 활성화되는 것도 아니었습니다.
그래서 react-native-incall-manager를 사용했지만, Android 12(API 31) 이상부터는 android.permission.BLUETOOTH_CONNECT
권한이 없으면 Bluetooth 기기를 인식하거나 라우팅하는 데 제약이 생겼습니다.
결국 BluetoothAdapter
대신 AudioManager
를 활용한 방식으로 전환했고, 별도 권한 요청 없이도 Bluetooth 오디오 기기에 자동 라우팅이 가능해졌습니다. 이제 AndroidManifest.xml
에서 android.permission.BLUETOOTH_CONNECT
를 제거해도 Bluetooth로 정상 라우팅됩니다.