diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8fdbc4a8b..9c7b2fbdb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,9 +3,12 @@ android:versionCode="1" android:versionName="1.0"> - + + + + diff --git a/android/app/src/main/java/org/jitsi/meet/MainApplication.java b/android/app/src/main/java/org/jitsi/meet/MainApplication.java index f8d79c9df..437e0743a 100644 --- a/android/app/src/main/java/org/jitsi/meet/MainApplication.java +++ b/android/app/src/main/java/org/jitsi/meet/MainApplication.java @@ -29,7 +29,8 @@ public class MainApplication extends Application implements ReactApplication { new com.corbt.keepawake.KCKeepAwakePackage(), new com.facebook.react.shell.MainReactPackage(), new com.oblador.vectoricons.VectorIconsPackage(), - new com.oney.WebRTCModule.WebRTCModulePackage() + new com.oney.WebRTCModule.WebRTCModulePackage(), + new org.jitsi.meet.audiomode.AudioModePackage() ); } }; diff --git a/android/app/src/main/java/org/jitsi/meet/audiomode/AudioModeModule.java b/android/app/src/main/java/org/jitsi/meet/audiomode/AudioModeModule.java new file mode 100644 index 000000000..4c62371f1 --- /dev/null +++ b/android/app/src/main/java/org/jitsi/meet/audiomode/AudioModeModule.java @@ -0,0 +1,305 @@ +package org.jitsi.meet.audiomode; + +import android.annotation.TargetApi; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +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 com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +/** + * Module implementing a simple API to select the appropriate audio device for a conference call. + * + * Audio calls should use AudioModeModule.AUDIO_CALL, which uses the builtin earpiece, + * wired headset or bluetooth headset. The builtin earpiece is the default audio device. + * + * Video calls should should use AudioModeModule.VIDEO_CALL, which uses the builtin + * speaker, earpiece, wired headset or bluetooth headset. The builtin speaker is the default + * audio device. + * + * Before a call has started and after it has ended the AudioModeModule.DEFAULT mode + * should be used. + */ +public class AudioModeModule extends ReactContextBaseJavaModule { + /** + * Constants representing the audio mode. + * - DEFAULT: Used before and after every call. It represents the default audio routing scheme. + * - AUDIO_CALL: Used for audio only calls. It will use the earpiece by default, unless a + * wired or Bluetooth headset is connected. + * - VIDEO_CALL: Used for video calls. It will use the speaker by default, unless a wired or + * Bluetooth headset is connected. + */ + private static final int DEFAULT = 0; + private static final int AUDIO_CALL = 1; + private static final int VIDEO_CALL = 2; + + /** + * React Native module name. + */ + private static final String MODULE_NAME = "AudioMode"; + + /** + * Tag used when logging messages. + */ + static final String TAG = MODULE_NAME; + + /** + * Audio mode currently in use. + */ + private int mode = -1; + + /** + * {@link AudioManager} instance used to interact with the Android audio subsystem. + */ + private final AudioManager audioManager; + + /** + * {@link Handler} for running all operations on the main thread. + */ + private final Handler mainThreadHandler; + + /** + * {@link Runnable} for running update operation on the main thread. + */ + private final Runnable mainThreadRunner; + + /** + * {@link BluetoothHeadsetMonitor} for detecting Bluetooth device changes in old (< M) + * Android versions. + */ + private BluetoothHeadsetMonitor bluetoothHeadsetMonitor; + + /** + * + */ + private static final String ACTION_HEADSET_PLUG = (android.os.Build.VERSION.SDK_INT >= 21) ? + AudioManager.ACTION_HEADSET_PLUG : Intent.ACTION_HEADSET_PLUG; + + /** + * Initializes a new module instance. There shall be a single instance of this module throughout + * the lifetime of the application. + * + * @param reactContext the {@link ReactApplicationContext} where this module is created. + */ + public AudioModeModule(ReactApplicationContext reactContext) { + super(reactContext); + + audioManager = ((AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE)); + mainThreadHandler = new Handler(Looper.getMainLooper()); + mainThreadRunner = new Runnable() { + @Override + public void run() { + if (mode != -1) { + updateAudioRoute(mode); + } + } + }; + + // Setup runtime device change detection + setupAudioRouteChangeDetection(); + } + + /** + * Gets the name for this module, to be used in the React Native bridge. + * + * @return a string with the module name. + */ + @Override + public String getName() { + return MODULE_NAME; + } + + /** + * Gets a mapping with the constants this module is exporting. + * + * @return a {@link Map} mapping the constants to be exported with their values. + */ + @Override + public Map getConstants() { + Map constants = new HashMap<>(); + constants.put("DEFAULT", DEFAULT); + constants.put("AUDIO_CALL", AUDIO_CALL); + constants.put("VIDEO_CALL", VIDEO_CALL); + return constants; + } + + /** + * Updates the audio route for the given mode. + * + * @param mode the audio mode to be used when computing the audio route. + * @return true if the audio route was updated successfully, false otherwise. + */ + private boolean updateAudioRoute(int mode) { + Log.d(TAG, "Update audio route for mode: " + mode); + + if (mode == DEFAULT) { + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.abandonAudioFocus(null); + audioManager.setSpeakerphoneOn(false); + audioManager.setMicrophoneMute(true); + setBluetoothAudioRoute(false); + + return true; + } + + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.setMicrophoneMute(false); + + if (audioManager.requestAudioFocus( + null, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN) + == AudioManager.AUDIOFOCUS_REQUEST_FAILED) { + Log.d(TAG, "Audio focus request failed"); + return false; + } + + boolean useSpeaker = (mode == VIDEO_CALL); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // On Android >= M we use the AudioDeviceCallback API, so turn on Bluetooth SCO + // from the start. + if (audioManager.isBluetoothScoAvailableOffCall()) { + audioManager.startBluetoothSco(); + } + } else { + // On older Android versions we must set the Bluetooth route manually. Also disable + // the speaker in that case. + setBluetoothAudioRoute(bluetoothHeadsetMonitor.isHeadsetAvailable()); + if (bluetoothHeadsetMonitor.isHeadsetAvailable()) { + useSpeaker = false; + } + } + + // XXX: isWiredHeadsetOn is not deprecated when used just for knowing if there is a wired + // headset connected, regardless of audio being routed to it. + audioManager.setSpeakerphoneOn(useSpeaker && + !(audioManager.isWiredHeadsetOn() || audioManager.isBluetoothScoOn())); + + return true; + } + + /** + * Public method to set the current audio mode. + * + * @param mode the desired audio mode. + * @param promise a {@link Promise} which will be resolved if the audio mode could be updated + * successfully, and it will be rejected otherwise. + */ + @ReactMethod + public void setMode(final int mode, final Promise promise) { + if (mode != DEFAULT && mode != AUDIO_CALL && mode != VIDEO_CALL) { + promise.reject("setMode", "Invalid audio mode " + mode); + return; + } + + Runnable r = new Runnable() { + @Override + public void run() { + if (updateAudioRoute(mode)) { + AudioModeModule.this.mode = mode; + promise.resolve(null); + } else { + promise.reject("setMode", "Failed to set the requested audio mode"); + } + } + }; + mainThreadHandler.post(r); + } + + /** + * Setup the audio route change detection mechanism. We use the + * {@link android.media.AudioDeviceCallback} API on Android >= 23 only. + */ + private void setupAudioRouteChangeDetection() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setupAudioRouteChangeDetectionM(); + } else { + setupAudioRouteChangeDetectionOld(); + } + } + + /** + * Audio route change detection mechanism for Android API >= 23. + */ + @TargetApi(Build.VERSION_CODES.M) + private void setupAudioRouteChangeDetectionM() { + android.media.AudioDeviceCallback audioDeviceCallback = + new android.media.AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { + Log.d(TAG, "Audio devices added"); + onAudioDeviceChange(); + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { + Log.d(TAG, "Audio devices removed"); + onAudioDeviceChange(); + } + }; + + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null); + } + + /** + * Audio route change detection mechanism for Android API < 23. + */ + private void setupAudioRouteChangeDetectionOld() { + ReactContext reactContext = getReactApplicationContext(); + + // Detect changes in wired headset connections + IntentFilter wiredHeadSetFilter = new IntentFilter(ACTION_HEADSET_PLUG); + BroadcastReceiver wiredHeadsetReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "Wired headset added / removed"); + onAudioDeviceChange(); + } + }; + reactContext.registerReceiver(wiredHeadsetReceiver, wiredHeadSetFilter); + + // Detect Bluetooth device changes + bluetoothHeadsetMonitor = + new BluetoothHeadsetMonitor(this, this.getReactApplicationContext()); + bluetoothHeadsetMonitor.start(); + } + + /** + * Helper method to set the output route to a Bluetooth device. + * @param enabled true if Bluetooth should use used, false otherwise. + */ + private void setBluetoothAudioRoute(boolean enabled) { + if (enabled) { + audioManager.startBluetoothSco(); + audioManager.setBluetoothScoOn(true); + } else { + audioManager.setBluetoothScoOn(false); + audioManager.stopBluetoothSco(); + } + } + + /** + * Helper method to trigger an audio route update when devices change. It makes sure the + * operation is performed on the main thread. + */ + void onAudioDeviceChange() { + mainThreadHandler.post(mainThreadRunner); + } +} diff --git a/android/app/src/main/java/org/jitsi/meet/audiomode/AudioModePackage.java b/android/app/src/main/java/org/jitsi/meet/audiomode/AudioModePackage.java new file mode 100644 index 000000000..19dc261a6 --- /dev/null +++ b/android/app/src/main/java/org/jitsi/meet/audiomode/AudioModePackage.java @@ -0,0 +1,41 @@ +package org.jitsi.meet.audiomode; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class AudioModePackage implements ReactPackage { + /** + * {@inheritDoc} + * @return List of native modules to be exposed by React Native. + */ + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new AudioModeModule(reactContext)); + return modules; + } + + /** + * {@inheritDoc} + */ + @Override + public List> createJSModules() { + return Collections.emptyList(); + } + + /** + * {@inheritDoc} + */ + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/android/app/src/main/java/org/jitsi/meet/audiomode/BluetoothHeadsetMonitor.java b/android/app/src/main/java/org/jitsi/meet/audiomode/BluetoothHeadsetMonitor.java new file mode 100644 index 000000000..bf184a8fa --- /dev/null +++ b/android/app/src/main/java/org/jitsi/meet/audiomode/BluetoothHeadsetMonitor.java @@ -0,0 +1,192 @@ +package org.jitsi.meet.audiomode; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothHeadset; +import android.bluetooth.BluetoothProfile; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.facebook.react.bridge.ReactContext; + +import java.util.List; + +/** + * Helper class to detect and handle Bluetooth device changes. It monitors Bluetooth headsets + * being connected / disconnected and notifies the module about device changes when this occurs. + */ +public class BluetoothHeadsetMonitor { + /** + * {@link AudioModeModule} where this monitor reports. + */ + private final AudioModeModule AudioModeModule; + + /** + * {@link AudioManager} instance used to interact with the Android audio subsystem. + */ + private final AudioManager audioManager; + + /** + * {@link ReactContext} instance where the main module runs. + */ + private final ReactContext reactContext; + + /** + * Reference to the Bluetooth adapter, needed for managing BluetoothProfile.HEADSET + * devices. + */ + private BluetoothAdapter bluetoothAdapter; + + /** + * Reference to a proxy object which allows us to query connected devices. + */ + private BluetoothHeadset bluetoothHeadset; + + /** + * Listener for Bluetooth service profiles, allows us to get the proxy object to + * {@link BluetoothHeadset}. + */ + private BluetoothProfile.ServiceListener bluetoothProfileListener; + + /** + * {@link Handler} for running all operations on the main thread. + */ + private final Handler mainThreadHandler; + + /** + * Helper for running Bluetooth operations on the main thread. + */ + private Runnable bluetoothRunnable; + + /** + * Flag indicating if there are any Bluetooth headset devices currently available. + */ + private boolean headsetAvailable = false; + + public BluetoothHeadsetMonitor(AudioModeModule audioModeModule, ReactContext reactContext) { + this.AudioModeModule = audioModeModule; + this.reactContext = reactContext; + + audioManager = ((AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE)); + bluetoothAdapter = null; + bluetoothHeadset = null; + bluetoothProfileListener = null; + mainThreadHandler = new Handler(Looper.getMainLooper()); + } + + /** + * Start monitoring Bluetooth device activity. + */ + public void start() { + bluetoothRunnable = new Runnable() { + @Override + public void run() { + if (bluetoothHeadset == null) { + headsetAvailable = false; + } else { + List devices = bluetoothHeadset.getConnectedDevices(); + headsetAvailable = !devices.isEmpty(); + } + BluetoothHeadsetMonitor.this.AudioModeModule.onAudioDeviceChange(); + } + }; + + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter == null) { + Log.w(AudioModeModule.TAG, "Device doesn't support Bluetooth"); + return; + } + + if (!audioManager.isBluetoothScoAvailableOffCall()) { + Log.w(AudioModeModule.TAG, "Bluetooth SCO is not available"); + return; + } + + // XXX: The profile listener listens for system services of the given type being available + // to the application. That is, if our Bluetooth adapter has the "headset" profile. + bluetoothProfileListener = new BluetoothProfile.ServiceListener() { + @Override + public void onServiceConnected(int profile, BluetoothProfile proxy) { + if (profile == BluetoothProfile.HEADSET) { + bluetoothHeadset = (BluetoothHeadset) proxy; + updateDevices(); + } + } + + @Override + public void onServiceDisconnected(int profile) { + if (profile == BluetoothProfile.HEADSET) { + bluetoothHeadset = null; + updateDevices(); + } + } + }; + + bluetoothAdapter.getProfileProxy(reactContext, + bluetoothProfileListener, BluetoothProfile.HEADSET); + + IntentFilter bluetoothFilter = new IntentFilter(); + bluetoothFilter.addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED); + bluetoothFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED); + BroadcastReceiver bluetoothReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) { + // XXX: This action will be fired when a Bluetooth headset is connected or + // disconnected to the system. This is not related to audio routing. + final int state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, -99); + switch (state) { + case BluetoothHeadset.STATE_CONNECTED: + case BluetoothHeadset.STATE_DISCONNECTED: + Log.d(AudioModeModule.TAG, "BT headset connection state changed: " + state); + updateDevices(); + break; + default: + break; + } + } else if (action.equals(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)) { + // XXX: This action will be fired when the connection established with a + // Bluetooth headset (called a SCO connection) changes state. When the SCO + // connection is active we route audio to it. + final int state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -99); + switch (state) { + case AudioManager.SCO_AUDIO_STATE_CONNECTED: + case AudioManager.SCO_AUDIO_STATE_DISCONNECTED: + Log.d(AudioModeModule.TAG, "BT SCO connection state changed: " + state); + updateDevices(); + break; + default: + break; + } + } + } + }; + reactContext.registerReceiver(bluetoothReceiver, bluetoothFilter); + + // Initial detection + updateDevices(); + } + + /** + * Detect if there are new devices connected / disconnected and fires the + * onAudioDeviceChange callback. + */ + private void updateDevices() { + mainThreadHandler.post(bluetoothRunnable); + } + + /** + * Returns the current headset availability. + * @return true if there is a Bluetooth headset connected, false otherwise. + */ + public boolean isHeadsetAvailable() { + return headsetAvailable; + } +} \ No newline at end of file