copy audio manager from AppRTCDemo

Daniel Gultsch created

Change summary

src/main/AndroidManifest.xml                                              |   1 
src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java     | 578 
src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java | 549 
src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java  | 170 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java  |  10 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java           |  22 
src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java               |  55 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java |   7 
src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java       |  27 
src/main/res/drawable-hdpi/ic_mic_black_24dp.png                          |   0 
src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png                      |   0 
src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png                   |   0 
src/main/res/drawable-hdpi/ic_volume_up_black_24dp.png                    |   0 
src/main/res/drawable-mdpi/ic_mic_black_24dp.png                          |   0 
src/main/res/drawable-mdpi/ic_mic_off_black_24dp.png                      |   0 
src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png                   |   0 
src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png                    |   0 
src/main/res/drawable-xhdpi/ic_mic_black_24dp.png                         |   0 
src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png                     |   0 
src/main/res/drawable-xhdpi/ic_volume_off_black_24dp.png                  |   0 
src/main/res/drawable-xhdpi/ic_volume_up_black_24dp.png                   |   0 
src/main/res/drawable-xxhdpi/ic_mic_black_24dp.png                        |   0 
src/main/res/drawable-xxhdpi/ic_mic_off_black_24dp.png                    |   0 
src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png                 |   0 
src/main/res/drawable-xxhdpi/ic_volume_up_black_24dp.png                  |   0 
src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png                       |   0 
src/main/res/drawable-xxxhdpi/ic_mic_off_black_24dp.png                   |   0 
src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png                |   0 
src/main/res/drawable-xxxhdpi/ic_volume_up_black_24dp.png                 |   0 
src/main/res/layout/activity_rtp_session.xml                              |  34 
src/main/res/values/attrs.xml                                             |   2 
src/main/res/values/themes.xml                                            |   7 
32 files changed, 1,453 insertions(+), 9 deletions(-)

Detailed changes

src/main/AndroidManifest.xml 🔗

@@ -31,6 +31,7 @@
 
     <uses-permission android:name="android.permission.CAMERA" />
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.BLUETOOTH" />
     <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
     <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
     <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java 🔗

@@ -0,0 +1,578 @@
+/*
+ *  Copyright 2014 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 eu.siacs.conversations.services;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.webrtc.ThreadUtils;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.AppRTCUtils;
+
+/**
+ * AppRTCAudioManager manages all audio related parts of the AppRTC demo.
+ */
+public class AppRTCAudioManager {
+    private final Context apprtcContext;
+    // Contains speakerphone setting: auto, true or false
+    @Nullable
+    private final SpeakerPhonePreference speakerPhonePreference;
+    // Handles all tasks related to Bluetooth headset devices.
+    private final AppRTCBluetoothManager bluetoothManager;
+    @Nullable
+    private AudioManager audioManager;
+    @Nullable
+    private AudioManagerEvents audioManagerEvents;
+    private AudioManagerState amState;
+    private int savedAudioMode = AudioManager.MODE_INVALID;
+    private boolean savedIsSpeakerPhoneOn;
+    private boolean savedIsMicrophoneMute;
+    private boolean hasWiredHeadset;
+    // 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;
+    // Proximity sensor object. It measures the proximity of an object in cm
+    // relative to the view screen of a device and can therefore be used to
+    // assist device switching (close to ear <=> use headset earpiece if
+    // available, far from ear <=> use speaker phone).
+    @Nullable
+    private AppRTCProximitySensor proximitySensor;
+    // Contains a list of available audio devices. A Set collection is used to
+    // avoid duplicate elements.
+    private Set<AudioDevice> audioDevices = new HashSet<>();
+    // Broadcast receiver for wired headset intent broadcasts.
+    private BroadcastReceiver wiredHeadsetReceiver;
+    // Callback method for changes in audio focus.
+    @Nullable
+    private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
+
+    private AppRTCAudioManager(Context context, final SpeakerPhonePreference speakerPhonePreference) {
+        Log.d(Config.LOGTAG, "ctor");
+        ThreadUtils.checkIsOnMainThread();
+        apprtcContext = context;
+        audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
+        bluetoothManager = AppRTCBluetoothManager.create(context, this);
+        wiredHeadsetReceiver = new WiredHeadsetReceiver();
+        amState = AudioManagerState.UNINITIALIZED;
+        Log.d(Config.LOGTAG, "speaker phone preference: " + speakerPhonePreference);
+        this.speakerPhonePreference = speakerPhonePreference;
+        if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE) {
+            defaultAudioDevice = AudioDevice.EARPIECE;
+        } else {
+            defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+        }
+        // Create and initialize the proximity sensor.
+        // Tablet devices (e.g. Nexus 7) does not support proximity sensors.
+        // Note that, the sensor will not be active until start() has been called.
+        proximitySensor = AppRTCProximitySensor.create(context,
+                // This method will be called each time a state change is detected.
+                // Example: user holds his hand over the device (closer than ~5 cm),
+                // or removes his hand from the device.
+                this::onProximitySensorChangedState);
+        Log.d(Config.LOGTAG, "defaultAudioDevice: " + defaultAudioDevice);
+        AppRTCUtils.logDeviceInfo(Config.LOGTAG);
+    }
+
+    /**
+     * Construction.
+     */
+    public static AppRTCAudioManager create(Context context, SpeakerPhonePreference speakerPhonePreference) {
+        return new AppRTCAudioManager(context, speakerPhonePreference);
+    }
+
+    /**
+     * 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 (speakerPhonePreference != SpeakerPhonePreference.AUTO) {
+            return;
+        }
+        // The proximity sensor should only be activated when there are exactly two
+        // available audio devices.
+        if (audioDevices.size() == 2 && audioDevices.contains(AppRTCAudioManager.AudioDevice.EARPIECE)
+                && audioDevices.contains(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE)) {
+            if (proximitySensor.sensorReportsNearState()) {
+                // Sensor reports that a "handset is being held up to a person's ear",
+                // or "something is covering the light sensor".
+                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".
+                setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
+            }
+        }
+    }
+
+    @SuppressWarnings("deprecation")
+    // TODO(henrika): audioManager.requestAudioFocus() is deprecated.
+    public void start(AudioManagerEvents audioManagerEvents) {
+        Log.d(Config.LOGTAG, "start");
+        ThreadUtils.checkIsOnMainThread();
+        if (amState == AudioManagerState.RUNNING) {
+            Log.e(Config.LOGTAG, "AudioManager is already active");
+            return;
+        }
+        // TODO(henrika): perhaps call new method called preInitAudio() here if UNINITIALIZED.
+        Log.d(Config.LOGTAG, "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() {
+            // Called on the listener to notify if the audio focus for this listener has been changed.
+            // The |focusChange| value indicates whether the focus was gained, whether the focus was lost,
+            // and whether that loss is transient, or whether the new focus holder will hold it for an
+            // unknown amount of time.
+            // TODO(henrika): possibly extend support of handling audio-focus changes. Only contains
+            // logging for now.
+            @Override
+            public void onAudioFocusChange(int focusChange) {
+                final String typeOfChange;
+                switch (focusChange) {
+                    case AudioManager.AUDIOFOCUS_GAIN:
+                        typeOfChange = "AUDIOFOCUS_GAIN";
+                        break;
+                    case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
+                        typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT";
+                        break;
+                    case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE:
+                        typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE";
+                        break;
+                    case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:
+                        typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK";
+                        break;
+                    case AudioManager.AUDIOFOCUS_LOSS:
+                        typeOfChange = "AUDIOFOCUS_LOSS";
+                        break;
+                    case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
+                        typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT";
+                        break;
+                    case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
+                        typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK";
+                        break;
+                    default:
+                        typeOfChange = "AUDIOFOCUS_INVALID";
+                        break;
+                }
+                Log.d(Config.LOGTAG, "onAudioFocusChange: " + typeOfChange);
+            }
+        };
+        // Request audio playout focus (without ducking) and install listener for changes in focus.
+        int result = audioManager.requestAudioFocus(audioFocusChangeListener,
+                AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
+        if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+            Log.d(Config.LOGTAG, "Audio focus request granted for VOICE_CALL streams");
+        } else {
+            Log.e(Config.LOGTAG, "Audio focus request failed");
+        }
+        // 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.
+        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 BT or wired headset or by covering/uncovering
+        // the proximity sensor.
+        updateAudioDeviceState();
+        // Register receiver for broadcast intents related to adding/removing a
+        // wired headset.
+        registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
+        Log.d(Config.LOGTAG, "AudioManager started");
+    }
+
+    @SuppressWarnings("deprecation")
+    // TODO(henrika): audioManager.abandonAudioFocus() is deprecated.
+    public void stop() {
+        Log.d(Config.LOGTAG, "stop");
+        ThreadUtils.checkIsOnMainThread();
+        if (amState != AudioManagerState.RUNNING) {
+            Log.e(Config.LOGTAG, "Trying to stop AudioManager in incorrect state: " + amState);
+            return;
+        }
+        amState = AudioManagerState.UNINITIALIZED;
+        unregisterReceiver(wiredHeadsetReceiver);
+        bluetoothManager.stop();
+        // Restore previously stored audio states.
+        setSpeakerphoneOn(savedIsSpeakerPhoneOn);
+        setMicrophoneMute(savedIsMicrophoneMute);
+        audioManager.setMode(savedAudioMode);
+        // Abandon audio focus. Gives the previous focus owner, if any, focus.
+        audioManager.abandonAudioFocus(audioFocusChangeListener);
+        audioFocusChangeListener = null;
+        Log.d(Config.LOGTAG, "Abandoned audio focus for VOICE_CALL streams");
+        if (proximitySensor != null) {
+            proximitySensor.stop();
+            proximitySensor = null;
+        }
+        audioManagerEvents = null;
+        Log.d(Config.LOGTAG, "AudioManager stopped");
+    }
+
+    /**
+     * Changes selection of the currently active audio device.
+     */
+    private void setAudioDeviceInternal(AudioDevice device) {
+        Log.d(Config.LOGTAG, "setAudioDeviceInternal(device=" + device + ")");
+        AppRTCUtils.assertIsTrue(audioDevices.contains(device));
+        switch (device) {
+            case SPEAKER_PHONE:
+                setSpeakerphoneOn(true);
+                break;
+            case EARPIECE:
+                setSpeakerphoneOn(false);
+                break;
+            case WIRED_HEADSET:
+                setSpeakerphoneOn(false);
+                break;
+            case BLUETOOTH:
+                setSpeakerphoneOn(false);
+                break;
+            default:
+                Log.e(Config.LOGTAG, "Invalid audio device selection");
+                break;
+        }
+        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(Config.LOGTAG, "Invalid default audio device selection");
+                break;
+        }
+        Log.d(Config.LOGTAG, "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(Config.LOGTAG, "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<>(audioDevices));
+    }
+
+    /**
+     * Returns the currently selected audio device.
+     */
+    public AudioDevice getSelectedAudioDevice() {
+        ThreadUtils.checkIsOnMainThread();
+        return selectedAudioDevice;
+    }
+
+    /**
+     * Helper method for receiver registration.
+     */
+    private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+        apprtcContext.registerReceiver(receiver, filter);
+    }
+
+    /**
+     * Helper method for unregistration of an existing receiver.
+     */
+    private void unregisterReceiver(BroadcastReceiver receiver) {
+        apprtcContext.unregisterReceiver(receiver);
+    }
+
+    /**
+     * Sets the speaker phone mode.
+     */
+    private void setSpeakerphoneOn(boolean on) {
+        boolean wasOn = audioManager.isSpeakerphoneOn();
+        if (wasOn == on) {
+            return;
+        }
+        audioManager.setSpeakerphoneOn(on);
+    }
+
+    /**
+     * Sets the microphone mute state.
+     */
+    private void setMicrophoneMute(boolean on) {
+        boolean wasMuted = audioManager.isMicrophoneMute();
+        if (wasMuted == on) {
+            return;
+        }
+        audioManager.setMicrophoneMute(on);
+    }
+
+    /**
+     * Gets the current earpiece state.
+     */
+    private boolean hasEarpiece() {
+        return apprtcContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
+    }
+
+    /**
+     * Checks whether a wired headset is connected or not.
+     * This is not a valid indication that audio playback is actually over
+     * the wired headset as audio routing depends on other conditions. We
+     * only use it as an early indicator (during initialization) of an attached
+     * wired headset.
+     */
+    @Deprecated
+    private boolean hasWiredHeadset() {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+            return audioManager.isWiredHeadsetOn();
+        } else {
+            final AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
+            for (AudioDeviceInfo device : devices) {
+                final int type = device.getType();
+                if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
+                    Log.d(Config.LOGTAG, "hasWiredHeadset: found wired headset");
+                    return true;
+                } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
+                    Log.d(Config.LOGTAG, "hasWiredHeadset: found USB audio device");
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    /**
+     * 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(Config.LOGTAG, "--- updateAudioDeviceState: "
+                + "wired headset=" + hasWiredHeadset + ", "
+                + "BT state=" + bluetoothManager.getState());
+        Log.d(Config.LOGTAG, "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.
+            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).
+            newAudioDevices.add(AudioDevice.SPEAKER_PHONE);
+            if (hasEarpiece()) {
+                newAudioDevices.add(AudioDevice.EARPIECE);
+            }
+        }
+        // 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;
+        }
+        // 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(Config.LOGTAG, "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.
+        final AudioDevice newAudioDevice;
+        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 {
+            // 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;
+        }
+        // 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(Config.LOGTAG, "New device status: "
+                    + "available=" + audioDevices + ", "
+                    + "selected=" + newAudioDevice);
+            if (audioManagerEvents != null) {
+                // Notify a listening client that audio device has been changed.
+                audioManagerEvents.onAudioDeviceChanged(selectedAudioDevice, audioDevices);
+            }
+        }
+        Log.d(Config.LOGTAG, "--- updateAudioDeviceState done");
+    }
+
+    /**
+     * AudioDevice is the names of possible audio devices that we currently
+     * support.
+     */
+    public enum AudioDevice {SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE}
+
+    /**
+     * AudioManager state.
+     */
+    public enum AudioManagerState {
+        UNINITIALIZED,
+        PREINITIALIZED,
+        RUNNING,
+    }
+
+    public enum SpeakerPhonePreference {
+        AUTO, EARPIECE, SPEAKER
+    }
+
+    /**
+     * Selected audio device change event.
+     */
+    public interface AudioManagerEvents {
+        // Callback fired once audio device is changed or list of available audio devices changed.
+        void onAudioDeviceChanged(
+                AudioDevice selectedAudioDevice, Set<AudioDevice> availableAudioDevices);
+    }
+
+    /* 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(Config.LOGTAG, "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();
+        }
+    }
+}

src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java 🔗

@@ -0,0 +1,549 @@
+/*
+ *  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 eu.siacs.conversations.services;
+
+import android.annotation.SuppressLint;
+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.support.annotation.Nullable;
+import android.util.Log;
+
+import java.util.List;
+import java.util.Set;
+
+import org.webrtc.ThreadUtils;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.AppRTCUtils;
+
+/**
+ * AppRTCProximitySensor manages functions related to Bluetoth devices in the
+ * AppRTC demo.
+ */
+public class 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;
+    private final Context apprtcContext;
+    private final AppRTCAudioManager apprtcAudioManager;
+    @Nullable
+    private final AudioManager audioManager;
+    private final Handler handler;
+    private final BluetoothProfile.ServiceListener bluetoothServiceListener;
+    private final BroadcastReceiver bluetoothHeadsetReceiver;
+    int scoConnectionAttempts;
+    private State bluetoothState;
+    @Nullable
+    private BluetoothAdapter bluetoothAdapter;
+    @Nullable
+    private BluetoothHeadset bluetoothHeadset;
+    @Nullable
+    private BluetoothDevice bluetoothDevice;
+    // 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();
+        }
+    };
+    protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioManager) {
+        Log.d(Config.LOGTAG, "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());
+    }
+
+    /**
+     * Construction.
+     */
+    static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audioManager) {
+        Log.d(Config.LOGTAG, "create" + AppRTCUtils.getThreadInfo());
+        return new AppRTCBluetoothManager(context, audioManager);
+    }
+
+    /**
+     * 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(Config.LOGTAG, "start");
+        if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) {
+            Log.w(Config.LOGTAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission");
+            return;
+        }
+        if (bluetoothState != State.UNINITIALIZED) {
+            Log.w(Config.LOGTAG, "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(Config.LOGTAG, "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(Config.LOGTAG, "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(Config.LOGTAG, "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(Config.LOGTAG, "HEADSET profile state: "
+                + stateToString(bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)));
+        Log.d(Config.LOGTAG, "Bluetooth proxy for headset profile has started");
+        bluetoothState = State.HEADSET_UNAVAILABLE;
+        Log.d(Config.LOGTAG, "start done: BT state=" + bluetoothState);
+    }
+
+    /**
+     * Stops and closes all components related to Bluetooth audio.
+     */
+    public void stop() {
+        ThreadUtils.checkIsOnMainThread();
+        Log.d(Config.LOGTAG, "stop: BT state=" + bluetoothState);
+        if (bluetoothAdapter == null) {
+            return;
+        }
+        // Stop BT SCO connection with remote device if needed.
+        stopScoAudio();
+        // Close down remaining BT resources.
+        if (bluetoothState == State.UNINITIALIZED) {
+            return;
+        }
+        unregisterReceiver(bluetoothHeadsetReceiver);
+        cancelTimer();
+        if (bluetoothHeadset != null) {
+            bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
+            bluetoothHeadset = null;
+        }
+        bluetoothAdapter = null;
+        bluetoothDevice = null;
+        bluetoothState = State.UNINITIALIZED;
+        Log.d(Config.LOGTAG, "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(Config.LOGTAG, "startSco: BT state=" + bluetoothState + ", "
+                + "attempts: " + scoConnectionAttempts + ", "
+                + "SCO is on: " + isScoOn());
+        if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
+            Log.e(Config.LOGTAG, "BT SCO connection fails - no more attempts");
+            return false;
+        }
+        if (bluetoothState != State.HEADSET_AVAILABLE) {
+            Log.e(Config.LOGTAG, "BT SCO connection fails - no headset available");
+            return false;
+        }
+        // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
+        Log.d(Config.LOGTAG, "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();
+        audioManager.setBluetoothScoOn(true);
+        scoConnectionAttempts++;
+        startTimer();
+        Log.d(Config.LOGTAG, "startScoAudio done: BT state=" + bluetoothState + ", "
+                + "SCO is on: " + isScoOn());
+        return true;
+    }
+
+    /**
+     * Stops Bluetooth SCO connection with remote device.
+     */
+    public void stopScoAudio() {
+        ThreadUtils.checkIsOnMainThread();
+        Log.d(Config.LOGTAG, "stopScoAudio: BT state=" + bluetoothState + ", "
+                + "SCO is on: " + isScoOn());
+        if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
+            return;
+        }
+        cancelTimer();
+        audioManager.stopBluetoothSco();
+        audioManager.setBluetoothScoOn(false);
+        bluetoothState = State.SCO_DISCONNECTING;
+        Log.d(Config.LOGTAG, "stopScoAudio done: BT state=" + bluetoothState + ", "
+                + "SCO is on: " + isScoOn());
+    }
+
+    /**
+     * 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(Config.LOGTAG, "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(Config.LOGTAG, "No connected bluetooth headset");
+        } else {
+            // Always use first device in list. Android only supports one device.
+            bluetoothDevice = devices.get(0);
+            bluetoothState = State.HEADSET_AVAILABLE;
+            Log.d(Config.LOGTAG, "Connected bluetooth headset: "
+                    + "name=" + bluetoothDevice.getName() + ", "
+                    + "state=" + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice))
+                    + ", SCO audio=" + bluetoothHeadset.isAudioConnected(bluetoothDevice));
+        }
+        Log.d(Config.LOGTAG, "updateDevice done: BT state=" + bluetoothState);
+    }
+
+    /**
+     * Stubs for test mocks.
+     */
+    @Nullable
+    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.
+     */
+    @SuppressLint("HardwareIds")
+    protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
+        Log.d(Config.LOGTAG, "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(Config.LOGTAG, "paired devices:");
+            for (BluetoothDevice device : pairedDevices) {
+                Log.d(Config.LOGTAG, " 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(Config.LOGTAG, "updateAudioDeviceState");
+        apprtcAudioManager.updateAudioDeviceState();
+    }
+
+    /**
+     * Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds.
+     */
+    private void startTimer() {
+        ThreadUtils.checkIsOnMainThread();
+        Log.d(Config.LOGTAG, "startTimer");
+        handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
+    }
+
+    /**
+     * Cancels any outstanding timer tasks.
+     */
+    private void cancelTimer() {
+        ThreadUtils.checkIsOnMainThread();
+        Log.d(Config.LOGTAG, "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(Config.LOGTAG, "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(Config.LOGTAG, "SCO connected with " + bluetoothDevice.getName());
+                scoConnected = true;
+            } else {
+                Log.d(Config.LOGTAG, "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(Config.LOGTAG, "BT failed to connect after timeout");
+            stopScoAudio();
+        }
+        updateAudioDeviceState();
+        Log.d(Config.LOGTAG, "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";
+        }
+    }
+
+    // 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
+    }
+
+    /**
+     * 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(Config.LOGTAG, "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState);
+            // Android only supports one connected Bluetooth Headset at a time.
+            bluetoothHeadset = (BluetoothHeadset) proxy;
+            updateAudioDeviceState();
+            Log.d(Config.LOGTAG, "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(Config.LOGTAG, "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState);
+            stopScoAudio();
+            bluetoothHeadset = null;
+            bluetoothDevice = null;
+            bluetoothState = State.HEADSET_UNAVAILABLE;
+            updateAudioDeviceState();
+            Log.d(Config.LOGTAG, "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(Config.LOGTAG, "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(Config.LOGTAG, "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(Config.LOGTAG, "+++ Bluetooth audio SCO is now connected");
+                        bluetoothState = State.SCO_CONNECTED;
+                        scoConnectionAttempts = 0;
+                        updateAudioDeviceState();
+                    } else {
+                        Log.w(Config.LOGTAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED");
+                    }
+                } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
+                    Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connecting...");
+                } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
+                    Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now disconnected");
+                    if (isInitialStickyBroadcast()) {
+                        Log.d(Config.LOGTAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.");
+                        return;
+                    }
+                    updateAudioDeviceState();
+                }
+            }
+            Log.d(Config.LOGTAG, "onReceive done: BT state=" + bluetoothState);
+        }
+    }
+}

src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java 🔗

@@ -0,0 +1,170 @@
+/*
+ *  Copyright 2014 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 eu.siacs.conversations.services;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.webrtc.ThreadUtils;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.AppRTCUtils;
+
+/**
+ * AppRTCProximitySensor manages functions related to the proximity sensor in
+ * the AppRTC demo.
+ * On most device, the proximity sensor is implemented as a boolean-sensor.
+ * It returns just two values "NEAR" or "FAR". Thresholding is done on the LUX
+ * value i.e. the LUX value of the light sensor is compared with a threshold.
+ * A LUX-value more than the threshold means the proximity sensor returns "FAR".
+ * Anything less than the threshold value and the sensor  returns "NEAR".
+ */
+public class AppRTCProximitySensor implements SensorEventListener {
+    // This class should be created, started and stopped on one thread
+    // (e.g. the main thread). We use |nonThreadSafe| to ensure that this is
+    // the case. Only active when |DEBUG| is set to true.
+    private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker();
+    private final Runnable onSensorStateListener;
+    private final SensorManager sensorManager;
+    @Nullable
+    private Sensor proximitySensor;
+    private boolean lastStateReportIsNear;
+
+    private AppRTCProximitySensor(Context context, Runnable sensorStateListener) {
+        Log.d(Config.LOGTAG, "AppRTCProximitySensor" + AppRTCUtils.getThreadInfo());
+        onSensorStateListener = sensorStateListener;
+        sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
+    }
+
+    /**
+     * Construction
+     */
+    static AppRTCProximitySensor create(Context context, Runnable sensorStateListener) {
+        return new AppRTCProximitySensor(context, sensorStateListener);
+    }
+
+    /**
+     * Activate the proximity sensor. Also do initialization if called for the
+     * first time.
+     */
+    public boolean start() {
+        threadChecker.checkIsOnValidThread();
+        Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo());
+        if (!initDefaultSensor()) {
+            // Proximity sensor is not supported on this device.
+            return false;
+        }
+        sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
+        return true;
+    }
+
+    /**
+     * Deactivate the proximity sensor.
+     */
+    public void stop() {
+        threadChecker.checkIsOnValidThread();
+        Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo());
+        if (proximitySensor == null) {
+            return;
+        }
+        sensorManager.unregisterListener(this, proximitySensor);
+    }
+
+    /**
+     * Getter for last reported state. Set to true if "near" is reported.
+     */
+    public boolean sensorReportsNearState() {
+        threadChecker.checkIsOnValidThread();
+        return lastStateReportIsNear;
+    }
+
+    @Override
+    public final void onAccuracyChanged(Sensor sensor, int accuracy) {
+        threadChecker.checkIsOnValidThread();
+        AppRTCUtils.assertIsTrue(sensor.getType() == Sensor.TYPE_PROXIMITY);
+        if (accuracy == SensorManager.SENSOR_STATUS_UNRELIABLE) {
+            Log.e(Config.LOGTAG, "The values returned by this sensor cannot be trusted");
+        }
+    }
+
+    @Override
+    public final void onSensorChanged(SensorEvent event) {
+        threadChecker.checkIsOnValidThread();
+        AppRTCUtils.assertIsTrue(event.sensor.getType() == Sensor.TYPE_PROXIMITY);
+        // As a best practice; do as little as possible within this method and
+        // avoid blocking.
+        float distanceInCentimeters = event.values[0];
+        if (distanceInCentimeters < proximitySensor.getMaximumRange()) {
+            Log.d(Config.LOGTAG, "Proximity sensor => NEAR state");
+            lastStateReportIsNear = true;
+        } else {
+            Log.d(Config.LOGTAG, "Proximity sensor => FAR state");
+            lastStateReportIsNear = false;
+        }
+        // Report about new state to listening client. Client can then call
+        // sensorReportsNearState() to query the current state (NEAR or FAR).
+        if (onSensorStateListener != null) {
+            onSensorStateListener.run();
+        }
+        Log.d(Config.LOGTAG, "onSensorChanged" + AppRTCUtils.getThreadInfo() + ": "
+                + "accuracy=" + event.accuracy + ", timestamp=" + event.timestamp + ", distance="
+                + event.values[0]);
+    }
+
+    /**
+     * Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7)
+     * does not support this type of sensor and false will be returned in such
+     * cases.
+     */
+    private boolean initDefaultSensor() {
+        if (proximitySensor != null) {
+            return true;
+        }
+        proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
+        if (proximitySensor == null) {
+            return false;
+        }
+        logProximitySensorInfo();
+        return true;
+    }
+
+    /**
+     * Helper method for logging information about the proximity sensor.
+     */
+    private void logProximitySensorInfo() {
+        if (proximitySensor == null) {
+            return;
+        }
+        StringBuilder info = new StringBuilder("Proximity sensor: ");
+        info.append("name=").append(proximitySensor.getName());
+        info.append(", vendor: ").append(proximitySensor.getVendor());
+        info.append(", power: ").append(proximitySensor.getPower());
+        info.append(", resolution: ").append(proximitySensor.getResolution());
+        info.append(", max range: ").append(proximitySensor.getMaximumRange());
+        info.append(", min delay: ").append(proximitySensor.getMinDelay());
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
+            // Added in API level 20.
+            info.append(", type: ").append(proximitySensor.getStringType());
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            // Added in API level 21.
+            info.append(", max delay: ").append(proximitySensor.getMaxDelay());
+            info.append(", reporting mode: ").append(proximitySensor.getReportingMode());
+            info.append(", isWakeUpSensor: ").append(proximitySensor.isWakeUpSensor());
+        }
+        Log.d(Config.LOGTAG, info.toString());
+    }
+}

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -655,7 +655,7 @@ public class XmppConnectionService extends Service {
                     Log.d(Config.LOGTAG, "received intent to end call with session id " + sessionId);
                     mJingleConnectionManager.endRtpSession(sessionId);
                 }
-                    break;
+                break;
                 case ACTION_DISMISS_ERROR_NOTIFICATIONS:
                     dismissErrorNotifications();
                     break;
@@ -4017,6 +4017,12 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void notifyJingleRtpConnectionUpdate(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+        for (OnJingleRtpConnectionUpdate listener : threadSafeList(this.onJingleRtpConnectionUpdate)) {
+            listener.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
+        }
+    }
+
     public void updateAccountUi() {
         for (OnAccountUpdate listener : threadSafeList(this.mOnAccountUpdates)) {
             listener.onAccountUpdate();
@@ -4696,6 +4702,8 @@ public class XmppConnectionService extends Service {
 
     public interface OnJingleRtpConnectionUpdate {
         void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state);
+
+        void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
     }
 
     public interface OnAccountUpdate {

src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java 🔗

@@ -20,14 +20,17 @@ import com.google.common.collect.ImmutableList;
 
 import java.lang.ref.WeakReference;
 import java.util.Arrays;
+import java.util.Set;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityRtpSessionBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.services.AppRTCAudioManager;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.PermissionUtils;
+import eu.siacs.conversations.utils.ThemeHelper;
 import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
 import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
@@ -57,6 +60,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
     private ActivityRtpSessionBinding binding;
     private PowerManager.WakeLock mProximityWakeLock;
 
+    private static AppRTCAudioManager audioManager;
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -143,7 +148,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
         super.onNewIntent(intent);
         setIntent(intent);
         if (xmppConnectionService == null) {
-            Log.d(Config.LOGTAG,"RtpSessionActivity: background service wasn't bound in onNewIntent()");
+            Log.d(Config.LOGTAG, "RtpSessionActivity: background service wasn't bound in onNewIntent()");
             return;
         }
         final Account account = extractAccount(intent);
@@ -339,6 +344,16 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
             this.binding.endCall.setVisibility(View.VISIBLE);
             this.binding.acceptCall.setVisibility(View.INVISIBLE);
         }
+
+        if (state == RtpEndUserState.CONNECTED) {
+            this.binding.inCallActionLeft.setImageResource(R.drawable.ic_volume_off_black_24dp);
+            this.binding.inCallActionLeft.setVisibility(View.VISIBLE);
+            this.binding.inCallActionRight.setImageResource(R.drawable.ic_mic_black_24dp);
+            this.binding.inCallActionRight.setVisibility(View.VISIBLE);
+        } else {
+            this.binding.inCallActionLeft.setVisibility(View.GONE);
+            this.binding.inCallActionRight.setVisibility(View.GONE);
+        }
     }
 
     private void retry(View view) {
@@ -401,6 +416,11 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
         }
     }
 
+    @Override
+    public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+        Log.d(Config.LOGTAG, "onAudioDeviceChanged in activity: selected:" + selectedAudioDevice + ", available:" + availableAudioDevices);
+    }
+
     private void updateRtpSessionProposalState(final Account account, final Jid with, final RtpEndUserState state) {
         final Intent currentIntent = getIntent();
         final String withExtra = currentIntent == null ? null : currentIntent.getStringExtra(EXTRA_WITH);

src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java 🔗

@@ -0,0 +1,55 @@
+
+
+/*
+ *  Copyright 2014 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 eu.siacs.conversations.utils;
+
+import android.os.Build;
+import android.util.Log;
+
+/**
+ * AppRTCUtils provides helper functions for managing thread safety.
+ */
+public final class AppRTCUtils {
+    private AppRTCUtils() {
+    }
+
+    /**
+     * Helper method which throws an exception  when an assertion has failed.
+     */
+    public static void assertIsTrue(boolean condition) {
+        if (!condition) {
+            throw new AssertionError("Expected condition to be true");
+        }
+    }
+
+    /**
+     * Helper method for building a string of thread information.
+     */
+    public static String getThreadInfo() {
+        return "@[name=" + Thread.currentThread().getName() + ", id=" + Thread.currentThread().getId()
+                + "]";
+    }
+
+    /**
+     * Information about the current build, taken from system properties.
+     */
+    public static void logDeviceInfo(String tag) {
+        Log.d(tag, "Android SDK: " + Build.VERSION.SDK_INT + ", "
+                + "Release: " + Build.VERSION.RELEASE + ", "
+                + "Brand: " + Build.BRAND + ", "
+                + "Device: " + Build.DEVICE + ", "
+                + "Id: " + Build.ID + ", "
+                + "Hardware: " + Build.HARDWARE + ", "
+                + "Manufacturer: " + Build.MANUFACTURER + ", "
+                + "Model: " + Build.MODEL + ", "
+                + "Product: " + Build.PRODUCT);
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java 🔗

@@ -17,12 +17,14 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.RtpSessionStatus;
+import eu.siacs.conversations.services.AppRTCAudioManager;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
@@ -831,6 +833,11 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
         }
     }
 
+    @Override
+    public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+        xmppConnectionService.notifyJingleRtpConnectionUpdate(selectedAudioDevice, availableAudioDevices);
+    }
+
     private void updateEndUserState() {
         xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState());
     }

src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java 🔗

@@ -1,6 +1,8 @@
 package eu.siacs.conversations.xmpp.jingle;
 
 import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
 import android.util.Log;
 
 import com.google.common.collect.ImmutableList;
@@ -29,11 +31,13 @@ import org.webrtc.VideoSource;
 import org.webrtc.VideoTrack;
 
 import java.util.List;
+import java.util.Set;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 import eu.siacs.conversations.Config;
+import eu.siacs.conversations.services.AppRTCAudioManager;
 
 public class WebRTCWrapper {
 
@@ -119,8 +123,16 @@ public class WebRTCWrapper {
 
         }
     };
+    private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents = new AppRTCAudioManager.AudioManagerEvents() {
+        @Override
+        public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+            eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
+        }
+    };
     @Nullable
     private PeerConnection peerConnection = null;
+    private AppRTCAudioManager appRTCAudioManager = null;
+    private final Handler mainHandler = new Handler(Looper.getMainLooper());
 
     public WebRTCWrapper(final EventCallback eventCallback) {
         this.eventCallback = eventCallback;
@@ -130,6 +142,10 @@ public class WebRTCWrapper {
         PeerConnectionFactory.initialize(
                 PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
         );
+        mainHandler.post(() -> {
+            appRTCAudioManager = AppRTCAudioManager.create(context, AppRTCAudioManager.SpeakerPhonePreference.EARPIECE);
+        appRTCAudioManager.start(audioManagerEvents);
+        });
     }
 
     public void initializePeerConnection(final List<PeerConnection.IceServer> iceServers) throws InitializationException {
@@ -202,16 +218,15 @@ public class WebRTCWrapper {
         peerConnection.setAudioRecording(true);
         this.peerConnection = peerConnection;
     }
-
-    public void closeOrThrow() {
-        requirePeerConnection().close();
-    }
-
     public void close() {
         final PeerConnection peerConnection = this.peerConnection;
         if (peerConnection != null) {
             peerConnection.close();
         }
+        final AppRTCAudioManager audioManager = this.appRTCAudioManager;
+        if (audioManager != null) {
+            mainHandler.post(audioManager::stop);
+        }
     }
 
 
@@ -355,5 +370,7 @@ public class WebRTCWrapper {
         void onIceCandidate(IceCandidate iceCandidate);
 
         void onConnectionChange(PeerConnection.PeerConnectionState newState);
+
+        void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
     }
 }

src/main/res/layout/activity_rtp_session.xml 🔗

@@ -5,7 +5,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:background="?color_background_secondary">
 
         <android.support.design.widget.AppBarLayout
             android:layout_width="match_parent"
@@ -58,6 +59,20 @@
                 app:maxImageSize="36dp"
                 tools:visibility="visible" />
 
+            <android.support.design.widget.FloatingActionButton
+                android:id="@+id/in_call_action_left"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_centerVertical="true"
+                android:layout_margin="16dp"
+                android:layout_toStartOf="@+id/end_call"
+                android:layout_toLeftOf="@+id/end_call"
+                android:visibility="gone"
+                app:backgroundTint="?color_background_primary"
+                app:elevation="4dp"
+                app:fabSize="mini"
+                app:tint="?attr/icon_tint" />
+
             <android.support.design.widget.FloatingActionButton
                 android:id="@+id/end_call"
                 android:layout_width="wrap_content"
@@ -65,12 +80,27 @@
                 android:layout_centerInParent="true"
                 android:layout_margin="16dp"
                 android:src="@drawable/ic_call_end_white_48dp"
-                android:visibility="gone"
+                android:visibility="visible"
                 app:backgroundTint="@color/red700"
                 app:elevation="4dp"
                 app:fabCustomSize="72dp"
                 app:maxImageSize="36dp" />
 
+            <android.support.design.widget.FloatingActionButton
+                android:id="@+id/in_call_action_right"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_centerVertical="true"
+                android:layout_margin="16dp"
+                android:layout_toEndOf="@+id/end_call"
+                android:layout_toRightOf="@+id/end_call"
+                android:visibility="gone"
+                app:backgroundTint="?color_background_primary"
+                app:elevation="4dp"
+                app:fabSize="mini"
+                app:tint="?attr/icon_tint" />
+
+
             <android.support.design.widget.FloatingActionButton
                 android:id="@+id/accept_call"
                 android:layout_width="wrap_content"

src/main/res/values/attrs.xml 🔗

@@ -28,6 +28,7 @@
 
     <attr name="TextColorOnline" format="reference|color"/>
     <attr name="TextColorError" format="reference|color"/>
+    <attr name="icon_tint" format="reference|color"/>
 
     <attr name="ic_send_cancel_offline" format="reference"/>
     <attr name="ic_send_location_offline" format="reference"/>
@@ -45,6 +46,7 @@
     <attr name="ic_attach_record" format="reference"/>
 
 
+
     <attr name="ic_cloud_download" format="reference"/>
 
     <attr name="message_bubble_received_monochrome" format="reference"/>

src/main/res/values/themes.xml 🔗

@@ -15,6 +15,7 @@
         <item name="TextColorOnline">@color/green600</item>
         <item name="TextColorError">@color/red800</item>
         <item name="edit_text_color">@color/black87</item>
+        <item name="icon_tint">@color/black54</item>
 
         <item name="activity_background_search">@drawable/search_background_light</item>
         <item name="activity_background_no_results">@drawable/no_results_background_light</item>
@@ -136,6 +137,7 @@
         <item name="TextColorOnline">@color/green500</item>
         <item name="TextColorError">@color/red500</item>
         <item name="edit_text_color">@color/white</item>
+        <item name="icon_tint">@color/white70</item>
 
         <item name="EmojiColor">@color/white</item>
 
@@ -147,6 +149,7 @@
         <item name="TextSizeBody2">14sp</item>
         <item name="TextSizeSubhead">16sp</item>
         <item name="TextSizeTitle">20sp</item>
+        <item name="TextSizeDisplay2">45sp</item>
         <item name="TextSizeInput">16sp</item>
         <item name="TextSeparation">5sp</item>
         <item name="IconSize">18sp</item>
@@ -237,6 +240,7 @@
         <item name="TextSizeBody2">16sp</item>
         <item name="TextSizeSubhead">18sp</item>
         <item name="TextSizeTitle">22sp</item>
+        <item name="TextSizeDisplay2">47sp</item>
         <item name="TextSizeInput">18sp</item>
         <item name="TextSeparation">6sp</item>
         <item name="IconSize">20sp</item>
@@ -248,6 +252,7 @@
         <item name="TextSizeBody2">16sp</item>
         <item name="TextSizeSubhead">18sp</item>
         <item name="TextSizeTitle">22sp</item>
+        <item name="TextSizeDisplay2">47sp</item>
         <item name="TextSizeInput">18sp</item>
         <item name="TextSeparation">6sp</item>
         <item name="IconSize">20sp</item>
@@ -259,6 +264,7 @@
         <item name="TextSizeBody2">18sp</item>
         <item name="TextSizeSubhead">20sp</item>
         <item name="TextSizeTitle">24sp</item>
+        <item name="TextSizeDisplay2">48sp</item>
         <item name="TextSizeInput">20sp</item>
         <item name="TextSeparation">7sp</item>
         <item name="IconSize">22sp</item>
@@ -270,6 +276,7 @@
         <item name="TextSizeBody2">18sp</item>
         <item name="TextSizeSubhead">20sp</item>
         <item name="TextSizeTitle">24sp</item>
+        <item name="TextSizeDisplay2">48sp</item>
         <item name="TextSizeInput">20sp</item>
         <item name="TextSeparation">7sp</item>
         <item name="IconSize">22sp</item>