copy audio manager from AppRTCDemo

This commit is contained in:
Daniel Gultsch 2020-04-13 12:02:34 +02:00
parent 5b98107e9a
commit b924a63d01
32 changed files with 1453 additions and 9 deletions

View file

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

View file

@ -0,0 +1,578 @@
/*
* Copyright 2014 The WebRTC Project Authors. All rights reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package eu.siacs.conversations.services;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.Build;
import android.support.annotation.Nullable;
import android.util.Log;
import org.webrtc.ThreadUtils;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
/**
* AppRTCAudioManager manages all audio related parts of the AppRTC demo.
*/
public class AppRTCAudioManager {
private final Context apprtcContext;
// Contains speakerphone setting: auto, true or false
@Nullable
private final SpeakerPhonePreference speakerPhonePreference;
// Handles all tasks related to Bluetooth headset devices.
private final AppRTCBluetoothManager bluetoothManager;
@Nullable
private AudioManager audioManager;
@Nullable
private AudioManagerEvents audioManagerEvents;
private AudioManagerState amState;
private int savedAudioMode = AudioManager.MODE_INVALID;
private boolean savedIsSpeakerPhoneOn;
private boolean savedIsMicrophoneMute;
private boolean hasWiredHeadset;
// Default audio device; speaker phone for video calls or earpiece for audio
// only calls.
private AudioDevice defaultAudioDevice;
// Contains the currently selected audio device.
// This device is changed automatically using a certain scheme where e.g.
// a wired headset "wins" over speaker phone. It is also possible for a
// user to explicitly select a device (and overrid any predefined scheme).
// See |userSelectedAudioDevice| for details.
private AudioDevice selectedAudioDevice;
// Contains the user-selected audio device which overrides the predefined
// selection scheme.
// TODO(henrika): always set to AudioDevice.NONE today. Add support for
// explicit selection based on choice by userSelectedAudioDevice.
private AudioDevice userSelectedAudioDevice;
// Proximity sensor object. It measures the proximity of an object in cm
// relative to the view screen of a device and can therefore be used to
// assist device switching (close to ear <=> use headset earpiece if
// available, far from ear <=> use speaker phone).
@Nullable
private AppRTCProximitySensor proximitySensor;
// Contains a list of available audio devices. A Set collection is used to
// avoid duplicate elements.
private Set<AudioDevice> audioDevices = new HashSet<>();
// Broadcast receiver for wired headset intent broadcasts.
private BroadcastReceiver wiredHeadsetReceiver;
// Callback method for changes in audio focus.
@Nullable
private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
private AppRTCAudioManager(Context context, final SpeakerPhonePreference speakerPhonePreference) {
Log.d(Config.LOGTAG, "ctor");
ThreadUtils.checkIsOnMainThread();
apprtcContext = context;
audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
bluetoothManager = AppRTCBluetoothManager.create(context, this);
wiredHeadsetReceiver = new WiredHeadsetReceiver();
amState = AudioManagerState.UNINITIALIZED;
Log.d(Config.LOGTAG, "speaker phone preference: " + speakerPhonePreference);
this.speakerPhonePreference = speakerPhonePreference;
if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE) {
defaultAudioDevice = AudioDevice.EARPIECE;
} else {
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
}
// Create and initialize the proximity sensor.
// Tablet devices (e.g. Nexus 7) does not support proximity sensors.
// Note that, the sensor will not be active until start() has been called.
proximitySensor = AppRTCProximitySensor.create(context,
// This method will be called each time a state change is detected.
// Example: user holds his hand over the device (closer than ~5 cm),
// or removes his hand from the device.
this::onProximitySensorChangedState);
Log.d(Config.LOGTAG, "defaultAudioDevice: " + defaultAudioDevice);
AppRTCUtils.logDeviceInfo(Config.LOGTAG);
}
/**
* Construction.
*/
public static AppRTCAudioManager create(Context context, SpeakerPhonePreference speakerPhonePreference) {
return new AppRTCAudioManager(context, speakerPhonePreference);
}
/**
* This method is called when the proximity sensor reports a state change,
* e.g. from "NEAR to FAR" or from "FAR to NEAR".
*/
private void onProximitySensorChangedState() {
if (speakerPhonePreference != SpeakerPhonePreference.AUTO) {
return;
}
// The proximity sensor should only be activated when there are exactly two
// available audio devices.
if (audioDevices.size() == 2 && audioDevices.contains(AppRTCAudioManager.AudioDevice.EARPIECE)
&& audioDevices.contains(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE)) {
if (proximitySensor.sensorReportsNearState()) {
// Sensor reports that a "handset is being held up to a person's ear",
// or "something is covering the light sensor".
setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.EARPIECE);
} else {
// Sensor reports that a "handset is removed from a person's ear", or
// "the light sensor is no longer covered".
setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
}
}
}
@SuppressWarnings("deprecation")
// TODO(henrika): audioManager.requestAudioFocus() is deprecated.
public void start(AudioManagerEvents audioManagerEvents) {
Log.d(Config.LOGTAG, "start");
ThreadUtils.checkIsOnMainThread();
if (amState == AudioManagerState.RUNNING) {
Log.e(Config.LOGTAG, "AudioManager is already active");
return;
}
// TODO(henrika): perhaps call new method called preInitAudio() here if UNINITIALIZED.
Log.d(Config.LOGTAG, "AudioManager starts...");
this.audioManagerEvents = audioManagerEvents;
amState = AudioManagerState.RUNNING;
// Store current audio state so we can restore it when stop() is called.
savedAudioMode = audioManager.getMode();
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn();
savedIsMicrophoneMute = audioManager.isMicrophoneMute();
hasWiredHeadset = hasWiredHeadset();
// Create an AudioManager.OnAudioFocusChangeListener instance.
audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
// Called on the listener to notify if the audio focus for this listener has been changed.
// The |focusChange| value indicates whether the focus was gained, whether the focus was lost,
// and whether that loss is transient, or whether the new focus holder will hold it for an
// unknown amount of time.
// TODO(henrika): possibly extend support of handling audio-focus changes. Only contains
// logging for now.
@Override
public void onAudioFocusChange(int focusChange) {
final String typeOfChange;
switch (focusChange) {
case AudioManager.AUDIOFOCUS_GAIN:
typeOfChange = "AUDIOFOCUS_GAIN";
break;
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT";
break;
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE:
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE";
break;
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK";
break;
case AudioManager.AUDIOFOCUS_LOSS:
typeOfChange = "AUDIOFOCUS_LOSS";
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT";
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK";
break;
default:
typeOfChange = "AUDIOFOCUS_INVALID";
break;
}
Log.d(Config.LOGTAG, "onAudioFocusChange: " + typeOfChange);
}
};
// Request audio playout focus (without ducking) and install listener for changes in focus.
int result = audioManager.requestAudioFocus(audioFocusChangeListener,
AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d(Config.LOGTAG, "Audio focus request granted for VOICE_CALL streams");
} else {
Log.e(Config.LOGTAG, "Audio focus request failed");
}
// Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
// required to be in this mode when playout and/or recording starts for
// best possible VoIP performance.
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
// Always disable microphone mute during a WebRTC call.
setMicrophoneMute(false);
// Set initial device states.
userSelectedAudioDevice = AudioDevice.NONE;
selectedAudioDevice = AudioDevice.NONE;
audioDevices.clear();
// Initialize and start Bluetooth if a BT device is available or initiate
// detection of new (enabled) BT devices.
bluetoothManager.start();
// Do initial selection of audio device. This setting can later be changed
// either by adding/removing a BT or wired headset or by covering/uncovering
// the proximity sensor.
updateAudioDeviceState();
// Register receiver for broadcast intents related to adding/removing a
// wired headset.
registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
Log.d(Config.LOGTAG, "AudioManager started");
}
@SuppressWarnings("deprecation")
// TODO(henrika): audioManager.abandonAudioFocus() is deprecated.
public void stop() {
Log.d(Config.LOGTAG, "stop");
ThreadUtils.checkIsOnMainThread();
if (amState != AudioManagerState.RUNNING) {
Log.e(Config.LOGTAG, "Trying to stop AudioManager in incorrect state: " + amState);
return;
}
amState = AudioManagerState.UNINITIALIZED;
unregisterReceiver(wiredHeadsetReceiver);
bluetoothManager.stop();
// Restore previously stored audio states.
setSpeakerphoneOn(savedIsSpeakerPhoneOn);
setMicrophoneMute(savedIsMicrophoneMute);
audioManager.setMode(savedAudioMode);
// Abandon audio focus. Gives the previous focus owner, if any, focus.
audioManager.abandonAudioFocus(audioFocusChangeListener);
audioFocusChangeListener = null;
Log.d(Config.LOGTAG, "Abandoned audio focus for VOICE_CALL streams");
if (proximitySensor != null) {
proximitySensor.stop();
proximitySensor = null;
}
audioManagerEvents = null;
Log.d(Config.LOGTAG, "AudioManager stopped");
}
/**
* Changes selection of the currently active audio device.
*/
private void setAudioDeviceInternal(AudioDevice device) {
Log.d(Config.LOGTAG, "setAudioDeviceInternal(device=" + device + ")");
AppRTCUtils.assertIsTrue(audioDevices.contains(device));
switch (device) {
case SPEAKER_PHONE:
setSpeakerphoneOn(true);
break;
case EARPIECE:
setSpeakerphoneOn(false);
break;
case WIRED_HEADSET:
setSpeakerphoneOn(false);
break;
case BLUETOOTH:
setSpeakerphoneOn(false);
break;
default:
Log.e(Config.LOGTAG, "Invalid audio device selection");
break;
}
selectedAudioDevice = device;
}
/**
* Changes default audio device.
* TODO(henrika): add usage of this method in the AppRTCMobile client.
*/
public void setDefaultAudioDevice(AudioDevice defaultDevice) {
ThreadUtils.checkIsOnMainThread();
switch (defaultDevice) {
case SPEAKER_PHONE:
defaultAudioDevice = defaultDevice;
break;
case EARPIECE:
if (hasEarpiece()) {
defaultAudioDevice = defaultDevice;
} else {
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
}
break;
default:
Log.e(Config.LOGTAG, "Invalid default audio device selection");
break;
}
Log.d(Config.LOGTAG, "setDefaultAudioDevice(device=" + defaultAudioDevice + ")");
updateAudioDeviceState();
}
/**
* Changes selection of the currently active audio device.
*/
public void selectAudioDevice(AudioDevice device) {
ThreadUtils.checkIsOnMainThread();
if (!audioDevices.contains(device)) {
Log.e(Config.LOGTAG, "Can not select " + device + " from available " + audioDevices);
}
userSelectedAudioDevice = device;
updateAudioDeviceState();
}
/**
* Returns current set of available/selectable audio devices.
*/
public Set<AudioDevice> getAudioDevices() {
ThreadUtils.checkIsOnMainThread();
return Collections.unmodifiableSet(new HashSet<>(audioDevices));
}
/**
* Returns the currently selected audio device.
*/
public AudioDevice getSelectedAudioDevice() {
ThreadUtils.checkIsOnMainThread();
return selectedAudioDevice;
}
/**
* Helper method for receiver registration.
*/
private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
apprtcContext.registerReceiver(receiver, filter);
}
/**
* Helper method for unregistration of an existing receiver.
*/
private void unregisterReceiver(BroadcastReceiver receiver) {
apprtcContext.unregisterReceiver(receiver);
}
/**
* Sets the speaker phone mode.
*/
private void setSpeakerphoneOn(boolean on) {
boolean wasOn = audioManager.isSpeakerphoneOn();
if (wasOn == on) {
return;
}
audioManager.setSpeakerphoneOn(on);
}
/**
* Sets the microphone mute state.
*/
private void setMicrophoneMute(boolean on) {
boolean wasMuted = audioManager.isMicrophoneMute();
if (wasMuted == on) {
return;
}
audioManager.setMicrophoneMute(on);
}
/**
* Gets the current earpiece state.
*/
private boolean hasEarpiece() {
return apprtcContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
}
/**
* Checks whether a wired headset is connected or not.
* This is not a valid indication that audio playback is actually over
* the wired headset as audio routing depends on other conditions. We
* only use it as an early indicator (during initialization) of an attached
* wired headset.
*/
@Deprecated
private boolean hasWiredHeadset() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return audioManager.isWiredHeadsetOn();
} else {
final AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
for (AudioDeviceInfo device : devices) {
final int type = device.getType();
if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
Log.d(Config.LOGTAG, "hasWiredHeadset: found wired headset");
return true;
} else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
Log.d(Config.LOGTAG, "hasWiredHeadset: found USB audio device");
return true;
}
}
return false;
}
}
/**
* Updates list of possible audio devices and make new device selection.
* TODO(henrika): add unit test to verify all state transitions.
*/
public void updateAudioDeviceState() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "--- updateAudioDeviceState: "
+ "wired headset=" + hasWiredHeadset + ", "
+ "BT state=" + bluetoothManager.getState());
Log.d(Config.LOGTAG, "Device status: "
+ "available=" + audioDevices + ", "
+ "selected=" + selectedAudioDevice + ", "
+ "user selected=" + userSelectedAudioDevice);
// Check if any Bluetooth headset is connected. The internal BT state will
// change accordingly.
// TODO(henrika): perhaps wrap required state into BT manager.
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_DISCONNECTING) {
bluetoothManager.updateDevice();
}
// Update the set of available audio devices.
Set<AudioDevice> newAudioDevices = new HashSet<>();
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE) {
newAudioDevices.add(AudioDevice.BLUETOOTH);
}
if (hasWiredHeadset) {
// If a wired headset is connected, then it is the only possible option.
newAudioDevices.add(AudioDevice.WIRED_HEADSET);
} else {
// No wired headset, hence the audio-device list can contain speaker
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
newAudioDevices.add(AudioDevice.SPEAKER_PHONE);
if (hasEarpiece()) {
newAudioDevices.add(AudioDevice.EARPIECE);
}
}
// Store state which is set to true if the device list has changed.
boolean audioDeviceSetUpdated = !audioDevices.equals(newAudioDevices);
// Update the existing audio device set.
audioDevices = newAudioDevices;
// Correct user selected audio devices if needed.
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
&& userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
// If BT is not available, it can't be the user selection.
userSelectedAudioDevice = AudioDevice.NONE;
}
if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
// If user selected speaker phone, but then plugged wired headset then make
// wired headset as user selected device.
userSelectedAudioDevice = AudioDevice.WIRED_HEADSET;
}
if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
// If user selected wired headset, but then unplugged wired headset then make
// speaker phone as user selected device.
userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE;
}
// Need to start Bluetooth if it is available and user either selected it explicitly or
// user did not select any output device.
boolean needBluetoothAudioStart =
bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
&& (userSelectedAudioDevice == AudioDevice.NONE
|| userSelectedAudioDevice == AudioDevice.BLUETOOTH);
// Need to stop Bluetooth audio if user selected different device and
// Bluetooth SCO connection is established or in the process.
boolean needBluetoothAudioStop =
(bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING)
&& (userSelectedAudioDevice != AudioDevice.NONE
&& userSelectedAudioDevice != AudioDevice.BLUETOOTH);
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
Log.d(Config.LOGTAG, "Need BT audio: start=" + needBluetoothAudioStart + ", "
+ "stop=" + needBluetoothAudioStop + ", "
+ "BT state=" + bluetoothManager.getState());
}
// Start or stop Bluetooth SCO connection given states set earlier.
if (needBluetoothAudioStop) {
bluetoothManager.stopScoAudio();
bluetoothManager.updateDevice();
}
if (needBluetoothAudioStart && !needBluetoothAudioStop) {
// Attempt to start Bluetooth SCO audio (takes a few second to start).
if (!bluetoothManager.startScoAudio()) {
// Remove BLUETOOTH from list of available devices since SCO failed.
audioDevices.remove(AudioDevice.BLUETOOTH);
audioDeviceSetUpdated = true;
}
}
// Update selected audio device.
final AudioDevice newAudioDevice;
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
// If a Bluetooth is connected, then it should be used as output audio
// device. Note that it is not sufficient that a headset is available;
// an active SCO channel must also be up and running.
newAudioDevice = AudioDevice.BLUETOOTH;
} else if (hasWiredHeadset) {
// If a wired headset is connected, but Bluetooth is not, then wired headset is used as
// audio device.
newAudioDevice = AudioDevice.WIRED_HEADSET;
} else {
// No wired headset and no Bluetooth, hence the audio-device list can contain speaker
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
// |defaultAudioDevice| contains either AudioDevice.SPEAKER_PHONE or AudioDevice.EARPIECE
// depending on the user's selection.
newAudioDevice = defaultAudioDevice;
}
// Switch to new device but only if there has been any changes.
if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) {
// Do the required device switch.
setAudioDeviceInternal(newAudioDevice);
Log.d(Config.LOGTAG, "New device status: "
+ "available=" + audioDevices + ", "
+ "selected=" + newAudioDevice);
if (audioManagerEvents != null) {
// Notify a listening client that audio device has been changed.
audioManagerEvents.onAudioDeviceChanged(selectedAudioDevice, audioDevices);
}
}
Log.d(Config.LOGTAG, "--- updateAudioDeviceState done");
}
/**
* AudioDevice is the names of possible audio devices that we currently
* support.
*/
public enum AudioDevice {SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE}
/**
* AudioManager state.
*/
public enum AudioManagerState {
UNINITIALIZED,
PREINITIALIZED,
RUNNING,
}
public enum SpeakerPhonePreference {
AUTO, EARPIECE, SPEAKER
}
/**
* Selected audio device change event.
*/
public interface AudioManagerEvents {
// Callback fired once audio device is changed or list of available audio devices changed.
void onAudioDeviceChanged(
AudioDevice selectedAudioDevice, Set<AudioDevice> availableAudioDevices);
}
/* Receiver which handles changes in wired headset availability. */
private class WiredHeadsetReceiver extends BroadcastReceiver {
private static final int STATE_UNPLUGGED = 0;
private static final int STATE_PLUGGED = 1;
private static final int HAS_NO_MIC = 0;
private static final int HAS_MIC = 1;
@Override
public void onReceive(Context context, Intent intent) {
int state = intent.getIntExtra("state", STATE_UNPLUGGED);
int microphone = intent.getIntExtra("microphone", HAS_NO_MIC);
String name = intent.getStringExtra("name");
Log.d(Config.LOGTAG, "WiredHeadsetReceiver.onReceive" + AppRTCUtils.getThreadInfo() + ": "
+ "a=" + intent.getAction() + ", s="
+ (state == STATE_UNPLUGGED ? "unplugged" : "plugged") + ", m="
+ (microphone == HAS_MIC ? "mic" : "no mic") + ", n=" + name + ", sb="
+ isInitialStickyBroadcast());
hasWiredHeadset = (state == STATE_PLUGGED);
updateAudioDeviceState();
}
}
}

View file

@ -0,0 +1,549 @@
/*
* Copyright 2016 The WebRTC Project Authors. All rights reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package eu.siacs.conversations.services;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothHeadset;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.media.AudioManager;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.support.annotation.Nullable;
import android.util.Log;
import java.util.List;
import java.util.Set;
import org.webrtc.ThreadUtils;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
/**
* AppRTCProximitySensor manages functions related to Bluetoth devices in the
* AppRTC demo.
*/
public class AppRTCBluetoothManager {
// Timeout interval for starting or stopping audio to a Bluetooth SCO device.
private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000;
// Maximum number of SCO connection attempts.
private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
private final Context apprtcContext;
private final AppRTCAudioManager apprtcAudioManager;
@Nullable
private final AudioManager audioManager;
private final Handler handler;
private final BluetoothProfile.ServiceListener bluetoothServiceListener;
private final BroadcastReceiver bluetoothHeadsetReceiver;
int scoConnectionAttempts;
private State bluetoothState;
@Nullable
private BluetoothAdapter bluetoothAdapter;
@Nullable
private BluetoothHeadset bluetoothHeadset;
@Nullable
private BluetoothDevice bluetoothDevice;
// Runs when the Bluetooth timeout expires. We use that timeout after calling
// startScoAudio() or stopScoAudio() because we're not guaranteed to get a
// callback after those calls.
private final Runnable bluetoothTimeoutRunnable = new Runnable() {
@Override
public void run() {
bluetoothTimeout();
}
};
protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioManager) {
Log.d(Config.LOGTAG, "ctor");
ThreadUtils.checkIsOnMainThread();
apprtcContext = context;
apprtcAudioManager = audioManager;
this.audioManager = getAudioManager(context);
bluetoothState = State.UNINITIALIZED;
bluetoothServiceListener = new BluetoothServiceListener();
bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver();
handler = new Handler(Looper.getMainLooper());
}
/**
* Construction.
*/
static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audioManager) {
Log.d(Config.LOGTAG, "create" + AppRTCUtils.getThreadInfo());
return new AppRTCBluetoothManager(context, audioManager);
}
/**
* Returns the internal state.
*/
public State getState() {
ThreadUtils.checkIsOnMainThread();
return bluetoothState;
}
/**
* Activates components required to detect Bluetooth devices and to enable
* BT SCO (audio is routed via BT SCO) for the headset profile. The end
* state will be HEADSET_UNAVAILABLE but a state machine has started which
* will start a state change sequence where the final outcome depends on
* if/when the BT headset is enabled.
* Example of state change sequence when start() is called while BT device
* is connected and enabled:
* UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE -->
* SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
* Note that the AppRTCAudioManager is also involved in driving this state
* change.
*/
public void start() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "start");
if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) {
Log.w(Config.LOGTAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission");
return;
}
if (bluetoothState != State.UNINITIALIZED) {
Log.w(Config.LOGTAG, "Invalid BT state");
return;
}
bluetoothHeadset = null;
bluetoothDevice = null;
scoConnectionAttempts = 0;
// Get a handle to the default local Bluetooth adapter.
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
if (bluetoothAdapter == null) {
Log.w(Config.LOGTAG, "Device does not support Bluetooth");
return;
}
// Ensure that the device supports use of BT SCO audio for off call use cases.
if (!audioManager.isBluetoothScoAvailableOffCall()) {
Log.e(Config.LOGTAG, "Bluetooth SCO audio is not available off call");
return;
}
logBluetoothAdapterInfo(bluetoothAdapter);
// Establish a connection to the HEADSET profile (includes both Bluetooth Headset and
// Hands-Free) proxy object and install a listener.
if (!getBluetoothProfileProxy(
apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET)) {
Log.e(Config.LOGTAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed");
return;
}
// Register receivers for BluetoothHeadset change notifications.
IntentFilter bluetoothHeadsetFilter = new IntentFilter();
// Register receiver for change in connection state of the Headset profile.
bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
// Register receiver for change in audio connection state of the Headset profile.
bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter);
Log.d(Config.LOGTAG, "HEADSET profile state: "
+ stateToString(bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)));
Log.d(Config.LOGTAG, "Bluetooth proxy for headset profile has started");
bluetoothState = State.HEADSET_UNAVAILABLE;
Log.d(Config.LOGTAG, "start done: BT state=" + bluetoothState);
}
/**
* Stops and closes all components related to Bluetooth audio.
*/
public void stop() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "stop: BT state=" + bluetoothState);
if (bluetoothAdapter == null) {
return;
}
// Stop BT SCO connection with remote device if needed.
stopScoAudio();
// Close down remaining BT resources.
if (bluetoothState == State.UNINITIALIZED) {
return;
}
unregisterReceiver(bluetoothHeadsetReceiver);
cancelTimer();
if (bluetoothHeadset != null) {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
bluetoothHeadset = null;
}
bluetoothAdapter = null;
bluetoothDevice = null;
bluetoothState = State.UNINITIALIZED;
Log.d(Config.LOGTAG, "stop done: BT state=" + bluetoothState);
}
/**
* Starts Bluetooth SCO connection with remote device.
* Note that the phone application always has the priority on the usage of the SCO connection
* for telephony. If this method is called while the phone is in call it will be ignored.
* Similarly, if a call is received or sent while an application is using the SCO connection,
* the connection will be lost for the application and NOT returned automatically when the call
* ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a
* virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_MR2 only a raw SCO
* audio connection is established.
* TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and
* higher. It might be required to initiates a virtual voice call since many devices do not
* accept SCO audio without a "call".
*/
public boolean startScoAudio() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "startSco: BT state=" + bluetoothState + ", "
+ "attempts: " + scoConnectionAttempts + ", "
+ "SCO is on: " + isScoOn());
if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
Log.e(Config.LOGTAG, "BT SCO connection fails - no more attempts");
return false;
}
if (bluetoothState != State.HEADSET_AVAILABLE) {
Log.e(Config.LOGTAG, "BT SCO connection fails - no headset available");
return false;
}
// Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
Log.d(Config.LOGTAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED...");
// The SCO connection establishment can take several seconds, hence we cannot rely on the
// connection to be available when the method returns but instead register to receive the
// intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED.
bluetoothState = State.SCO_CONNECTING;
audioManager.startBluetoothSco();
audioManager.setBluetoothScoOn(true);
scoConnectionAttempts++;
startTimer();
Log.d(Config.LOGTAG, "startScoAudio done: BT state=" + bluetoothState + ", "
+ "SCO is on: " + isScoOn());
return true;
}
/**
* Stops Bluetooth SCO connection with remote device.
*/
public void stopScoAudio() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "stopScoAudio: BT state=" + bluetoothState + ", "
+ "SCO is on: " + isScoOn());
if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
return;
}
cancelTimer();
audioManager.stopBluetoothSco();
audioManager.setBluetoothScoOn(false);
bluetoothState = State.SCO_DISCONNECTING;
Log.d(Config.LOGTAG, "stopScoAudio done: BT state=" + bluetoothState + ", "
+ "SCO is on: " + isScoOn());
}
/**
* Use the BluetoothHeadset proxy object (controls the Bluetooth Headset
* Service via IPC) to update the list of connected devices for the HEADSET
* profile. The internal state will change to HEADSET_UNAVAILABLE or to
* HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected
* device if available.
*/
public void updateDevice() {
if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
return;
}
Log.d(Config.LOGTAG, "updateDevice");
// Get connected devices for the headset profile. Returns the set of
// devices which are in state STATE_CONNECTED. The BluetoothDevice class
// is just a thin wrapper for a Bluetooth hardware address.
List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
if (devices.isEmpty()) {
bluetoothDevice = null;
bluetoothState = State.HEADSET_UNAVAILABLE;
Log.d(Config.LOGTAG, "No connected bluetooth headset");
} else {
// Always use first device in list. Android only supports one device.
bluetoothDevice = devices.get(0);
bluetoothState = State.HEADSET_AVAILABLE;
Log.d(Config.LOGTAG, "Connected bluetooth headset: "
+ "name=" + bluetoothDevice.getName() + ", "
+ "state=" + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice))
+ ", SCO audio=" + bluetoothHeadset.isAudioConnected(bluetoothDevice));
}
Log.d(Config.LOGTAG, "updateDevice done: BT state=" + bluetoothState);
}
/**
* Stubs for test mocks.
*/
@Nullable
protected AudioManager getAudioManager(Context context) {
return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
}
protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
apprtcContext.registerReceiver(receiver, filter);
}
protected void unregisterReceiver(BroadcastReceiver receiver) {
apprtcContext.unregisterReceiver(receiver);
}
protected boolean getBluetoothProfileProxy(
Context context, BluetoothProfile.ServiceListener listener, int profile) {
return bluetoothAdapter.getProfileProxy(context, listener, profile);
}
protected boolean hasPermission(Context context, String permission) {
return apprtcContext.checkPermission(permission, Process.myPid(), Process.myUid())
== PackageManager.PERMISSION_GRANTED;
}
/**
* Logs the state of the local Bluetooth adapter.
*/
@SuppressLint("HardwareIds")
protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
Log.d(Config.LOGTAG, "BluetoothAdapter: "
+ "enabled=" + localAdapter.isEnabled() + ", "
+ "state=" + stateToString(localAdapter.getState()) + ", "
+ "name=" + localAdapter.getName() + ", "
+ "address=" + localAdapter.getAddress());
// Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter.
Set<BluetoothDevice> pairedDevices = localAdapter.getBondedDevices();
if (!pairedDevices.isEmpty()) {
Log.d(Config.LOGTAG, "paired devices:");
for (BluetoothDevice device : pairedDevices) {
Log.d(Config.LOGTAG, " name=" + device.getName() + ", address=" + device.getAddress());
}
}
}
/**
* Ensures that the audio manager updates its list of available audio devices.
*/
private void updateAudioDeviceState() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "updateAudioDeviceState");
apprtcAudioManager.updateAudioDeviceState();
}
/**
* Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds.
*/
private void startTimer() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "startTimer");
handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
}
/**
* Cancels any outstanding timer tasks.
*/
private void cancelTimer() {
ThreadUtils.checkIsOnMainThread();
Log.d(Config.LOGTAG, "cancelTimer");
handler.removeCallbacks(bluetoothTimeoutRunnable);
}
/**
* Called when start of the BT SCO channel takes too long time. Usually
* happens when the BT device has been turned on during an ongoing call.
*/
private void bluetoothTimeout() {
ThreadUtils.checkIsOnMainThread();
if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
return;
}
Log.d(Config.LOGTAG, "bluetoothTimeout: BT state=" + bluetoothState + ", "
+ "attempts: " + scoConnectionAttempts + ", "
+ "SCO is on: " + isScoOn());
if (bluetoothState != State.SCO_CONNECTING) {
return;
}
// Bluetooth SCO should be connecting; check the latest result.
boolean scoConnected = false;
List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
if (devices.size() > 0) {
bluetoothDevice = devices.get(0);
if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) {
Log.d(Config.LOGTAG, "SCO connected with " + bluetoothDevice.getName());
scoConnected = true;
} else {
Log.d(Config.LOGTAG, "SCO is not connected with " + bluetoothDevice.getName());
}
}
if (scoConnected) {
// We thought BT had timed out, but it's actually on; updating state.
bluetoothState = State.SCO_CONNECTED;
scoConnectionAttempts = 0;
} else {
// Give up and "cancel" our request by calling stopBluetoothSco().
Log.w(Config.LOGTAG, "BT failed to connect after timeout");
stopScoAudio();
}
updateAudioDeviceState();
Log.d(Config.LOGTAG, "bluetoothTimeout done: BT state=" + bluetoothState);
}
/**
* Checks whether audio uses Bluetooth SCO.
*/
private boolean isScoOn() {
return audioManager.isBluetoothScoOn();
}
/**
* Converts BluetoothAdapter states into local string representations.
*/
private String stateToString(int state) {
switch (state) {
case BluetoothAdapter.STATE_DISCONNECTED:
return "DISCONNECTED";
case BluetoothAdapter.STATE_CONNECTED:
return "CONNECTED";
case BluetoothAdapter.STATE_CONNECTING:
return "CONNECTING";
case BluetoothAdapter.STATE_DISCONNECTING:
return "DISCONNECTING";
case BluetoothAdapter.STATE_OFF:
return "OFF";
case BluetoothAdapter.STATE_ON:
return "ON";
case BluetoothAdapter.STATE_TURNING_OFF:
// Indicates the local Bluetooth adapter is turning off. Local clients should immediately
// attempt graceful disconnection of any remote links.
return "TURNING_OFF";
case BluetoothAdapter.STATE_TURNING_ON:
// Indicates the local Bluetooth adapter is turning on. However local clients should wait
// for STATE_ON before attempting to use the adapter.
return "TURNING_ON";
default:
return "INVALID";
}
}
// Bluetooth connection state.
public enum State {
// Bluetooth is not available; no adapter or Bluetooth is off.
UNINITIALIZED,
// Bluetooth error happened when trying to start Bluetooth.
ERROR,
// Bluetooth proxy object for the Headset profile exists, but no connected headset devices,
// SCO is not started or disconnected.
HEADSET_UNAVAILABLE,
// Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset
// present, but SCO is not started or disconnected.
HEADSET_AVAILABLE,
// Bluetooth audio SCO connection with remote device is closing.
SCO_DISCONNECTING,
// Bluetooth audio SCO connection with remote device is initiated.
SCO_CONNECTING,
// Bluetooth audio SCO connection with remote device is established.
SCO_CONNECTED
}
/**
* Implementation of an interface that notifies BluetoothProfile IPC clients when they have been
* connected to or disconnected from the service.
*/
private class BluetoothServiceListener implements BluetoothProfile.ServiceListener {
@Override
// Called to notify the client when the proxy object has been connected to the service.
// Once we have the profile proxy object, we can use it to monitor the state of the
// connection and perform other operations that are relevant to the headset profile.
public void onServiceConnected(int profile, BluetoothProfile proxy) {
if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
return;
}
Log.d(Config.LOGTAG, "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState);
// Android only supports one connected Bluetooth Headset at a time.
bluetoothHeadset = (BluetoothHeadset) proxy;
updateAudioDeviceState();
Log.d(Config.LOGTAG, "onServiceConnected done: BT state=" + bluetoothState);
}
@Override
/** Notifies the client when the proxy object has been disconnected from the service. */
public void onServiceDisconnected(int profile) {
if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
return;
}
Log.d(Config.LOGTAG, "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState);
stopScoAudio();
bluetoothHeadset = null;
bluetoothDevice = null;
bluetoothState = State.HEADSET_UNAVAILABLE;
updateAudioDeviceState();
Log.d(Config.LOGTAG, "onServiceDisconnected done: BT state=" + bluetoothState);
}
}
// Intent broadcast receiver which handles changes in Bluetooth device availability.
// Detects headset changes and Bluetooth SCO state changes.
private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (bluetoothState == State.UNINITIALIZED) {
return;
}
final String action = intent.getAction();
// Change in connection state of the Headset profile. Note that the
// change does not tell us anything about whether we're streaming
// audio to BT over SCO. Typically received when user turns on a BT
// headset while audio is active using another audio device.
if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
final int state =
intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED);
Log.d(Config.LOGTAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
+ "a=ACTION_CONNECTION_STATE_CHANGED, "
+ "s=" + stateToString(state) + ", "
+ "sb=" + isInitialStickyBroadcast() + ", "
+ "BT state: " + bluetoothState);
if (state == BluetoothHeadset.STATE_CONNECTED) {
scoConnectionAttempts = 0;
updateAudioDeviceState();
} else if (state == BluetoothHeadset.STATE_CONNECTING) {
// No action needed.
} else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
// No action needed.
} else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
// Bluetooth is probably powered off during the call.
stopScoAudio();
updateAudioDeviceState();
}
// Change in the audio (SCO) connection state of the Headset profile.
// Typically received after call to startScoAudio() has finalized.
} else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
final int state = intent.getIntExtra(
BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
Log.d(Config.LOGTAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
+ "a=ACTION_AUDIO_STATE_CHANGED, "
+ "s=" + stateToString(state) + ", "
+ "sb=" + isInitialStickyBroadcast() + ", "
+ "BT state: " + bluetoothState);
if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
cancelTimer();
if (bluetoothState == State.SCO_CONNECTING) {
Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connected");
bluetoothState = State.SCO_CONNECTED;
scoConnectionAttempts = 0;
updateAudioDeviceState();
} else {
Log.w(Config.LOGTAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED");
}
} else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connecting...");
} else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now disconnected");
if (isInitialStickyBroadcast()) {
Log.d(Config.LOGTAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.");
return;
}
updateAudioDeviceState();
}
}
Log.d(Config.LOGTAG, "onReceive done: BT state=" + bluetoothState);
}
}
}

View file

@ -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());
}
}

View file

@ -4017,6 +4017,12 @@ public class XmppConnectionService extends Service {
} }
} }
public void notifyJingleRtpConnectionUpdate(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
for (OnJingleRtpConnectionUpdate listener : threadSafeList(this.onJingleRtpConnectionUpdate)) {
listener.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
}
}
public void updateAccountUi() { public void updateAccountUi() {
for (OnAccountUpdate listener : threadSafeList(this.mOnAccountUpdates)) { for (OnAccountUpdate listener : threadSafeList(this.mOnAccountUpdates)) {
listener.onAccountUpdate(); listener.onAccountUpdate();
@ -4696,6 +4702,8 @@ public class XmppConnectionService extends Service {
public interface OnJingleRtpConnectionUpdate { public interface OnJingleRtpConnectionUpdate {
void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state); void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state);
void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
} }
public interface OnAccountUpdate { public interface OnAccountUpdate {

View file

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

View file

@ -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);
}
}

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

View file

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

View file

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

View file

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