Adds basic Bluetooth support to AppRTCMobile
BUG=webrtc:6649
- Supports Bluetooth Headset profile.
- Detects new BT headset:
+ enabled at start, and
+ powered on during active call.
- Enables/disables BT SCO channel when BT device is selected.
- Removes proximity sensor usage to avoid conflicts (will be added again later).
- Adds new (unused) APIs to explicitly select audio device.
- Starts routing audio to BT headset when enabled, i.e, BT is default.
Review-Url: https://codereview.webrtc.org/2501983002
Cr-Original-Commit-Position: refs/heads/master@{#15610}
Cr-Mirrored-From: https://chromium.googlesource.com/external/webrtc
Cr-Mirrored-Commit: c3c2f318521270ac42e3721d707259a29288e98c
diff --git a/BUILD.gn b/BUILD.gn
index 4f086f5..064b1b4 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -704,6 +704,7 @@
"androidjunit/src/org/webrtc/CameraEnumerationTest.java",
"examples/androidjunit/src/org/appspot/apprtc/DirectRTCClientTest.java",
"examples/androidjunit/src/org/appspot/apprtc/TCPChannelClientTest.java",
+ "examples/androidjunit/src/org/appspot/apprtc/BluetoothManagerTest.java",
]
deps = [
diff --git a/examples/BUILD.gn b/examples/BUILD.gn
index 8c60134..e6b73f0 100644
--- a/examples/BUILD.gn
+++ b/examples/BUILD.gn
@@ -65,6 +65,7 @@
android_library("AppRTCMobile_javalib") {
java_files = [
"androidapp/src/org/appspot/apprtc/AppRTCAudioManager.java",
+ "androidapp/src/org/appspot/apprtc/AppRTCBluetoothManager.java",
"androidapp/src/org/appspot/apprtc/AppRTCClient.java",
"androidapp/src/org/appspot/apprtc/AppRTCProximitySensor.java",
"androidapp/src/org/appspot/apprtc/CallActivity.java",
diff --git a/examples/androidapp/AndroidManifest.xml b/examples/androidapp/AndroidManifest.xml
index 48e2e8f..fc9624e 100644
--- a/examples/androidapp/AndroidManifest.xml
+++ b/examples/androidapp/AndroidManifest.xml
@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
diff --git a/examples/androidapp/src/org/appspot/apprtc/AppRTCAudioManager.java b/examples/androidapp/src/org/appspot/apprtc/AppRTCAudioManager.java
index 6e66b08..a57e84f 100644
--- a/examples/androidapp/src/org/appspot/apprtc/AppRTCAudioManager.java
+++ b/examples/androidapp/src/org/appspot/apprtc/AppRTCAudioManager.java
@@ -22,8 +22,11 @@
import android.preference.PreferenceManager;
import android.util.Log;
+import org.webrtc.ThreadUtils;
+
import java.util.Collections;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
/**
@@ -39,22 +42,48 @@
* AudioDevice is the names of possible audio devices that we currently
* support.
*/
- // TODO(henrika): add support for BLUETOOTH as well.
- public enum AudioDevice {
- SPEAKER_PHONE,
- WIRED_HEADSET,
- EARPIECE,
+ public enum AudioDevice { SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE }
+
+ /** AudioManager state. */
+ public enum AudioManagerState {
+ UNINITIALIZED,
+ PREINITIALIZED,
+ RUNNING,
+ }
+
+ /** Selected audio device change event. */
+ public static interface AudioManagerEvents {
+ // Callback fired once audio device is changed or list of available audio devices changed.
+ void onAudioDeviceChanged(
+ AudioDevice selectedAudioDevice, Set<AudioDevice> availableAudioDevices);
}
private final Context apprtcContext;
- private final Runnable onStateChangeListener;
- private boolean initialized = false;
private AudioManager audioManager;
+
+ private AudioManagerEvents audioManagerEvents;
+ private AudioManagerState amState;
private int savedAudioMode = AudioManager.MODE_INVALID;
private boolean savedIsSpeakerPhoneOn = false;
private boolean savedIsMicrophoneMute = false;
+ private boolean hasWiredHeadset = false;
- private final AudioDevice defaultAudioDevice;
+ // Default audio device; speaker phone for video calls or earpiece for audio
+ // only calls.
+ private AudioDevice defaultAudioDevice;
+
+ // Contains the currently selected audio device.
+ // This device is changed automatically using a certain scheme where e.g.
+ // a wired headset "wins" over speaker phone. It is also possible for a
+ // user to explicitly select a device (and overrid any predefined scheme).
+ // See |userSelectedAudioDevice| for details.
+ private AudioDevice selectedAudioDevice;
+
+ // Contains the user-selected audio device which overrides the predefined
+ // selection scheme.
+ // TODO(henrika): always set to AudioDevice.NONE today. Add support for
+ // explicit selection based on choice by userSelectedAudioDevice.
+ private AudioDevice userSelectedAudioDevice;
// Contains speakerphone setting: auto, true or false
private final String useSpeakerphone;
@@ -65,12 +94,12 @@
// available, far from ear <=> use speaker phone).
private AppRTCProximitySensor proximitySensor = null;
- // Contains the currently selected audio device.
- private AudioDevice selectedAudioDevice;
+ // Handles all tasks related to Bluetooth headset devices.
+ private final AppRTCBluetoothManager bluetoothManager;
// Contains a list of available audio devices. A Set collection is used to
// avoid duplicate elements.
- private final Set<AudioDevice> audioDevices = new HashSet<AudioDevice>();
+ private Set<AudioDevice> audioDevices = new HashSet<AudioDevice>();
// Broadcast receiver for wired headset intent broadcasts.
private BroadcastReceiver wiredHeadsetReceiver;
@@ -78,8 +107,10 @@
// Callback method for changes in audio focus.
private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
- // This method is called when the proximity sensor reports a state change,
- // e.g. from "NEAR to FAR" or from "FAR to NEAR".
+ /**
+ * This method is called when the proximity sensor reports a state change,
+ * e.g. from "NEAR to FAR" or from "FAR to NEAR".
+ */
private void onProximitySensorChangedState() {
if (!useSpeakerphone.equals(SPEAKERPHONE_AUTO)) {
return;
@@ -92,29 +123,55 @@
if (proximitySensor.sensorReportsNearState()) {
// Sensor reports that a "handset is being held up to a person's ear",
// or "something is covering the light sensor".
- setAudioDevice(AppRTCAudioManager.AudioDevice.EARPIECE);
+ setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.EARPIECE);
} else {
// Sensor reports that a "handset is removed from a person's ear", or
// "the light sensor is no longer covered".
- setAudioDevice(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
+ setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
}
}
}
- /** Construction */
- static AppRTCAudioManager create(Context context, Runnable deviceStateChangeListener) {
- return new AppRTCAudioManager(context, deviceStateChangeListener);
+ /* Receiver which handles changes in wired headset availability. */
+ private class WiredHeadsetReceiver extends BroadcastReceiver {
+ private static final int STATE_UNPLUGGED = 0;
+ private static final int STATE_PLUGGED = 1;
+ private static final int HAS_NO_MIC = 0;
+ private static final int HAS_MIC = 1;
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ int state = intent.getIntExtra("state", STATE_UNPLUGGED);
+ int microphone = intent.getIntExtra("microphone", HAS_NO_MIC);
+ String name = intent.getStringExtra("name");
+ Log.d(TAG, "WiredHeadsetReceiver.onReceive" + AppRTCUtils.getThreadInfo() + ": "
+ + "a=" + intent.getAction() + ", s="
+ + (state == STATE_UNPLUGGED ? "unplugged" : "plugged") + ", m="
+ + (microphone == HAS_MIC ? "mic" : "no mic") + ", n=" + name + ", sb="
+ + isInitialStickyBroadcast());
+ hasWiredHeadset = (state == STATE_PLUGGED);
+ updateAudioDeviceState();
+ }
+ };
+
+ /** Construction. */
+ static AppRTCAudioManager create(Context context) {
+ return new AppRTCAudioManager(context);
}
- private AppRTCAudioManager(Context context, Runnable deviceStateChangeListener) {
+ private AppRTCAudioManager(Context context) {
+ Log.d(TAG, "ctor");
+ ThreadUtils.checkIsOnMainThread();
apprtcContext = context;
- onStateChangeListener = deviceStateChangeListener;
audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
+ bluetoothManager = AppRTCBluetoothManager.create(context, this);
+ wiredHeadsetReceiver = new WiredHeadsetReceiver();
+ amState = AudioManagerState.UNINITIALIZED;
SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
useSpeakerphone = sharedPreferences.getString(context.getString(R.string.pref_speakerphone_key),
context.getString(R.string.pref_speakerphone_default));
-
+ Log.d(TAG, "useSpeakerphone: " + useSpeakerphone);
if (useSpeakerphone.equals(SPEAKERPHONE_FALSE)) {
defaultAudioDevice = AudioDevice.EARPIECE;
} else {
@@ -132,19 +189,29 @@
onProximitySensorChangedState();
}
});
+
+ Log.d(TAG, "defaultAudioDevice: " + defaultAudioDevice);
AppRTCUtils.logDeviceInfo(TAG);
}
- public void init() {
- Log.d(TAG, "init");
- if (initialized) {
+ public void start(AudioManagerEvents audioManagerEvents) {
+ Log.d(TAG, "start");
+ ThreadUtils.checkIsOnMainThread();
+ if (amState == AudioManagerState.RUNNING) {
+ Log.e(TAG, "AudioManager is already active");
return;
}
+ // TODO(henrika): perhaps call new method called preInitAudio() here if UNINITIALIZED.
- // Store current audio state so we can restore it when close() is called.
+ Log.d(TAG, "AudioManager starts...");
+ this.audioManagerEvents = audioManagerEvents;
+ amState = AudioManagerState.RUNNING;
+
+ // Store current audio state so we can restore it when stop() is called.
savedAudioMode = audioManager.getMode();
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn();
savedIsMicrophoneMute = audioManager.isMicrophoneMute();
+ hasWiredHeadset = hasWiredHeadset();
// Create an AudioManager.OnAudioFocusChangeListener instance.
audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
@@ -199,31 +266,43 @@
// Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
// required to be in this mode when playout and/or recording starts for
// best possible VoIP performance.
- // TODO(henrika): we migh want to start with RINGTONE mode here instead.
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
// Always disable microphone mute during a WebRTC call.
setMicrophoneMute(false);
+ // Set initial device states.
+ userSelectedAudioDevice = AudioDevice.NONE;
+ selectedAudioDevice = AudioDevice.NONE;
+ audioDevices.clear();
+
+ // Initialize and start Bluetooth if a BT device is available or initiate
+ // detection of new (enabled) BT devices.
+ bluetoothManager.start();
+
// Do initial selection of audio device. This setting can later be changed
- // either by adding/removing a wired headset or by covering/uncovering the
- // proximity sensor.
- updateAudioDeviceState(hasWiredHeadset());
+ // either by adding/removing a BT or wired headset or by covering/uncovering
+ // the proximity sensor.
+ updateAudioDeviceState();
// Register receiver for broadcast intents related to adding/removing a
- // wired headset (Intent.ACTION_HEADSET_PLUG).
- registerForWiredHeadsetIntentBroadcast();
-
- initialized = true;
+ // wired headset.
+ registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
+ Log.d(TAG, "AudioManager started");
}
- public void close() {
- Log.d(TAG, "close");
- if (!initialized) {
+ public void stop() {
+ Log.d(TAG, "stop");
+ ThreadUtils.checkIsOnMainThread();
+ if (amState != AudioManagerState.RUNNING) {
+ Log.e(TAG, "Trying to stop AudioManager in incorrect state: " + amState);
return;
}
+ amState = AudioManagerState.UNINITIALIZED;
- unregisterForWiredHeadsetIntentBroadcast();
+ unregisterReceiver(wiredHeadsetReceiver);
+
+ bluetoothManager.stop();
// Restore previously stored audio states.
setSpeakerphoneOn(savedIsSpeakerPhoneOn);
@@ -240,94 +319,90 @@
proximitySensor = null;
}
- initialized = false;
+ audioManagerEvents = null;
+ Log.d(TAG, "AudioManager stopped");
}
/** Changes selection of the currently active audio device. */
- public void setAudioDevice(AudioDevice device) {
- Log.d(TAG, "setAudioDevice(device=" + device + ")");
+ private void setAudioDeviceInternal(AudioDevice device) {
+ Log.d(TAG, "setAudioDeviceInternal(device=" + device + ")");
AppRTCUtils.assertIsTrue(audioDevices.contains(device));
switch (device) {
case SPEAKER_PHONE:
setSpeakerphoneOn(true);
- selectedAudioDevice = AudioDevice.SPEAKER_PHONE;
break;
case EARPIECE:
setSpeakerphoneOn(false);
- selectedAudioDevice = AudioDevice.EARPIECE;
break;
case WIRED_HEADSET:
setSpeakerphoneOn(false);
- selectedAudioDevice = AudioDevice.WIRED_HEADSET;
+ break;
+ case BLUETOOTH:
+ setSpeakerphoneOn(false);
break;
default:
Log.e(TAG, "Invalid audio device selection");
break;
}
- onAudioManagerChangedState();
+ selectedAudioDevice = device;
+ }
+
+ /**
+ * Changes default audio device.
+ * TODO(henrika): add usage of this method in the AppRTCMobile client.
+ */
+ public void setDefaultAudioDevice(AudioDevice defaultDevice) {
+ ThreadUtils.checkIsOnMainThread();
+ switch (defaultDevice) {
+ case SPEAKER_PHONE:
+ defaultAudioDevice = defaultDevice;
+ break;
+ case EARPIECE:
+ if (hasEarpiece()) {
+ defaultAudioDevice = defaultDevice;
+ } else {
+ defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+ }
+ break;
+ default:
+ Log.e(TAG, "Invalid default audio device selection");
+ break;
+ }
+ Log.d(TAG, "setDefaultAudioDevice(device=" + defaultAudioDevice + ")");
+ updateAudioDeviceState();
+ }
+
+ /** Changes selection of the currently active audio device. */
+ public void selectAudioDevice(AudioDevice device) {
+ ThreadUtils.checkIsOnMainThread();
+ if (!audioDevices.contains(device)) {
+ Log.e(TAG, "Can not select " + device + " from available " + audioDevices);
+ }
+ userSelectedAudioDevice = device;
+ updateAudioDeviceState();
}
/** Returns current set of available/selectable audio devices. */
public Set<AudioDevice> getAudioDevices() {
+ ThreadUtils.checkIsOnMainThread();
return Collections.unmodifiableSet(new HashSet<AudioDevice>(audioDevices));
}
/** Returns the currently selected audio device. */
public AudioDevice getSelectedAudioDevice() {
+ ThreadUtils.checkIsOnMainThread();
return selectedAudioDevice;
}
- /**
- * Registers receiver for the broadcasted intent when a wired headset is
- * plugged in or unplugged. The received intent will have an extra
- * 'state' value where 0 means unplugged, and 1 means plugged.
- */
- private void registerForWiredHeadsetIntentBroadcast() {
- IntentFilter filter = new IntentFilter(Intent.ACTION_HEADSET_PLUG);
-
- /** Receiver which handles changes in wired headset availability. */
- wiredHeadsetReceiver = new BroadcastReceiver() {
- private static final int STATE_UNPLUGGED = 0;
- private static final int STATE_PLUGGED = 1;
- private static final int HAS_NO_MIC = 0;
- private static final int HAS_MIC = 1;
-
- @Override
- public void onReceive(Context context, Intent intent) {
- int state = intent.getIntExtra("state", STATE_UNPLUGGED);
- int microphone = intent.getIntExtra("microphone", HAS_NO_MIC);
- String name = intent.getStringExtra("name");
- Log.d(TAG, "BroadcastReceiver.onReceive" + AppRTCUtils.getThreadInfo() + ": "
- + "a=" + intent.getAction() + ", s="
- + (state == STATE_UNPLUGGED ? "unplugged" : "plugged") + ", m="
- + (microphone == HAS_MIC ? "mic" : "no mic") + ", n=" + name + ", sb="
- + isInitialStickyBroadcast());
-
- boolean hasWiredHeadset = (state == STATE_PLUGGED);
- switch (state) {
- case STATE_UNPLUGGED:
- updateAudioDeviceState(hasWiredHeadset);
- break;
- case STATE_PLUGGED:
- if (selectedAudioDevice != AudioDevice.WIRED_HEADSET) {
- updateAudioDeviceState(hasWiredHeadset);
- }
- break;
- default:
- Log.e(TAG, "Invalid state");
- break;
- }
- }
- };
-
- apprtcContext.registerReceiver(wiredHeadsetReceiver, filter);
+ /** Helper method for receiver registration. */
+ private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+ apprtcContext.registerReceiver(receiver, filter);
}
- /** Unregister receiver for broadcasted ACTION_HEADSET_PLUG intent. */
- private void unregisterForWiredHeadsetIntentBroadcast() {
- apprtcContext.unregisterReceiver(wiredHeadsetReceiver);
- wiredHeadsetReceiver = null;
+ /** Helper method for unregistration of an existing receiver. */
+ private void unregisterReceiver(BroadcastReceiver receiver) {
+ apprtcContext.unregisterReceiver(receiver);
}
/** Sets the speaker phone mode. */
@@ -365,55 +440,139 @@
return audioManager.isWiredHeadsetOn();
}
- /** Update list of possible audio devices and make new device selection. */
- private void updateAudioDeviceState(boolean hasWiredHeadset) {
- // Update the list of available audio devices.
- audioDevices.clear();
+ /**
+ * Updates list of possible audio devices and make new device selection.
+ * TODO(henrika): add unit test to verify all state transitions.
+ */
+ public void updateAudioDeviceState() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "--- updateAudioDeviceState: "
+ + "wired headset=" + hasWiredHeadset + ", "
+ + "BT state=" + bluetoothManager.getState());
+ Log.d(TAG, "Device status: "
+ + "available=" + audioDevices + ", "
+ + "selected=" + selectedAudioDevice + ", "
+ + "user selected=" + userSelectedAudioDevice);
+
+ // Check if any Bluetooth headset is connected. The internal BT state will
+ // change accordingly.
+ // TODO(henrika): perhaps wrap required state into BT manager.
+ if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_DISCONNECTING) {
+ bluetoothManager.updateDevice();
+ }
+
+ // Update the set of available audio devices.
+ Set<AudioDevice> newAudioDevices = new HashSet<>();
+
+ if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE) {
+ newAudioDevices.add(AudioDevice.BLUETOOTH);
+ }
+
if (hasWiredHeadset) {
// If a wired headset is connected, then it is the only possible option.
- audioDevices.add(AudioDevice.WIRED_HEADSET);
+ newAudioDevices.add(AudioDevice.WIRED_HEADSET);
} else {
// No wired headset, hence the audio-device list can contain speaker
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
- audioDevices.add(AudioDevice.SPEAKER_PHONE);
+ newAudioDevices.add(AudioDevice.SPEAKER_PHONE);
if (hasEarpiece()) {
- audioDevices.add(AudioDevice.EARPIECE);
+ newAudioDevices.add(AudioDevice.EARPIECE);
}
}
- Log.d(TAG, "audioDevices: " + audioDevices);
+ // Store state which is set to true if the device list has changed.
+ boolean audioDeviceSetUpdated = !audioDevices.equals(newAudioDevices);
+ // Update the existing audio device set.
+ audioDevices = newAudioDevices;
+ // Correct user selected audio devices if needed.
+ if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
+ && userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
+ // If BT is not available, it can't be the user selection.
+ userSelectedAudioDevice = AudioDevice.NONE;
+ }
+ if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
+ // If user selected speaker phone, but then plugged wired headset then make
+ // wired headset as user selected device.
+ userSelectedAudioDevice = AudioDevice.WIRED_HEADSET;
+ }
+ if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
+ // If user selected wired headset, but then unplugged wired headset then make
+ // speaker phone as user selected device.
+ userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE;
+ }
- // Switch to correct audio device given the list of available audio devices.
- if (hasWiredHeadset) {
- setAudioDevice(AudioDevice.WIRED_HEADSET);
+ // Need to start Bluetooth if it is available and user either selected it explicitly or
+ // user did not select any output device.
+ boolean needBluetoothAudioStart =
+ bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
+ && (userSelectedAudioDevice == AudioDevice.NONE
+ || userSelectedAudioDevice == AudioDevice.BLUETOOTH);
+
+ // Need to stop Bluetooth audio if user selected different device and
+ // Bluetooth SCO connection is established or in the process.
+ boolean needBluetoothAudioStop =
+ (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING)
+ && (userSelectedAudioDevice != AudioDevice.NONE
+ && userSelectedAudioDevice != AudioDevice.BLUETOOTH);
+
+ if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
+ || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
+ Log.d(TAG, "Need BT audio: start=" + needBluetoothAudioStart + ", "
+ + "stop=" + needBluetoothAudioStop + ", "
+ + "BT state=" + bluetoothManager.getState());
+ }
+
+ // Start or stop Bluetooth SCO connection given states set earlier.
+ if (needBluetoothAudioStop) {
+ bluetoothManager.stopScoAudio();
+ bluetoothManager.updateDevice();
+ }
+
+ if (needBluetoothAudioStart && !needBluetoothAudioStop) {
+ // Attempt to start Bluetooth SCO audio (takes a few second to start).
+ if (!bluetoothManager.startScoAudio()) {
+ // Remove BLUETOOTH from list of available devices since SCO failed.
+ audioDevices.remove(AudioDevice.BLUETOOTH);
+ audioDeviceSetUpdated = true;
+ }
+ }
+
+ // Update selected audio device.
+ AudioDevice newAudioDevice = selectedAudioDevice;
+
+ if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
+ // If a Bluetooth is connected, then it should be used as output audio
+ // device. Note that it is not sufficient that a headset is available;
+ // an active SCO channel must also be up and running.
+ newAudioDevice = AudioDevice.BLUETOOTH;
+ } else if (hasWiredHeadset) {
+ // If a wired headset is connected, but Bluetooth is not, then wired headset is used as
+ // audio device.
+ newAudioDevice = AudioDevice.WIRED_HEADSET;
} else {
- setAudioDevice(defaultAudioDevice);
+ // No wired headset and no Bluetooth, hence the audio-device list can contain speaker
+ // phone (on a tablet), or speaker phone and earpiece (on mobile phone).
+ // |defaultAudioDevice| contains either AudioDevice.SPEAKER_PHONE or AudioDevice.EARPIECE
+ // depending on the user's selection.
+ newAudioDevice = defaultAudioDevice;
}
- }
-
- /** Called each time a new audio device has been added or removed. */
- private void onAudioManagerChangedState() {
- Log.d(TAG, "onAudioManagerChangedState: devices=" + audioDevices + ", selected="
- + selectedAudioDevice);
-
- // Enable the proximity sensor if there are two available audio devices
- // in the list. Given the current implementation, we know that the choice
- // will then be between EARPIECE and SPEAKER_PHONE.
- if (audioDevices.size() == 2) {
- AppRTCUtils.assertIsTrue(audioDevices.contains(AudioDevice.EARPIECE)
- && audioDevices.contains(AudioDevice.SPEAKER_PHONE));
- // Start the proximity sensor.
- proximitySensor.start();
- } else if (audioDevices.size() == 1) {
- // Stop the proximity sensor since it is no longer needed.
- proximitySensor.stop();
- } else {
- Log.e(TAG, "Invalid device list");
+ // Switch to new device but only if there has been any changes.
+ if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) {
+ // Do the required device switch.
+ setAudioDeviceInternal(newAudioDevice);
+ Log.d(TAG, "New device status: "
+ + "available=" + audioDevices + ", "
+ + "selected=" + newAudioDevice);
+ if (audioManagerEvents != null) {
+ // Notify a listening client that audio device has been changed.
+ audioManagerEvents.onAudioDeviceChanged(selectedAudioDevice, audioDevices);
+ }
}
-
- if (onStateChangeListener != null) {
- // Run callback to notify a listening client. The client can then
- // use public getters to query the new state.
- onStateChangeListener.run();
- }
+ Log.d(TAG, "--- updateAudioDeviceState done");
}
}
diff --git a/examples/androidapp/src/org/appspot/apprtc/AppRTCBluetoothManager.java b/examples/androidapp/src/org/appspot/apprtc/AppRTCBluetoothManager.java
new file mode 100644
index 0000000..ee58cea
--- /dev/null
+++ b/examples/androidapp/src/org/appspot/apprtc/AppRTCBluetoothManager.java
@@ -0,0 +1,521 @@
+/*
+ * Copyright 2016 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+package org.appspot.apprtc;
+
+import org.appspot.apprtc.util.AppRTCUtils;
+
+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.content.pm.PackageManager;
+import android.media.AudioManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Process;
+import android.util.Log;
+
+import org.webrtc.ThreadUtils;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * AppRTCProximitySensor manages functions related to Bluetoth devices in the
+ * AppRTC demo.
+ */
+public class AppRTCBluetoothManager {
+ private static final String TAG = "AppRTCBluetoothManager";
+
+ // Timeout interval for starting or stopping audio to a Bluetooth SCO device.
+ private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000;
+ // Maximum number of SCO connection attempts.
+ private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
+
+ // Bluetooth connection state.
+ public enum State {
+ // Bluetooth is not available; no adapter or Bluetooth is off.
+ UNINITIALIZED,
+ // Bluetooth error happened when trying to start Bluetooth.
+ ERROR,
+ // Bluetooth proxy object for the Headset profile exists, but no connected headset devices,
+ // SCO is not started or disconnected.
+ HEADSET_UNAVAILABLE,
+ // Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset
+ // present, but SCO is not started or disconnected.
+ HEADSET_AVAILABLE,
+ // Bluetooth audio SCO connection with remote device is closing.
+ SCO_DISCONNECTING,
+ // Bluetooth audio SCO connection with remote device is initiated.
+ SCO_CONNECTING,
+ // Bluetooth audio SCO connection with remote device is established.
+ SCO_CONNECTED
+ }
+
+ private final Context apprtcContext;
+ private final AppRTCAudioManager apprtcAudioManager;
+ private final AudioManager audioManager;
+ private final Handler handler;
+
+ int scoConnectionAttempts;
+ private State bluetoothState;
+ private final BluetoothProfile.ServiceListener bluetoothServiceListener;
+ private BluetoothAdapter bluetoothAdapter;
+ private BluetoothHeadset bluetoothHeadset;
+ private BluetoothDevice bluetoothDevice;
+ private final BroadcastReceiver bluetoothHeadsetReceiver;
+
+ // Runs when the Bluetooth timeout expires. We use that timeout after calling
+ // startScoAudio() or stopScoAudio() because we're not guaranteed to get a
+ // callback after those calls.
+ private final Runnable bluetoothTimeoutRunnable = new Runnable() {
+ @Override
+ public void run() {
+ bluetoothTimeout();
+ }
+ };
+
+ /**
+ * Implementation of an interface that notifies BluetoothProfile IPC clients when they have been
+ * connected to or disconnected from the service.
+ */
+ private class BluetoothServiceListener implements BluetoothProfile.ServiceListener {
+ @Override
+ // Called to notify the client when the proxy object has been connected to the service.
+ // Once we have the profile proxy object, we can use it to monitor the state of the
+ // connection and perform other operations that are relevant to the headset profile.
+ public void onServiceConnected(int profile, BluetoothProfile proxy) {
+ if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
+ return;
+ }
+ Log.d(TAG, "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState);
+ // Android only supports one connected Bluetooth Headset at a time.
+ bluetoothHeadset = (BluetoothHeadset) proxy;
+ updateAudioDeviceState();
+ Log.d(TAG, "onServiceConnected done: BT state=" + bluetoothState);
+ }
+
+ @Override
+ /** Notifies the client when the proxy object has been disconnected from the service. */
+ public void onServiceDisconnected(int profile) {
+ if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
+ return;
+ }
+ Log.d(TAG, "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState);
+ stopScoAudio();
+ bluetoothHeadset = null;
+ bluetoothDevice = null;
+ bluetoothState = State.HEADSET_UNAVAILABLE;
+ updateAudioDeviceState();
+ Log.d(TAG, "onServiceDisconnected done: BT state=" + bluetoothState);
+ }
+ }
+
+ // Intent broadcast receiver which handles changes in Bluetooth device availability.
+ // Detects headset changes and Bluetooth SCO state changes.
+ private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (bluetoothState == State.UNINITIALIZED) {
+ return;
+ }
+ final String action = intent.getAction();
+ // Change in connection state of the Headset profile. Note that the
+ // change does not tell us anything about whether we're streaming
+ // audio to BT over SCO. Typically received when user turns on a BT
+ // headset while audio is active using another audio device.
+ if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
+ final int state =
+ intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED);
+ Log.d(TAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
+ + "a=ACTION_CONNECTION_STATE_CHANGED, "
+ + "s=" + stateToString(state) + ", "
+ + "sb=" + isInitialStickyBroadcast() + ", "
+ + "BT state: " + bluetoothState);
+ if (state == BluetoothHeadset.STATE_CONNECTED) {
+ scoConnectionAttempts = 0;
+ updateAudioDeviceState();
+ } else if (state == BluetoothHeadset.STATE_CONNECTING) {
+ // No action needed.
+ } else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
+ // No action needed.
+ } else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
+ // Bluetooth is probably powered off during the call.
+ stopScoAudio();
+ updateAudioDeviceState();
+ }
+ // Change in the audio (SCO) connection state of the Headset profile.
+ // Typically received after call to startScoAudio() has finalized.
+ } else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
+ final int state = intent.getIntExtra(
+ BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
+ Log.d(TAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
+ + "a=ACTION_AUDIO_STATE_CHANGED, "
+ + "s=" + stateToString(state) + ", "
+ + "sb=" + isInitialStickyBroadcast() + ", "
+ + "BT state: " + bluetoothState);
+ if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
+ cancelTimer();
+ if (bluetoothState == State.SCO_CONNECTING) {
+ Log.d(TAG, "+++ Bluetooth audio SCO is now connected");
+ bluetoothState = State.SCO_CONNECTED;
+ scoConnectionAttempts = 0;
+ updateAudioDeviceState();
+ } else {
+ Log.w(TAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED");
+ }
+ } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
+ Log.d(TAG, "+++ Bluetooth audio SCO is now connecting...");
+ } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
+ Log.d(TAG, "+++ Bluetooth audio SCO is now disconnected");
+ if (isInitialStickyBroadcast()) {
+ Log.d(TAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.");
+ return;
+ }
+ updateAudioDeviceState();
+ }
+ }
+ Log.d(TAG, "onReceive done: BT state=" + bluetoothState);
+ }
+ };
+
+ /** Construction. */
+ static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audioManager) {
+ Log.d(TAG, "create" + AppRTCUtils.getThreadInfo());
+ return new AppRTCBluetoothManager(context, audioManager);
+ }
+
+ protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioManager) {
+ Log.d(TAG, "ctor");
+ ThreadUtils.checkIsOnMainThread();
+ apprtcContext = context;
+ apprtcAudioManager = audioManager;
+ this.audioManager = getAudioManager(context);
+ bluetoothState = State.UNINITIALIZED;
+ bluetoothServiceListener = new BluetoothServiceListener();
+ bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver();
+ handler = new Handler(Looper.getMainLooper());
+ }
+
+ /** Returns the internal state. */
+ public State getState() {
+ ThreadUtils.checkIsOnMainThread();
+ return bluetoothState;
+ }
+
+ /**
+ * Activates components required to detect Bluetooth devices and to enable
+ * BT SCO (audio is routed via BT SCO) for the headset profile. The end
+ * state will be HEADSET_UNAVAILABLE but a state machine has started which
+ * will start a state change sequence where the final outcome depends on
+ * if/when the BT headset is enabled.
+ * Example of state change sequence when start() is called while BT device
+ * is connected and enabled:
+ * UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE -->
+ * SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
+ * Note that the AppRTCAudioManager is also involved in driving this state
+ * change.
+ */
+ public void start() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "start");
+ if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) {
+ Log.w(TAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission");
+ return;
+ }
+ if (bluetoothState != State.UNINITIALIZED) {
+ Log.w(TAG, "Invalid BT state");
+ return;
+ }
+ bluetoothHeadset = null;
+ bluetoothDevice = null;
+ scoConnectionAttempts = 0;
+ // Get a handle to the default local Bluetooth adapter.
+ bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ if (bluetoothAdapter == null) {
+ Log.w(TAG, "Device does not support Bluetooth");
+ return;
+ }
+ // Ensure that the device supports use of BT SCO audio for off call use cases.
+ if (!audioManager.isBluetoothScoAvailableOffCall()) {
+ Log.e(TAG, "Bluetooth SCO audio is not available off call");
+ return;
+ }
+ logBluetoothAdapterInfo(bluetoothAdapter);
+ // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and
+ // Hands-Free) proxy object and install a listener.
+ if (!getBluetoothProfileProxy(
+ apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET)) {
+ Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed");
+ return;
+ }
+ // Register receivers for BluetoothHeadset change notifications.
+ IntentFilter bluetoothHeadsetFilter = new IntentFilter();
+ // Register receiver for change in connection state of the Headset profile.
+ bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
+ // Register receiver for change in audio connection state of the Headset profile.
+ bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
+ registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter);
+ Log.d(TAG, "HEADSET profile state: "
+ + stateToString(bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)));
+ Log.d(TAG, "Bluetooth proxy for headset profile has started");
+ bluetoothState = State.HEADSET_UNAVAILABLE;
+ Log.d(TAG, "start done: BT state=" + bluetoothState);
+ }
+
+ /** Stops and closes all components related to Bluetooth audio. */
+ public void stop() {
+ ThreadUtils.checkIsOnMainThread();
+ unregisterReceiver(bluetoothHeadsetReceiver);
+ Log.d(TAG, "stop: BT state=" + bluetoothState);
+ if (bluetoothAdapter != null) {
+ // Stop BT SCO connection with remote device if needed.
+ stopScoAudio();
+ // Close down remaining BT resources.
+ if (bluetoothState != State.UNINITIALIZED) {
+ cancelTimer();
+ if (bluetoothHeadset != null) {
+ bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
+ bluetoothHeadset = null;
+ }
+ bluetoothAdapter = null;
+ bluetoothDevice = null;
+ bluetoothState = State.UNINITIALIZED;
+ }
+ }
+ Log.d(TAG, "stop done: BT state=" + bluetoothState);
+ }
+
+ /**
+ * Starts Bluetooth SCO connection with remote device.
+ * Note that the phone application always has the priority on the usage of the SCO connection
+ * for telephony. If this method is called while the phone is in call it will be ignored.
+ * Similarly, if a call is received or sent while an application is using the SCO connection,
+ * the connection will be lost for the application and NOT returned automatically when the call
+ * ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a
+ * virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_MR2 only a raw SCO
+ * audio connection is established.
+ * TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and
+ * higher. It might be required to initiates a virtual voice call since many devices do not
+ * accept SCO audio without a "call".
+ */
+ public boolean startScoAudio() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "startSco: BT state=" + bluetoothState + ", "
+ + "attempts: " + scoConnectionAttempts + ", "
+ + "SCO is on: " + isScoOn());
+ if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
+ Log.e(TAG, "BT SCO connection fails - no more attempts");
+ return false;
+ }
+ if (bluetoothState != State.HEADSET_AVAILABLE) {
+ Log.e(TAG, "BT SCO connection fails - no headset available");
+ return false;
+ }
+ // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
+ Log.d(TAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED...");
+ // The SCO connection establishment can take several seconds, hence we cannot rely on the
+ // connection to be available when the method returns but instead register to receive the
+ // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED.
+ bluetoothState = State.SCO_CONNECTING;
+ audioManager.startBluetoothSco();
+ scoConnectionAttempts++;
+ startTimer();
+ Log.d(TAG, "startScoAudio done: BT state=" + bluetoothState);
+ return true;
+ }
+
+ /** Stops Bluetooth SCO connection with remote device. */
+ public void stopScoAudio() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "stopScoAudio: BT state=" + bluetoothState + ", "
+ + "SCO is on: " + isScoOn());
+ if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
+ return;
+ }
+ cancelTimer();
+ audioManager.stopBluetoothSco();
+ bluetoothState = State.SCO_DISCONNECTING;
+ Log.d(TAG, "stopScoAudio done: BT state=" + bluetoothState);
+ }
+
+ /**
+ * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset
+ * Service via IPC) to update the list of connected devices for the HEADSET
+ * profile. The internal state will change to HEADSET_UNAVAILABLE or to
+ * HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected
+ * device if available.
+ */
+ public void updateDevice() {
+ if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
+ return;
+ }
+ Log.d(TAG, "updateDevice");
+ // Get connected devices for the headset profile. Returns the set of
+ // devices which are in state STATE_CONNECTED. The BluetoothDevice class
+ // is just a thin wrapper for a Bluetooth hardware address.
+ List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
+ if (devices.isEmpty()) {
+ bluetoothDevice = null;
+ bluetoothState = State.HEADSET_UNAVAILABLE;
+ Log.d(TAG, "No connected bluetooth headset");
+ } else {
+ // Always use first device is list. Android only supports one device.
+ bluetoothDevice = devices.get(0);
+ bluetoothState = State.HEADSET_AVAILABLE;
+ Log.d(TAG, "Connected bluetooth headset: "
+ + "name=" + bluetoothDevice.getName() + ", "
+ + "state=" + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice))
+ + ", SCO audio=" + bluetoothHeadset.isAudioConnected(bluetoothDevice));
+ }
+ Log.d(TAG, "updateDevice done: BT state=" + bluetoothState);
+ }
+
+ /**
+ * Stubs for test mocks.
+ */
+ protected AudioManager getAudioManager(Context context) {
+ return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ }
+
+ protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+ apprtcContext.registerReceiver(receiver, filter);
+ }
+
+ protected void unregisterReceiver(BroadcastReceiver receiver) {
+ apprtcContext.unregisterReceiver(receiver);
+ }
+
+ protected boolean getBluetoothProfileProxy(
+ Context context, BluetoothProfile.ServiceListener listener, int profile) {
+ return bluetoothAdapter.getProfileProxy(context, listener, profile);
+ }
+
+ protected boolean hasPermission(Context context, String permission) {
+ return apprtcContext.checkPermission(permission, Process.myPid(), Process.myUid())
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ /** Logs the state of the local Bluetooth adapter. */
+ protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
+ Log.d(TAG, "BluetoothAdapter: "
+ + "enabled=" + localAdapter.isEnabled() + ", "
+ + "state=" + stateToString(localAdapter.getState()) + ", "
+ + "name=" + localAdapter.getName() + ", "
+ + "address=" + localAdapter.getAddress());
+ // Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter.
+ Set<BluetoothDevice> pairedDevices = localAdapter.getBondedDevices();
+ if (!pairedDevices.isEmpty()) {
+ Log.d(TAG, "paired devices:");
+ for (BluetoothDevice device : pairedDevices) {
+ Log.d(TAG, " name=" + device.getName() + ", address=" + device.getAddress());
+ }
+ }
+ }
+
+ /** Ensures that the audio manager updates its list of available audio devices. */
+ private void updateAudioDeviceState() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "updateAudioDeviceState");
+ apprtcAudioManager.updateAudioDeviceState();
+ }
+
+ /** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */
+ private void startTimer() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "startTimer");
+ handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
+ }
+
+ /** Cancels any outstanding timer tasks. */
+ private void cancelTimer() {
+ ThreadUtils.checkIsOnMainThread();
+ Log.d(TAG, "cancelTimer");
+ handler.removeCallbacks(bluetoothTimeoutRunnable);
+ }
+
+ /**
+ * Called when start of the BT SCO channel takes too long time. Usually
+ * happens when the BT device has been turned on during an ongoing call.
+ */
+ private void bluetoothTimeout() {
+ ThreadUtils.checkIsOnMainThread();
+ if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
+ return;
+ }
+ Log.d(TAG, "bluetoothTimeout: BT state=" + bluetoothState + ", "
+ + "attempts: " + scoConnectionAttempts + ", "
+ + "SCO is on: " + isScoOn());
+ if (bluetoothState != State.SCO_CONNECTING) {
+ return;
+ }
+ // Bluetooth SCO should be connecting; check the latest result.
+ boolean scoConnected = false;
+ List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
+ if (devices.size() > 0) {
+ bluetoothDevice = devices.get(0);
+ if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) {
+ Log.d(TAG, "SCO connected with " + bluetoothDevice.getName());
+ scoConnected = true;
+ } else {
+ Log.d(TAG, "SCO is not connected with " + bluetoothDevice.getName());
+ }
+ }
+ if (scoConnected) {
+ // We thought BT had timed out, but it's actually on; updating state.
+ bluetoothState = State.SCO_CONNECTED;
+ scoConnectionAttempts = 0;
+ } else {
+ // Give up and "cancel" our request by calling stopBluetoothSco().
+ Log.w(TAG, "BT failed to connect after timeout");
+ stopScoAudio();
+ }
+ updateAudioDeviceState();
+ Log.d(TAG, "bluetoothTimeout done: BT state=" + bluetoothState);
+ }
+
+ /** Checks whether audio uses Bluetooth SCO. */
+ private boolean isScoOn() {
+ return audioManager.isBluetoothScoOn();
+ }
+
+ /** Converts BluetoothAdapter states into local string representations. */
+ private String stateToString(int state) {
+ switch (state) {
+ case BluetoothAdapter.STATE_DISCONNECTED:
+ return "DISCONNECTED";
+ case BluetoothAdapter.STATE_CONNECTED:
+ return "CONNECTED";
+ case BluetoothAdapter.STATE_CONNECTING:
+ return "CONNECTING";
+ case BluetoothAdapter.STATE_DISCONNECTING:
+ return "DISCONNECTING";
+ case BluetoothAdapter.STATE_OFF:
+ return "OFF";
+ case BluetoothAdapter.STATE_ON:
+ return "ON";
+ case BluetoothAdapter.STATE_TURNING_OFF:
+ // Indicates the local Bluetooth adapter is turning off. Local clients should immediately
+ // attempt graceful disconnection of any remote links.
+ return "TURNING_OFF";
+ case BluetoothAdapter.STATE_TURNING_ON:
+ // Indicates the local Bluetooth adapter is turning on. However local clients should wait
+ // for STATE_ON before attempting to use the adapter.
+ return "TURNING_ON";
+ default:
+ return "INVALID";
+ }
+ }
+}
diff --git a/examples/androidapp/src/org/appspot/apprtc/CallActivity.java b/examples/androidapp/src/org/appspot/apprtc/CallActivity.java
index 3570d5e..276f752 100644
--- a/examples/androidapp/src/org/appspot/apprtc/CallActivity.java
+++ b/examples/androidapp/src/org/appspot/apprtc/CallActivity.java
@@ -33,6 +33,9 @@
import java.lang.RuntimeException;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
+import org.appspot.apprtc.AppRTCAudioManager.AudioDevice;
+import org.appspot.apprtc.AppRTCAudioManager.AudioManagerEvents;
import org.appspot.apprtc.AppRTCClient.RoomConnectionParameters;
import org.appspot.apprtc.AppRTCClient.SignalingParameters;
import org.appspot.apprtc.PeerConnectionClient.DataChannelParameters;
@@ -527,18 +530,19 @@
// Create and audio manager that will take care of audio routing,
// audio modes, audio device enumeration etc.
- audioManager = AppRTCAudioManager.create(this, new Runnable() {
- // This method will be called each time the audio state (number and
- // type of devices) has been changed.
- @Override
- public void run() {
- onAudioManagerChangedState();
- }
- });
+ audioManager = AppRTCAudioManager.create(this);
// Store existing audio settings and change audio mode to
// MODE_IN_COMMUNICATION for best possible VoIP performance.
- Log.d(TAG, "Initializing the audio manager...");
- audioManager.init();
+ Log.d(TAG, "Starting the audio manager...");
+ audioManager.start(new AudioManagerEvents() {
+ // This method will be called each time the number of available audio
+ // devices has changed.
+ @Override
+ public void onAudioDeviceChanged(
+ AudioDevice audioDevice, Set<AudioDevice> availableAudioDevices) {
+ onAudioManagerDevicesChanged(audioDevice, availableAudioDevices);
+ }
+ });
}
// Should be called from UI thread
@@ -555,9 +559,13 @@
peerConnectionClient.enableStatsEvents(true, STAT_CALLBACK_PERIOD);
}
- private void onAudioManagerChangedState() {
- // TODO(henrika): disable video if AppRTCAudioManager.AudioDevice.EARPIECE
- // is active.
+ // This method is called when the audio manager reports audio device change,
+ // e.g. from wired headset to speakerphone.
+ private void onAudioManagerDevicesChanged(
+ final AudioDevice device, final Set<AudioDevice> availableDevices) {
+ Log.d(TAG, "onAudioManagerDevicesChanged: " + availableDevices + ", "
+ + "selected: " + device);
+ // TODO(henrika): add callback handler.
}
// Disconnect from remote resources, dispose of local resources, and exit.
@@ -584,7 +592,7 @@
remoteRenderScreen = null;
}
if (audioManager != null) {
- audioManager.close();
+ audioManager.stop();
audioManager = null;
}
if (iceConnected && !isError) {
diff --git a/examples/androidjunit/src/org/appspot/apprtc/BluetoothManagerTest.java b/examples/androidjunit/src/org/appspot/apprtc/BluetoothManagerTest.java
new file mode 100644
index 0000000..0d077f3
--- /dev/null
+++ b/examples/androidjunit/src/org/appspot/apprtc/BluetoothManagerTest.java
@@ -0,0 +1,271 @@
+/*
+ * Copyright 2016 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+package org.appspot.apprtc;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+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.util.Log;
+import java.util.LinkedList;
+import java.util.List;
+import org.appspot.apprtc.AppRTCBluetoothManager.State;
+import org.chromium.testing.local.LocalRobolectricTestRunner;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowLog;
+
+/**
+ * Verifies basic behavior of the AppRTCBluetoothManager class.
+ * Note that the test object uses an AppRTCAudioManager (injected in ctor),
+ * but a mocked version is used instead. Hence, the parts "driven" by the AppRTC
+ * audio manager are not included in this test.
+ */
+@RunWith(LocalRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class BluetoothManagerTest {
+ private static final String TAG = "BluetoothManagerTest";
+ private static final String BLUETOOTH_TEST_DEVICE_NAME = "BluetoothTestDevice";
+
+ private BroadcastReceiver bluetoothHeadsetStateReceiver;
+ private BluetoothProfile.ServiceListener bluetoothServiceListener;
+ private BluetoothHeadset mockedBluetoothHeadset;
+ private BluetoothDevice mockedBluetoothDevice;
+ private List<BluetoothDevice> mockedBluetoothDeviceList;
+ private AppRTCBluetoothManager bluetoothManager;
+ private AppRTCAudioManager mockedAppRtcAudioManager;
+ private AudioManager mockedAudioManager;
+ private Context context;
+
+ @Before
+ public void setUp() {
+ ShadowLog.stream = System.out;
+ context = ShadowApplication.getInstance().getApplicationContext();
+ mockedAppRtcAudioManager = mock(AppRTCAudioManager.class);
+ mockedAudioManager = mock(AudioManager.class);
+ mockedBluetoothHeadset = mock(BluetoothHeadset.class);
+ mockedBluetoothDevice = mock(BluetoothDevice.class);
+ mockedBluetoothDeviceList = new LinkedList<BluetoothDevice>();
+
+ // Simulate that bluetooth SCO audio is available by default.
+ when(mockedAudioManager.isBluetoothScoAvailableOffCall()).thenReturn(true);
+
+ // Create the test object and override protected methods for this test.
+ bluetoothManager = new AppRTCBluetoothManager(context, mockedAppRtcAudioManager) {
+ @Override
+ protected AudioManager getAudioManager(Context context) {
+ Log.d(TAG, "getAudioManager");
+ return mockedAudioManager;
+ }
+
+ @Override
+ protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+ Log.d(TAG, "registerReceiver");
+ if (filter.hasAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
+ && filter.hasAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
+ // Gives access to the real broadcast receiver so the test can use it.
+ bluetoothHeadsetStateReceiver = receiver;
+ }
+ }
+
+ @Override
+ protected void unregisterReceiver(BroadcastReceiver receiver) {
+ Log.d(TAG, "unregisterReceiver");
+ if (receiver == bluetoothHeadsetStateReceiver) {
+ bluetoothHeadsetStateReceiver = null;
+ }
+ }
+
+ @Override
+ protected boolean getBluetoothProfileProxy(
+ Context context, BluetoothProfile.ServiceListener listener, int profile) {
+ Log.d(TAG, "getBluetoothProfileProxy");
+ if (profile == BluetoothProfile.HEADSET) {
+ // Allows the test to access the real Bluetooth service listener object.
+ bluetoothServiceListener = listener;
+ }
+ return true;
+ }
+
+ @Override
+ protected boolean hasPermission(Context context, String permission) {
+ Log.d(TAG, "hasPermission(" + permission + ")");
+ // Ensure that the client asks for Bluetooth permission.
+ return (permission == android.Manifest.permission.BLUETOOTH);
+ }
+
+ @Override
+ protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
+ // Do nothing in tests. No need to mock BluetoothAdapter.
+ }
+ };
+ }
+
+ // Verify that Bluetooth service listener for headset profile is properly initialized.
+ @Test
+ public void testBluetoothServiceListenerInitialized() {
+ bluetoothManager.start();
+ assertNotNull(bluetoothServiceListener);
+ verify(mockedAppRtcAudioManager, never()).updateAudioDeviceState();
+ }
+
+ // Verify that broadcast receivers for Bluetooth SCO audio state and Bluetooth headset state
+ // are properly registered and unregistered.
+ @Test
+ public void testBluetoothBroadcastReceiversAreRegistered() {
+ bluetoothManager.start();
+ assertNotNull(bluetoothHeadsetStateReceiver);
+ bluetoothManager.stop();
+ assertNull(bluetoothHeadsetStateReceiver);
+ }
+
+ // Verify that the Bluetooth manager starts and stops with correct states.
+ @Test
+ public void testBluetoothDefaultStartStopStates() {
+ bluetoothManager.start();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ bluetoothManager.stop();
+ assertEquals(bluetoothManager.getState(), State.UNINITIALIZED);
+ }
+
+ // Verify correct state after receiving BluetoothServiceListener.onServiceConnected()
+ // when no BT device is enabled.
+ @Test
+ public void testBluetoothServiceListenerConnectedWithNoHeadset() {
+ bluetoothManager.start();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ simulateBluetoothServiceConnectedWithNoConnectedHeadset();
+ verify(mockedAppRtcAudioManager, times(1)).updateAudioDeviceState();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ }
+
+ // Verify correct state after receiving BluetoothServiceListener.onServiceConnected()
+ // when one emulated (test) BT device is enabled. Android does not support more than
+ // one connected BT headset.
+ @Test
+ public void testBluetoothServiceListenerConnectedWithHeadset() {
+ bluetoothManager.start();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ simulateBluetoothServiceConnectedWithConnectedHeadset();
+ verify(mockedAppRtcAudioManager, times(1)).updateAudioDeviceState();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_AVAILABLE);
+ }
+
+ // Verify correct state after receiving BluetoothProfile.ServiceListener.onServiceDisconnected().
+ @Test
+ public void testBluetoothServiceListenerDisconnected() {
+ bluetoothManager.start();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ simulateBluetoothServiceDisconnected();
+ verify(mockedAppRtcAudioManager, times(1)).updateAudioDeviceState();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ }
+
+ // Verify correct state after BluetoothServiceListener.onServiceConnected() and
+ // the intent indicating that the headset is actually connected. Both these callbacks
+ // results in calls to updateAudioDeviceState() on the AppRTC audio manager.
+ // No BT SCO is enabled here to keep the test limited.
+ @Test
+ public void testBluetoothHeadsetConnected() {
+ bluetoothManager.start();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ simulateBluetoothServiceConnectedWithConnectedHeadset();
+ simulateBluetoothHeadsetConnected();
+ verify(mockedAppRtcAudioManager, times(2)).updateAudioDeviceState();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_AVAILABLE);
+ }
+
+ // Verify correct state sequence for a case when a BT headset is available,
+ // followed by BT SCO audio being enabled and then stopped.
+ @Test
+ public void testBluetoothScoAudioStartAndStop() {
+ bluetoothManager.start();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_UNAVAILABLE);
+ simulateBluetoothServiceConnectedWithConnectedHeadset();
+ assertEquals(bluetoothManager.getState(), State.HEADSET_AVAILABLE);
+ bluetoothManager.startScoAudio();
+ assertEquals(bluetoothManager.getState(), State.SCO_CONNECTING);
+ simulateBluetoothScoConnectionConnected();
+ assertEquals(bluetoothManager.getState(), State.SCO_CONNECTED);
+ bluetoothManager.stopScoAudio();
+ simulateBluetoothScoConnectionDisconnected();
+ assertEquals(bluetoothManager.getState(), State.SCO_DISCONNECTING);
+ bluetoothManager.stop();
+ assertEquals(bluetoothManager.getState(), State.UNINITIALIZED);
+ verify(mockedAppRtcAudioManager, times(3)).updateAudioDeviceState();
+ }
+
+ /**
+ * Private helper methods.
+ */
+ private void simulateBluetoothServiceConnectedWithNoConnectedHeadset() {
+ mockedBluetoothDeviceList.clear();
+ when(mockedBluetoothHeadset.getConnectedDevices()).thenReturn(mockedBluetoothDeviceList);
+ bluetoothServiceListener.onServiceConnected(BluetoothProfile.HEADSET, mockedBluetoothHeadset);
+ // In real life, the AppRTC audio manager makes this call.
+ bluetoothManager.updateDevice();
+ }
+
+ private void simulateBluetoothServiceConnectedWithConnectedHeadset() {
+ mockedBluetoothDeviceList.clear();
+ mockedBluetoothDeviceList.add(mockedBluetoothDevice);
+ when(mockedBluetoothHeadset.getConnectedDevices()).thenReturn(mockedBluetoothDeviceList);
+ when(mockedBluetoothDevice.getName()).thenReturn(BLUETOOTH_TEST_DEVICE_NAME);
+ bluetoothServiceListener.onServiceConnected(BluetoothProfile.HEADSET, mockedBluetoothHeadset);
+ // In real life, the AppRTC audio manager makes this call.
+ bluetoothManager.updateDevice();
+ }
+
+ private void simulateBluetoothServiceDisconnected() {
+ bluetoothServiceListener.onServiceDisconnected(BluetoothProfile.HEADSET);
+ }
+
+ private void simulateBluetoothHeadsetConnected() {
+ Intent intent = new Intent();
+ intent.setAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
+ intent.putExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_CONNECTED);
+ bluetoothHeadsetStateReceiver.onReceive(context, intent);
+ }
+
+ private void simulateBluetoothScoConnectionConnected() {
+ Intent intent = new Intent();
+ intent.setAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
+ intent.putExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_CONNECTED);
+ bluetoothHeadsetStateReceiver.onReceive(context, intent);
+ }
+
+ private void simulateBluetoothScoConnectionDisconnected() {
+ Intent intent = new Intent();
+ intent.setAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
+ intent.putExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
+ bluetoothHeadsetStateReceiver.onReceive(context, intent);
+ }
+}