React-Native WebRTC 앱에서 Android 오디오 Route 제어: 네이티브 모듈 개발기

WebRTCreact-nativeandroidbluetoothJava
avatar
2025.04.12
·
6 min read

시작하며

개발 중인 서비스에서는 WebRTC 기반의 영상 통화 기능을 제공하고 있으며, 통화 중 다음과 같은 기능이 필요했습니다.

  • 오디오 디바이스(Bluetooth, 유선, 스피커) 간 자동 전환 및 수동 제어

  • 화면 꺼짐 방지

초기에는 react-native-incall-manager 라이브러리를 사용하여 이 기능을 구현했습니다. 그러나 이 라이브러리는 Android 12(API 31) 이상부터 android.permission.BLUETOOTH_CONNECT 권한 없이 Bluetooth 기기의 연결 상태를 확인하거나 제어할 수 없습니다.

문제는 이 권한 요청 모달이 아래와 같이 블루투스 관련이라는 표시가 없어 사용자에게 거부당할 가능성이 높다는 것입니다. 사용자 거부 시 Bluetooth 기기 연결 인식이 안됩니다.

5029

이로 인해 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)를 일관되게 유지하기 위해, 이벤트 발생 시마다 전체 디바이스 목록을 조회하여 라우팅을 결정하도록 구성했습니다.

오디오 라우팅 로직: 우선순위 기반 설정

라우팅 우선순위는 다음과 같습니다

  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로 정상 라우팅됩니다.