Browse Source

Add support for audio

Signed-off-by: Mario Danic <mario@lovelyhq.com>
Mario Danic 7 years ago
parent
commit
8eee14d32f

+ 25 - 0
app/src/main/java/com/nextcloud/talk/activities/CallActivity.java

@@ -51,6 +51,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication;
 import com.nextcloud.talk.events.MediaStreamEvent;
 import com.nextcloud.talk.events.MediaStreamEvent;
 import com.nextcloud.talk.events.SessionDescriptionSendEvent;
 import com.nextcloud.talk.events.SessionDescriptionSendEvent;
 import com.nextcloud.talk.persistence.entities.UserEntity;
 import com.nextcloud.talk.persistence.entities.UserEntity;
+import com.nextcloud.talk.webrtc.MagicAudioManager;
 import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
 import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
 
 
 import org.apache.commons.lang3.StringEscapeUtils;
 import org.apache.commons.lang3.StringEscapeUtils;
@@ -81,6 +82,7 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeUnit;
 
 
 import javax.inject.Inject;
 import javax.inject.Inject;
@@ -115,6 +117,7 @@ public class CallActivity extends AppCompatActivity {
     MediaConstraints audioConstraints;
     MediaConstraints audioConstraints;
     MediaConstraints videoConstraints;
     MediaConstraints videoConstraints;
     MediaConstraints sdpConstraints;
     MediaConstraints sdpConstraints;
+    MagicAudioManager audioManager;
     VideoSource videoSource;
     VideoSource videoSource;
     VideoTrack localVideoTrack;
     VideoTrack localVideoTrack;
     AudioSource audioSource;
     AudioSource audioSource;
@@ -262,6 +265,20 @@ public class CallActivity extends AppCompatActivity {
         localMediaStream.addTrack(localAudioTrack);
         localMediaStream.addTrack(localAudioTrack);
         localMediaStream.addTrack(localVideoTrack);
         localMediaStream.addTrack(localVideoTrack);
 
 
+        // Create and audio manager that will take care of audio routing,
+        // audio modes, audio device enumeration etc.
+        audioManager = MagicAudioManager.create(getApplicationContext());
+        // Store existing audio settings and change audio mode to
+        // MODE_IN_COMMUNICATION for best possible VoIP performance.
+        Log.d(TAG, "Starting the audio manager...");
+        audioManager.start(new MagicAudioManager.AudioManagerEvents() {
+            @Override
+            public void onAudioDeviceChanged(MagicAudioManager.AudioDevice selectedAudioDevice,
+                                             Set<MagicAudioManager.AudioDevice> availableAudioDevices) {
+                onAudioManagerDevicesChanged(selectedAudioDevice, availableAudioDevices);
+            }
+        });
+
         Resources r = getResources();
         Resources r = getResources();
         int px = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 120, r.getDisplayMetrics());
         int px = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 120, r.getDisplayMetrics());
         videoCapturerAndroid.startCapture(px, px, 30);
         videoCapturerAndroid.startCapture(px, px, 30);
@@ -460,6 +477,14 @@ public class CallActivity extends AppCompatActivity {
         }
         }
     }
     }
 
 
+    // This method is called when the audio manager reports audio device change,
+    // e.g. from wired headset to speakerphone.
+    private void onAudioManagerDevicesChanged(
+            final MagicAudioManager.AudioDevice device, final Set<MagicAudioManager.AudioDevice> availableDevices) {
+        Log.d(TAG, "onAudioManagerDevicesChanged: " + availableDevices + ", "
+                + "selected: " + device);
+    }
+
     private void processUsersInRoom(List<HashMap<String, String>> users) {
     private void processUsersInRoom(List<HashMap<String, String>> users) {
         List<String> newSessions = new ArrayList<>();
         List<String> newSessions = new ArrayList<>();
         List<String> oldSesssions = new ArrayList<>();
         List<String> oldSesssions = new ArrayList<>();

+ 593 - 0
app/src/main/java/com/nextcloud/talk/webrtc/MagicAudioManager.java

@@ -0,0 +1,593 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.webrtc;
+
+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.util.Log;
+
+import org.webrtc.ThreadUtils;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * MagicAudioManager manages all audio related parts of the AppRTC demo.
+ */
+public class MagicAudioManager {
+  private static final String TAG = "MagicAudioManager";
+  private static final String SPEAKERPHONE_AUTO = "auto";
+  private static final String SPEAKERPHONE_TRUE = "true";
+  private static final String SPEAKERPHONE_FALSE = "false";
+
+  /**
+   * 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,
+  }
+
+  /** 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 magicContext;
+  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;
+
+  // 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;
+
+  // 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).
+  private MagicProximitySensor proximitySensor = null;
+
+  // Handles all tasks related to Bluetooth headset devices.
+  private final MagicBluetoothManager bluetoothManager;
+
+  // Contains a list of available audio devices. A Set collection is used to
+  // avoid duplicate elements.
+  private Set<AudioDevice> audioDevices = new HashSet<AudioDevice>();
+
+  // Broadcast receiver for wired headset intent broadcasts.
+  private BroadcastReceiver wiredHeadsetReceiver;
+
+  // 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".
+   */
+  private void onProximitySensorChangedState() {
+    if (!useSpeakerphone.equals(SPEAKERPHONE_AUTO)) {
+      return;
+    }
+
+    // The proximity sensor should only be activated when there are exactly two
+    // available audio devices.
+    if (audioDevices.size() == 2 && audioDevices.contains(MagicAudioManager.AudioDevice.EARPIECE)
+        && audioDevices.contains(MagicAudioManager.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(MagicAudioManager.AudioDevice.EARPIECE);
+      } else {
+        // Sensor reports that a "handset is removed from a person's ear", or
+        // "the light sensor is no longer covered".
+        setAudioDeviceInternal(MagicAudioManager.AudioDevice.SPEAKER_PHONE);
+      }
+    }
+  }
+
+  /* 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");
+      hasWiredHeadset = (state == STATE_PLUGGED);
+      updateAudioDeviceState();
+    }
+  };
+
+  /** Construction. */
+  public static MagicAudioManager create(Context context) {
+    return new MagicAudioManager(context);
+  }
+
+  private MagicAudioManager(Context context) {
+    Log.d(TAG, "ctor");
+    ThreadUtils.checkIsOnMainThread();
+    magicContext = context;
+    audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
+    bluetoothManager = MagicBluetoothManager.create(context, this);
+    wiredHeadsetReceiver = new WiredHeadsetReceiver();
+    amState = AudioManagerState.UNINITIALIZED;
+
+    useSpeakerphone = SPEAKERPHONE_AUTO;
+    if (useSpeakerphone.equals(SPEAKERPHONE_FALSE)) {
+      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 = MagicProximitySensor.create(context, new Runnable() {
+      // 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.
+      public void run() {
+        onProximitySensorChangedState();
+      }
+    });
+
+    Log.d(TAG, "defaultAudioDevice: " + defaultAudioDevice);
+  }
+
+  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.
+
+    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() {
+      // 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) {
+        String typeOfChange = "AUDIOFOCUS_NOT_DEFINED";
+        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(TAG, "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(TAG, "Audio focus request granted for VOICE_CALL streams");
+    } else {
+      Log.e(TAG, "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(TAG, "AudioManager started");
+  }
+
+  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;
+
+    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(TAG, "Abandoned audio focus for VOICE_CALL streams");
+
+    if (proximitySensor != null) {
+      proximitySensor.stop();
+      proximitySensor = null;
+    }
+
+    audioManagerEvents = null;
+    Log.d(TAG, "AudioManager stopped");
+  }
+
+  /** Changes selection of the currently active audio device. */
+  private void setAudioDeviceInternal(AudioDevice device) {
+    Log.d(TAG, "setAudioDeviceInternal(device=" + device + ")");
+
+    if (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(TAG, "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(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;
+  }
+
+  /** Helper method for receiver registration. */
+  private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+    magicContext.registerReceiver(receiver, filter);
+  }
+
+  /** Helper method for unregistration of an existing receiver. */
+  private void unregisterReceiver(BroadcastReceiver receiver) {
+    magicContext.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 magicContext.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(TAG, "hasWiredHeadset: found wired headset");
+          return true;
+        } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
+          Log.d(TAG, "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(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() == MagicBluetoothManager.State.HEADSET_AVAILABLE
+        || bluetoothManager.getState() == MagicBluetoothManager.State.HEADSET_UNAVAILABLE
+        || bluetoothManager.getState() == MagicBluetoothManager.State.SCO_DISCONNECTING) {
+      bluetoothManager.updateDevice();
+    }
+
+    // Update the set of available audio devices.
+    Set<AudioDevice> newAudioDevices = new HashSet<>();
+
+    if (bluetoothManager.getState() == MagicBluetoothManager.State.SCO_CONNECTED
+        || bluetoothManager.getState() == MagicBluetoothManager.State.SCO_CONNECTING
+        || bluetoothManager.getState() == MagicBluetoothManager.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() == MagicBluetoothManager.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() == MagicBluetoothManager.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() == MagicBluetoothManager.State.SCO_CONNECTED
+            || bluetoothManager.getState() == MagicBluetoothManager.State.SCO_CONNECTING)
+        && (userSelectedAudioDevice != AudioDevice.NONE
+               && userSelectedAudioDevice != AudioDevice.BLUETOOTH);
+
+    if (bluetoothManager.getState() == MagicBluetoothManager.State.HEADSET_AVAILABLE
+        || bluetoothManager.getState() == MagicBluetoothManager.State.SCO_CONNECTING
+        || bluetoothManager.getState() == MagicBluetoothManager.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() == MagicBluetoothManager.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(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);
+      }
+    }
+    Log.d(TAG, "--- updateAudioDeviceState done");
+  }
+}

+ 543 - 0
app/src/main/java/com/nextcloud/talk/webrtc/MagicBluetoothManager.java

@@ -0,0 +1,543 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Original code:
+ *
+ *
+ * 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 com.nextcloud.talk.webrtc;
+
+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.util.Log;
+
+import org.webrtc.ThreadUtils;
+
+import java.util.List;
+import java.util.Set;
+
+public class MagicBluetoothManager {
+  private static final String TAG = "MagicBluetoothManager";
+
+  // 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 MagicAudioManager 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 MagicBluetoothManager create(Context context, MagicAudioManager audioManager) {
+    return new MagicBluetoothManager(context, audioManager);
+  }
+
+  protected MagicBluetoothManager(Context context, MagicAudioManager 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 MagicAudioManager 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();
+    Log.d(TAG, "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(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();
+    audioManager.setBluetoothScoOn(true);
+    scoConnectionAttempts++;
+    startTimer();
+    Log.d(TAG, "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(TAG, "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(TAG, "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(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 in 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. */
+  @SuppressLint("HardwareIds")
+  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";
+    }
+  }
+}

+ 179 - 0
app/src/main/java/com/nextcloud/talk/webrtc/MagicProximitySensor.java

@@ -0,0 +1,179 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Original code:
+ *
+ *
+ * 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 com.nextcloud.talk.webrtc;
+
+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.util.Log;
+
+import org.webrtc.ThreadUtils;
+
+/**
+ * MagicProximitySensor 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 MagicProximitySensor implements SensorEventListener {
+  private static final String TAG = "MagicProximitySensor";
+
+  // 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;
+  private Sensor proximitySensor = null;
+  private boolean lastStateReportIsNear = false;
+
+  /** Construction */
+  static MagicProximitySensor create(Context context, Runnable sensorStateListener) {
+    return new MagicProximitySensor(context, sensorStateListener);
+  }
+
+  private MagicProximitySensor(Context context, Runnable sensorStateListener) {
+    onSensorStateListener = sensorStateListener;
+    sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
+  }
+
+  /**
+   * Activate the proximity sensor. Also do initialization if called for the
+   * first time.
+   */
+  public boolean start() {
+    threadChecker.checkIsOnValidThread();
+    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();
+    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();
+    if (sensor.getType() == Sensor.TYPE_PROXIMITY) {
+        if (accuracy == SensorManager.SENSOR_STATUS_UNRELIABLE) {
+            Log.e(TAG, "The values returned by this sensor cannot be trusted");
+        }
+    }
+  }
+
+  @Override
+  public final void onSensorChanged(SensorEvent event) {
+    threadChecker.checkIsOnValidThread();
+    if (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(TAG, "Proximity sensor => NEAR state");
+            lastStateReportIsNear = true;
+        } else {
+            Log.d(TAG, "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();
+        }
+    }
+  }
+
+  /**
+   * 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(TAG, info.toString());
+  }
+}