
개발 중인 서비스에서는 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 권한 없이도 라우팅이 가능합니다.
React Native 앱에서 Android의 오디오 디바이스 상태를 감지하고, Bluetooth/Wired/Speaker 디바이스로 오디오 라우팅을 제어하기 위해 커스텀 네이티브 모듈인 AudioDeviceModule을 구현했습니다.
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);
}
};
라우팅 우선순위는 다음과 같습니다:
1. Bluetooth SCO
2. Bluetooth A2DP
3. Wired (유선 헤드셋/USB)
4. 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로 정상 라우팅됩니다.
오 멋지십니다!!!