From b924a63d01ad2a1dea955c8434053ec41065b339 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Mon, 13 Apr 2020 12:02:34 +0200 Subject: [PATCH] copy audio manager from AppRTCDemo --- src/main/AndroidManifest.xml | 1 + .../services/AppRTCAudioManager.java | 578 ++++++++++++++++++ .../services/AppRTCBluetoothManager.java | 549 +++++++++++++++++ .../services/AppRTCProximitySensor.java | 170 ++++++ .../services/XmppConnectionService.java | 10 +- .../conversations/ui/RtpSessionActivity.java | 22 +- .../conversations/utils/AppRTCUtils.java | 55 ++ .../xmpp/jingle/JingleRtpConnection.java | 7 + .../xmpp/jingle/WebRTCWrapper.java | 27 +- .../res/drawable-hdpi/ic_mic_black_24dp.png | Bin 0 -> 344 bytes .../drawable-hdpi/ic_mic_off_black_24dp.png | Bin 0 -> 402 bytes .../ic_volume_off_black_24dp.png | Bin 0 -> 407 bytes .../drawable-hdpi/ic_volume_up_black_24dp.png | Bin 0 -> 364 bytes .../res/drawable-mdpi/ic_mic_black_24dp.png | Bin 0 -> 232 bytes .../drawable-mdpi/ic_mic_off_black_24dp.png | Bin 0 -> 271 bytes .../ic_volume_off_black_24dp.png | Bin 0 -> 279 bytes .../drawable-mdpi/ic_volume_up_black_24dp.png | Bin 0 -> 235 bytes .../res/drawable-xhdpi/ic_mic_black_24dp.png | Bin 0 -> 418 bytes .../drawable-xhdpi/ic_mic_off_black_24dp.png | Bin 0 -> 454 bytes .../ic_volume_off_black_24dp.png | Bin 0 -> 493 bytes .../ic_volume_up_black_24dp.png | Bin 0 -> 434 bytes .../res/drawable-xxhdpi/ic_mic_black_24dp.png | Bin 0 -> 581 bytes .../drawable-xxhdpi/ic_mic_off_black_24dp.png | Bin 0 -> 671 bytes .../ic_volume_off_black_24dp.png | Bin 0 -> 704 bytes .../ic_volume_up_black_24dp.png | Bin 0 -> 626 bytes .../drawable-xxxhdpi/ic_mic_black_24dp.png | Bin 0 -> 773 bytes .../ic_mic_off_black_24dp.png | Bin 0 -> 832 bytes .../ic_volume_off_black_24dp.png | Bin 0 -> 924 bytes .../ic_volume_up_black_24dp.png | Bin 0 -> 828 bytes 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, 1453 insertions(+), 9 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java create mode 100644 src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java create mode 100644 src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java create mode 100644 src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java create mode 100644 src/main/res/drawable-hdpi/ic_mic_black_24dp.png create mode 100644 src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png create mode 100644 src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png create mode 100644 src/main/res/drawable-hdpi/ic_volume_up_black_24dp.png create mode 100644 src/main/res/drawable-mdpi/ic_mic_black_24dp.png create mode 100644 src/main/res/drawable-mdpi/ic_mic_off_black_24dp.png create mode 100644 src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png create mode 100644 src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png create mode 100644 src/main/res/drawable-xhdpi/ic_mic_black_24dp.png create mode 100644 src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png create mode 100644 src/main/res/drawable-xhdpi/ic_volume_off_black_24dp.png create mode 100644 src/main/res/drawable-xhdpi/ic_volume_up_black_24dp.png create mode 100644 src/main/res/drawable-xxhdpi/ic_mic_black_24dp.png create mode 100644 src/main/res/drawable-xxhdpi/ic_mic_off_black_24dp.png create mode 100644 src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png create mode 100644 src/main/res/drawable-xxhdpi/ic_volume_up_black_24dp.png create mode 100644 src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png create mode 100644 src/main/res/drawable-xxxhdpi/ic_mic_off_black_24dp.png create mode 100644 src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png create mode 100644 src/main/res/drawable-xxxhdpi/ic_volume_up_black_24dp.png diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 9ab4073b2..3cbf0e51c 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ + diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java b/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java new file mode 100644 index 000000000..db1b8e1e1 --- /dev/null +++ b/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 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 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 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 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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java b/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java new file mode 100644 index 000000000..e5ea9be02 --- /dev/null +++ b/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 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 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 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java b/src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java new file mode 100644 index 000000000..8bdc65f2e --- /dev/null +++ b/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()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 24c80713c..543eb9fd5 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/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 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 availableAudioDevices); } public interface OnAccountUpdate { diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java index fb4de0e95..bdef662bd 100644 --- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +++ b/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 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); diff --git a/src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java b/src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java new file mode 100644 index 000000000..1b6e43f75 --- /dev/null +++ b/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); + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index 3cfb2b15c..d3dba70a9 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/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 availableAudioDevices) { + xmppConnectionService.notifyJingleRtpConnectionUpdate(selectedAudioDevice, availableAudioDevices); + } + private void updateEndUserState() { xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState()); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java index 8eb46fe1e..0b776e431 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java +++ b/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 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 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 availableAudioDevices); } } diff --git a/src/main/res/drawable-hdpi/ic_mic_black_24dp.png b/src/main/res/drawable-hdpi/ic_mic_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..74218e1a654239fed6b687d62cd6af594c5cf71d GIT binary patch literal 344 zcmV-e0jK_nP)El6=!u!2@xw3|4KiT_1kLfjMw!ATHt%!^dxF41^} z8gpD6E*DJ9ji6}0-+sp9|Fz|{PbzPmHHXVa@}rSGEv*`SBTTIoomj#i2^ zy8pX|y8rG_=dT;O_Xmcy6~z+hN~61ASy618=#GeWa6=kh1*b*aHqiwU7r{v%rPc>0 zz!ed@COWPXF93V>8*vHnQVcDd;;|UptR36X&?}pOb0Qv@YF#DXIt7~6yceIXG2MZP zLuWK?>5I7O7d;g5%`@jMfn#<(Hx}{03=Vt|5nsL2Hxdz*TV}N3i6g0X_6vd8(xqSzZwB?CPi)9u70000`j^ literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1755dbf3fab08d39326fa7a5963a9752a2a70aa2 GIT binary patch literal 402 zcmV;D0d4+?P)VX4gfi6} zj9m3m2%SyJYy1#GIPldM2SSJ%>Wr6uiQayC;Xw_>NjJO~!UyY4fbBYpR=)eYnW}|k wsvhEwPUuW3p+r5gptxnN*0LOz%bEYd9};=|!lODW)&Kwi07*qoM6N<$f_Q1S=>Px# literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f1326ba7c66527698d531e576b8c52cc391189ba GIT binary patch literal 407 zcmV;I0cie-P);#*5)f=HEFu<48e1*I2N0h? zL`cw#Fmp*}vunniI%leI;UhbDNtmW8B4%8<|1#(FDe-qb+7yWh6w}9KQb)LqJx}Bb zR2Hg?Uju{dN2qW1_0%zD04>tsL$D@+DhX%z_|8YoHKCT{6K2l>=)0y0oDkMcHnL$G=Z%A$5aN0CX*0q7{QDbP~GHh9BKPPx`-(iM6_$|VFn zE+>6JA9`fd`(SA8EEWNd@XsA`1=9f1^7b^bxnRzA1Z zVG5)i_pndFov5ybeUJ}~f+V!mTMaoPmrQ`Gs`J7?+@USeR%a(}j0|(9{U?%`!8XQ? zzrW>}QM<&W$8UxY|3%J874k_nkKfTdWn%cquTvyalLKby3-k@)_AuR&OJC9e0000< KMNUMnLSTYh!I?_{ literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-mdpi/ic_mic_black_24dp.png b/src/main/res/drawable-mdpi/ic_mic_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..19a16138fe6e7d8b474b947b28c73307a8fce199 GIT binary patch literal 232 zcmVP)H;?C}1_!@?CvWq1VFURB+Cu!3L>r6hhJe(6cYIJ2%uL;t)9p{%BV39DvuU|=GIiGgFQMv{d9^xKgHN#?CUlDSKNwI4Rh VaA?C^Ro?&r002ovPDHLkV1oGvbIbq$ literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a629cfff539acfd740f2f453b31ab595f3aca279 GIT binary patch literal 279 zcmV+y0qFjTP)VA~UEv`kM<9e}OA1x`895eU-<$BM{Ws~WOz9-RtP5diu}lVu zGm3I0|&xc5oDx=D`6cg!s}R&$h2#Fm=V6}KqA%> z_^t!JL||2TsRLC;c7;>L4kbWFnmRTaPmBy31VVVUtS8*6&xs9C d>1vzQegTRRkWq31_4xn*002ovPDHLkV1fXya%KPk literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png b/src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..4ed27786a14065d9c6593b522ed2b0f99e580138 GIT binary patch literal 235 zcmVSZ-ns}-Ox1blNm2bV#qBV-0>fz>GiJ>};*0p%m)yCHw led{*W=WWm_b7I@y=L_YThWS09+57+i002ovPDHLkV1jr0XO#c| literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xhdpi/ic_mic_black_24dp.png b/src/main/res/drawable-xhdpi/ic_mic_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cac51c37a38c0ae7800b2f93c2d567508b417c52 GIT binary patch literal 418 zcmV;T0bTxyP)_%aKH zx>y7uq*Z&n1RjKgB=rdx$o1RL4QKfqBNGiPI(mBAZfU6586QN|drdWJsGhGTrqpId z3f)wjjudLCO-~AS)h6XeJ+MFa1HVQ+@N?`3evEoxZ|nzlM?KK{j|cWdTm#UPLOTGK zhyAi`5qAN!q|h?}_e5+N*i#XY0o;&6ivZR{tQpuH5naatQ{ENM%^|?7T@kAWc1}dx z1~BKXsA@Y6U{yq%HH38$-^>A+vTRdV*M=pB0nGa@;)$VLurK1JX@4_gQ^daWhV(#0 ztUHOaS$rWEjxowx#;HV{U#GiFsH?2lZ3({{v(@3NrCIgd!8cj_626bPT(G584*Z=?k M07*qoM6N<$f)`D-EC2ui literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..fa741be1c0e71d611d0b63c8f7d3c210be2eb581 GIT binary patch literal 454 zcmV;%0XhDOP)!_7sd*(?%MX;je7rHyoQ zC?WpA*X8gV?4jfr&l!Y#zWe(mkZ{cVk6N^D%f!UMeG3YA-bbOC_c~^3MNc>L>6`P- zh8AWy|5?~MK&*?**0ioCM26VP0)4IP+Q|}=EHI`N(HGe%MJ(kBY7=S`Y7=S`Y7=S` zY7=S`N)f;Fgkt>?Sz_jX2;sJreUTwfNVzM7pQdgkgvV0eh#b-ML_pWJ(M+~?Zv_iWEOxu4~nCVC2kW?K-ZMczY{xH;tl}?QhuYvTMiRhzks+aQ|&ZU)*11ULmr~T zI~35)v&e`ybgB@2nH?0uwvQ(naUbG|RRICEJv_~bD}Exc{d~$UZHwrVTmtDe3EGA1 zP7T3BXn`LL&>qOiSCkY=*2)m!FQKzDY~0K33PP(o-9 zg8xlycbTt*dI{|;k7ZUXA@P!+i%ormwDs|OoMTlNVSx_}%pXXKGX-97gWX6%Ea*~8 jV@_a8Na@nlRC9j-C(7lD#HSVVB&sm*<6cg}E*jxzHS6Z3~`GLBaah6ZaSQDx5+*wvz>8E?V`vdW~j z;$1up7q=8O-tZOu zw5{7F87MGbP+{wAR&Z8 zrdbq3QQ?elV2TvO=8w&3_i&DvnZt1JoLlgGe!F||@MZAA;UA}z$}q(e>ollSVUbBv zm7|2gU6Cv)Im<5$(QT0@1-WRBm6Rp03?)p5trFN2#d7Sz-r&l(!!SzUwDy$@-S|EfBIt?mul#5S7L|JDyV=oq$& zH|P_#6Y{)DEFa~di`W*uK?Q7=O8W!Y+*v%x5~97cAOn7+tSPjKbiByDmUS3%x=Cbrww2YVP-meokk8^&-Q zp($@~5!-$?RCIN6P=Z0J1;I^3 z97|h7&_NMn7XK7l6;WH7qNk|?cD=RDI<>;eAjRtkz zafkhQZ7Y)~_1(&3T5i}1jNgq(g!6hk=nS^~(GZ6{bq%H%Aw2|;b z`se^;Aoa&2En>z0BH@p;<*y{tM#4AglNscfDs&*1=7G)r9CfEjBkG5a*LE8xS1{Ip7Ajx*dNtx*qyVYLo%i z5o|rDct{iFhBTu5FDW?Ile(q;$BQnR#sM4)^CUa^M@3H-y{G4002ovPDHLk FV1mZ`Cw>3` literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2d57c8674a901fd8e6717b910423fa4ac9a4f1dd GIT binary patch literal 704 zcmV;x0zdtUP)dRN-E8J&OtIg8IWx-#vU|IPJiGGp^77^|OqrS1 z*?i6jhU$z-hL2of3x#HKj04I1o0lA5F3t^OFd0dMXh!BOdvL6WYDUs2Obnzm%F5=F zdKz&WfDGU03q=~j#6Z6=33HZ$8)?#lbQ+TYYGXeS6G+!7`XnjG5N|tUG13k2M_S5< zx*#ZWgdY*o8K0!JLQ1QX@d&98rz%QfGD2E2O-gSG_ajf-YQLoS4M4CULb?k+N$r10 z^;^s#K=VnJx}P7H zh_(|Wvkl9P?Ie@r3df1|NKRjM2U(J)S8K6*X+X|?rsxi`BFX9#3TSQUAAN&6$d2?* zR?C1kn*B*L&ZKrB-2t>AId`q;AR+apkgCi_>()FZPkEWDolnB21APuB3+~+1|dz<(hkJJ#UM2_#8f0$B?hS$5mj@HdgurmiK495EfdRQVaQB6afivTMZb z3uf%$FOh$$yk9s=%riCPmc7st1BBGnj4xEIg@hMmXsRr#(SCa&;UFP(z0y2{?h|rR zjotPTYO)&5klsQ(goNE_dez9Rk5CQGIyLguN9a77F8w*nCiD}4^vMR>{;3;2%YIec zW&glK=pG@L^!Dl@G(^ZlHFm;YXqpVo+F*0tW-YYH4nj_<8K3QsA7jMmL+qsW5%wxE z2Z9-cmWTW%r-|w0jmp2ZyaqjDJFScd`QvsM#eB8~ZL9_>e6P|Qm<`gSzRiYe)K8o8 zZO3fnNsyeU$FpJ8Pa{em@@wgJFwR1xbHT6axt~`^R5yLv!rjzzWP}@*ooY=pN9Gq3 zWqMkeXShs{FH>wP&-2s=hH*s;z2*p=;EyoJb#_u3wvb395{duCZ*dvEM$7kCH~;_u M07*qoM6N<$f=QhsL;wH) literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cf70b63bebaf755b4c6697fb7959f07a7484c8e0 GIT binary patch literal 773 zcmV+g1N!`lP)7{~GVYQO#gO-0?6g48-hbO;X}5)z3dq92lhL6L(!!}hr){xg<4?* zc1cOQrc`$6Vo2Fx(Mr)mx6;*h>ge}7vpVDK?01Cc^&IzMzRzco-I-@a2qAX>I@+jBjGB*9HoR( zHK488@c<0YrQ*cb}unWq!r$pUJq_$^a$ku6YV@5#7$9x7TgT0Y}kA zNqM-L&O>)X>9|kEomURHhuss`0ot)^S31s-aXXX)#)zORVb0a~GvuSoA+Gd93BVWS zh(p-byV}XsVOOj4j1q|3${{as(!}x_OWg}N=~cRJ5{L?InEg08#cbN~e{l>aRa)P! z?EH{7NDfYl>0-%YnfQo8oaFLe>3m6Gs$KuzLn$rv5oh6l#p&Y;#jby#JxG8CwDpw# z!>UOQ~;E`WXL*cC{M>ZWHfppDi`4#}V7-)eyO4 zL5xY;m(v}#;>9-Z+P*QqQ$m(hF^{FyC+cwPIHL}mq61`6twRKhx74$l)oh`GVaG*R z4Ov!8BF(MZ15Q$au87k-s% z6rqW^bq_zwB_bGC#AB8{51*owLPDdGE)ScbBps9!I{7s7Va*4cVJ?$Lh!xUE8$-6y zhiIdb0yYdiKn6(w2_OL^fCP{L5%!Xga|nR(nD$XsI1Bs0T3aKFFx49^EHb73?!H8nLg4WS9I zTd-u!nnmYK*{zs%`%p*ptmBM370?kYVuLMvO3iykY_j7<+>2(kPwi+A}F1pbb`_eN+&3tpbWyp z8xoXGP&z^B1f>&{PEa~QiG%|Zfzk=;M!4Q5p>`!eCZXPd48q7;)lU0SwQE8?VZUk@ zL|VtdyDHKys`k6+i%{*NNV{f`Rla^j^`KA1wg@f%inQMhyz@eualIWE+ain$X}&P< zP6}xr*V|6*Z4nL%Y2G&QjtXhsRPBAS4Z>SOnr95WJwlpaRom(BEfZS)5YpUjz*T=| z9#ZX)?iL9LgfzbxO4CA`PgLWW*b?DEt0L_UL+Ku|ZuhFjVauYr{Qp#S?J=Ylgfw3p zQ;l6t>uiJYnV~f)*6o;$nlNR-Wo;cxF7!p%FE#Af*_!)pRtsZJWfML&)b{nfuKu)F zPWgl}LvC8E+v30pL+)ljh;_SavMj<~u84JOn|4b{ga_OZJ^R_CB@rI05`=F&>84E{ zz(ZE+LRb~mxnj-}cIfR^2hICS2vHBheJ+a)x8y4yIc45yU%DjHYDC!SyvSc231eRN zPuT>qw{oN+VPNMG*1<`Giscw9M6sPyv)ir~t|#bUjCLfJEqemNEgf%>Li`O-)TrO-)TrP5%M7+Xys|z?EVE0000< KMNUMnLSTX;#)AU@ literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6d9b355842a2debb4e9f7da09d1a8852d4cfa70c GIT binary patch literal 924 zcmV;N17rM&P)*v`}dgEy{e^6cn`R#a@UYq6l(Eds(#% zj8G&9T1g@;5|f~1X)hvyNoJ$tX%mspIWzMxcf8LXIN!TH_u=^n^ZRiwu{y-EoZEVPht$Y;!QCCl|)D+&M3tMUbp(v{Wvtt7brJ-a+hZwBk9Ak6y! z@f0~k#QI^t1k3zglW@v}J>fN+1c>^0iG9L^Gaj=f$n_nyx{jAQ3KGtlv?C02 ztONku%4(2s!iXK=VL@or3@3rfzcbu=!lIHec7f2iyZJdV`5U^jA(R^sG~LGcfywWJ z9pRz>2)b_Jhro~dzB~}L4Y23U-KJZ|Xih)&$u$ zqttH2oBBN15@ZfXiE9Wqz@K8~uN3VG65!b2X2QMb%^Zg;35z5OfMbW72-U-ZnD`Y< z<+P8UAm(n+O{f>V{}1{z0O$x}Zi!HRRK&dL3;;@koI6ISPsORV3;;@koV!B-zj1Nu z4+T5A+$_C(AQV@pldr!=H+W?>@%;NXBn6hjD zP!m4EZ$y6dG5{zE;pGO!gr5umYQh12H^@uA0YFWt;MXPIt{4E+gfsjK^6Rw$Kuy@i zuUEWzYyeObZpUv(y!VR%Ku=KoGrDC0NI`f?yt8TtSZIn+mS5@Y07+AX9dZ5%I{-}( zu6n2MG6Xb1nDZ_)9fp7=2m49bOnK&|2>UwADc zB7%&h0!xvgXc1wlIE6);Gq2sCO>pn|;t;}|3->wOb1t6;<~uWd?;+qnfj}S-2n0ML zj_1tqFP)U)E#`MEaG$*-{S^399QTDtQ{? z%K~z}qEcIYSs=Qm*{LnQE6_|I^N|V}T6D!XNECB{smP~czoz(xC<^G}o2c-URz2|z z(ZD9|iVE}8X^C%Gj1GPXQ(vgk5#O*l+xRHVy&$O}z9AdRc`Zy{*AU;39VNUG9_HHI z$2Vk48RO3ED2ZIhH)PLt=A7wMxsLA&#E7QdnVzB`xA9$pQ5uM*-u6w2J{My5yPd)>iSAdtAL>emqIdD*H*m(Pmp+Obsg3#V5r39 zhShUTvp^><2dthx%>uV@sk3@s>J^Z~RaiYAGz(1QvfAqTtXUv~OVa8|6G;#VAE9C2 zAL5(1)cf=`+JVbKpMJ>7ap|#oy7dY?Mqr?mWd%4>~ z9x~<3TJxVs=z6_ALOsz8IP+(Gy5KnKOiz&S&lOXRaASPJpG&?9CWHl?xG&eN8+j+J zeN_8$(Otz`VY1toRf8HPg}G-W{8;ulNm`gnQR&0F&1N28gqovP|H4xtSD201*=p3j za#qS^QjuG8t-6=~_R&uo6H)W*_Id4cl3Je^U!POv@9OdYTq5D`^7k;+{;uCo(nbs~ za0T9ToO}TP1Ty?bBXK+=#cd8zgtx?4A_9RxAP@)y0>1!Yns3wvLsGc_0000 + android:layout_height="match_parent" + android:background="?color_background_secondary"> + + + + + + @@ -45,6 +46,7 @@ + diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index 054194fbe..e07c3bf67 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -15,6 +15,7 @@ @color/green600 @color/red800 @color/black87 + @color/black54 @drawable/search_background_light @drawable/no_results_background_light @@ -136,6 +137,7 @@ @color/green500 @color/red500 @color/white + @color/white70 @color/white @@ -147,6 +149,7 @@ 14sp 16sp 20sp + 45sp 16sp 5sp 18sp @@ -237,6 +240,7 @@ 16sp 18sp 22sp + 47sp 18sp 6sp 20sp @@ -248,6 +252,7 @@ 16sp 18sp 22sp + 47sp 18sp 6sp 20sp @@ -259,6 +264,7 @@ 18sp 20sp 24sp + 48sp 20sp 7sp 22sp @@ -270,6 +276,7 @@ 18sp 20sp 24sp + 48sp 20sp 7sp 22sp