Refactor call audio routing and bluetooth management.

This commit is contained in:
Cody Henthorne
2021-09-27 11:23:10 -04:00
parent 6c55916cda
commit e637f15a43
36 changed files with 1345 additions and 981 deletions

View File

@@ -0,0 +1,109 @@
package org.thoughtcrime.securesms.webrtc.audio
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import org.thoughtcrime.securesms.util.ParcelUtil
/**
* Commands that can be issued to [SignalAudioManager] to perform various tasks.
*
* Additional context: The audio management is tied closely with the Android audio and thus benefits from being
* tied to the [org.thoughtcrime.securesms.service.webrtc.WebRtcCallService] lifecycle. Because of this, all
* calls have to go through an intent to the service and this allows one entry point for that but multiple
* operations.
*/
sealed class AudioManagerCommand : Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) = Unit
override fun describeContents(): Int = 0
class Initialize : AudioManagerCommand() {
companion object {
@JvmField
val CREATOR: Parcelable.Creator<Initialize> = ParcelCheat { Initialize() }
}
}
class StartIncomingRinger(val ringtoneUri: Uri, val vibrate: Boolean) : AudioManagerCommand() {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeParcelable(ringtoneUri, flags)
ParcelUtil.writeBoolean(parcel, vibrate)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<StartIncomingRinger> = ParcelCheat { parcel ->
StartIncomingRinger(
ringtoneUri = parcel.readParcelable(Uri::class.java.classLoader)!!,
vibrate = ParcelUtil.readBoolean(parcel)
)
}
}
}
class StartOutgoingRinger : AudioManagerCommand() {
companion object {
@JvmField
val CREATOR: Parcelable.Creator<StartOutgoingRinger> = ParcelCheat { StartOutgoingRinger() }
}
}
class SilenceIncomingRinger : AudioManagerCommand() {
companion object {
@JvmField
val CREATOR: Parcelable.Creator<SilenceIncomingRinger> = ParcelCheat { SilenceIncomingRinger() }
}
}
class Start : AudioManagerCommand() {
companion object {
@JvmField
val CREATOR: Parcelable.Creator<Start> = ParcelCheat { Start() }
}
}
class Stop(val playDisconnect: Boolean) : AudioManagerCommand() {
override fun writeToParcel(parcel: Parcel, flags: Int) {
ParcelUtil.writeBoolean(parcel, playDisconnect)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<Stop> = ParcelCheat { Stop(ParcelUtil.readBoolean(it)) }
}
}
class SetUserDevice(val device: SignalAudioManager.AudioDevice) : AudioManagerCommand() {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeSerializable(device)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<SetUserDevice> = ParcelCheat { SetUserDevice(it.readSerializable() as SignalAudioManager.AudioDevice) }
}
}
class SetDefaultDevice(val device: SignalAudioManager.AudioDevice, val clearUserEarpieceSelection: Boolean) : AudioManagerCommand() {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeSerializable(device)
ParcelUtil.writeBoolean(parcel, clearUserEarpieceSelection)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<SetDefaultDevice> = ParcelCheat { parcel ->
SetDefaultDevice(
device = parcel.readSerializable() as SignalAudioManager.AudioDevice,
clearUserEarpieceSelection = ParcelUtil.readBoolean(parcel)
)
}
}
}
class ParcelCheat<T>(private val createFrom: (Parcel) -> T) : Parcelable.Creator<T> {
override fun createFromParcel(parcel: Parcel): T = createFrom(parcel)
override fun newArray(size: Int): Array<T?> = throw UnsupportedOperationException()
}
}

View File

@@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.webrtc.audio;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.media.SoundPool;
@@ -30,6 +33,88 @@ public abstract class AudioManagerCompat {
audioManager = ServiceUtil.getAudioManager(context);
}
public boolean isBluetoothScoAvailableOffCall() {
return audioManager.isBluetoothScoAvailableOffCall();
}
public void startBluetoothSco() {
audioManager.startBluetoothSco();
}
public void stopBluetoothSco() {
audioManager.stopBluetoothSco();
}
public boolean isBluetoothScoOn() {
return audioManager.isBluetoothScoOn();
}
public void setBluetoothScoOn(boolean on) {
audioManager.setBluetoothScoOn(on);
}
public int getMode() {
return audioManager.getMode();
}
public void setMode(int modeInCommunication) {
audioManager.setMode(modeInCommunication);
}
public boolean isSpeakerphoneOn() {
return audioManager.isSpeakerphoneOn();
}
public void setSpeakerphoneOn(boolean on) {
audioManager.setSpeakerphoneOn(on);
}
public boolean isMicrophoneMute() {
return audioManager.isMicrophoneMute();
}
public void setMicrophoneMute(boolean on) {
audioManager.setMicrophoneMute(on);
}
public boolean hasEarpiece(@NonNull Context context) {
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
}
@SuppressLint("WrongConstant")
public boolean isWiredHeadsetOn() {
if (Build.VERSION.SDK_INT < 23) {
//noinspection deprecation
return audioManager.isWiredHeadsetOn();
} else {
AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
for (AudioDeviceInfo device : devices) {
final int type = device.getType();
if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
return true;
} else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
return true;
}
}
return false;
}
}
public float ringVolumeWithMinimum() {
int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING);
int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING);
float volume = logVolume(currentVolume, maxVolume);
float minVolume = logVolume(15, 100);
return Math.max(volume, minVolume);
}
private static float logVolume(int volume, int maxVolume) {
if (maxVolume == 0 || volume > maxVolume) {
return 0.5f;
}
return (float) (1 - (Math.log(maxVolume + 1 - volume) / Math.log(maxVolume + 1)));
}
abstract public SoundPool createSoundPool();
abstract public void requestCallAudioFocus();
abstract public void abandonCallAudioFocus();

View File

@@ -1,257 +0,0 @@
package org.thoughtcrime.securesms.webrtc.audio;
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.media.AudioManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Note: We will need to start handling new permissions once we move to target API 31
*/
@SuppressLint("MissingPermission")
public class BluetoothStateManager {
private static final String TAG = Log.tag(BluetoothStateManager.class);
private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
private enum ScoConnection {
DISCONNECTED,
IN_PROGRESS,
CONNECTED
}
private final Object LOCK = new Object();
private final Context context;
private final BluetoothAdapter bluetoothAdapter;
private BluetoothScoReceiver bluetoothScoReceiver;
private BluetoothConnectionReceiver bluetoothConnectionReceiver;
private final BluetoothStateListener listener;
private final AtomicBoolean destroyed;
private volatile ScoConnection scoConnection = ScoConnection.DISCONNECTED;
private int scoConnectionAttempts = 0;
private BluetoothHeadset bluetoothHeadset = null;
private boolean wantsConnection = false;
public BluetoothStateManager(@NonNull Context context, @Nullable BluetoothStateListener listener) {
this.context = context.getApplicationContext();
BluetoothAdapter localAdapter = BluetoothAdapter.getDefaultAdapter();
if (localAdapter == null) {
this.bluetoothAdapter = null;
this.listener = null;
this.destroyed = new AtomicBoolean(true);
return;
}
this.bluetoothAdapter = localAdapter;
this.bluetoothScoReceiver = new BluetoothScoReceiver();
this.bluetoothConnectionReceiver = new BluetoothConnectionReceiver();
this.listener = listener;
this.destroyed = new AtomicBoolean(false);
requestHeadsetProxyProfile();
this.context.registerReceiver(bluetoothConnectionReceiver, new IntentFilter(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED));
Intent sticky = this.context.registerReceiver(bluetoothScoReceiver, new IntentFilter(getScoChangeIntent()));
if (sticky != null) {
bluetoothScoReceiver.onReceive(context, sticky);
}
handleBluetoothStateChange();
}
public void onDestroy() {
destroyed.set(true);
if (bluetoothHeadset != null && bluetoothAdapter != null) {
this.bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
}
if (bluetoothConnectionReceiver != null) {
context.unregisterReceiver(bluetoothConnectionReceiver);
bluetoothConnectionReceiver = null;
}
if (bluetoothScoReceiver != null) {
context.unregisterReceiver(bluetoothScoReceiver);
bluetoothScoReceiver = null;
}
this.bluetoothHeadset = null;
}
public void setWantsConnection(boolean enabled) {
synchronized (LOCK) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
this.wantsConnection = enabled;
if (wantsConnection && isBluetoothAvailable() && scoConnection == ScoConnection.DISCONNECTED) {
if (scoConnectionAttempts > MAX_SCO_CONNECTION_ATTEMPTS) {
Log.w(TAG, "We've already attempted to start SCO too many times. Won't try again.");
} else {
scoConnectionAttempts++;
audioManager.startBluetoothSco();
scoConnection = ScoConnection.IN_PROGRESS;
}
} else if (!wantsConnection && scoConnection == ScoConnection.CONNECTED) {
audioManager.stopBluetoothSco();
audioManager.setBluetoothScoOn(false);
scoConnection = ScoConnection.DISCONNECTED;
} else if (!wantsConnection && scoConnection == ScoConnection.IN_PROGRESS) {
audioManager.stopBluetoothSco();
scoConnection = ScoConnection.DISCONNECTED;
}
}
}
private void handleBluetoothStateChange() {
if (!destroyed.get()) {
boolean isBluetoothAvailable = isBluetoothAvailable();
if (!isBluetoothAvailable) {
setWantsConnection(false);
}
if (listener != null) {
listener.onBluetoothStateChanged(isBluetoothAvailable);
}
}
}
private boolean isBluetoothAvailable() {
try {
synchronized (LOCK) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) return false;
if (!audioManager.isBluetoothScoAvailableOffCall()) return false;
return bluetoothHeadset != null && !bluetoothHeadset.getConnectedDevices().isEmpty();
}
} catch (Exception e) {
Log.w(TAG, e);
return false;
}
}
private String getScoChangeIntent() {
return AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED;
}
private void requestHeadsetProxyProfile() {
this.bluetoothAdapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() {
@Override
public void onServiceConnected(int profile, BluetoothProfile proxy) {
if (destroyed.get()) {
Log.w(TAG, "Got bluetooth profile event after the service was destroyed. Ignoring.");
return;
}
if (profile == BluetoothProfile.HEADSET) {
synchronized (LOCK) {
bluetoothHeadset = (BluetoothHeadset) proxy;
}
Intent sticky = context.registerReceiver(null, new IntentFilter(getScoChangeIntent()));
bluetoothScoReceiver.onReceive(context, sticky);
synchronized (LOCK) {
if (wantsConnection && isBluetoothAvailable() && scoConnection == ScoConnection.DISCONNECTED) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
audioManager.startBluetoothSco();
scoConnection = ScoConnection.IN_PROGRESS;
}
}
handleBluetoothStateChange();
}
}
@Override
public void onServiceDisconnected(int profile) {
Log.i(TAG, "onServiceDisconnected");
if (profile == BluetoothProfile.HEADSET) {
bluetoothHeadset = null;
handleBluetoothStateChange();
}
}
}, BluetoothProfile.HEADSET);
}
private class BluetoothScoReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) return;
Log.i(TAG, "onReceive");
synchronized (LOCK) {
if (getScoChangeIntent().equals(intent.getAction())) {
int status = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR);
if (status == AudioManager.SCO_AUDIO_STATE_CONNECTED) {
if (bluetoothHeadset != null) {
List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
for (BluetoothDevice device : devices) {
if (bluetoothHeadset.isAudioConnected(device)) {
scoConnection = ScoConnection.CONNECTED;
scoConnectionAttempts = 0;
if (wantsConnection) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
audioManager.setBluetoothScoOn(true);
}
}
}
}
} else if (status == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) {
setWantsConnection(false);
}
}
}
handleBluetoothStateChange();
}
}
private class BluetoothConnectionReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Log.i(TAG, "onReceive");
if (intent.getAction().equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
int state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED);
if (state == BluetoothHeadset.STATE_CONNECTED) {
scoConnectionAttempts = 0;
}
}
handleBluetoothStateChange();
}
}
public interface BluetoothStateListener {
void onBluetoothStateChanged(boolean isAvailable);
}
}

View File

@@ -51,6 +51,8 @@ public class IncomingRinger {
if (shouldVibrate(context, player, ringerMode, vibrate)) {
Log.i(TAG, "Starting vibration");
vibrator.vibrate(VIBRATE_PATTERN, 1);
} else {
Log.i(TAG, "Skipping vibration");
}
if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) {

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.webrtc.audio
import android.os.Handler
import android.os.Looper
/**
* Handler to run all audio/bluetooth operations. Provides current thread
* assertion for enforcing use of the handler when necessary.
*/
class SignalAudioHandler(looper: Looper) : Handler(looper) {
fun assertHandlerThread() {
if (!isOnHandler()) {
throw AssertionError("Must run on audio handler thread.")
}
}
fun isOnHandler(): Boolean {
return Looper.myLooper() == looper
}
}

View File

@@ -1,116 +0,0 @@
package org.thoughtcrime.securesms.webrtc.audio;
import android.content.Context;
import android.media.AudioManager;
import android.media.SoundPool;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
public class SignalAudioManager {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(SignalAudioManager.class);
private final Context context;
private final IncomingRinger incomingRinger;
private final OutgoingRinger outgoingRinger;
private final SoundPool soundPool;
private final int connectedSoundId;
private final int disconnectedSoundId;
private final AudioManagerCompat audioManagerCompat;
public SignalAudioManager(@NonNull Context context) {
this.context = context.getApplicationContext();
this.incomingRinger = new IncomingRinger(context);
this.outgoingRinger = new OutgoingRinger(context);
this.audioManagerCompat = AudioManagerCompat.create(context);
this.soundPool = audioManagerCompat.createSoundPool();
this.connectedSoundId = this.soundPool.load(context, R.raw.webrtc_completed, 1);
this.disconnectedSoundId = this.soundPool.load(context, R.raw.webrtc_disconnected, 1);
}
public void initializeAudioForCall() {
audioManagerCompat.requestCallAudioFocus();
}
public void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
boolean speaker = !audioManager.isWiredHeadsetOn() && !audioManager.isBluetoothScoOn();
audioManager.setMode(AudioManager.MODE_RINGTONE);
audioManager.setMicrophoneMute(false);
audioManager.setSpeakerphoneOn(speaker);
incomingRinger.start(ringtoneUri, vibrate);
}
public void startOutgoingRinger(OutgoingRinger.Type type) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
audioManager.setMicrophoneMute(false);
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
outgoingRinger.start(type);
}
public void silenceIncomingRinger() {
incomingRinger.stop();
}
public void startCommunication(boolean preserveSpeakerphone) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
incomingRinger.stop();
outgoingRinger.stop();
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
if (!preserveSpeakerphone) {
audioManager.setSpeakerphoneOn(false);
}
float volume = ringVolumeWithMinimum(audioManager);
soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f);
}
public void stop(boolean playDisconnected) {
AudioManager audioManager = ServiceUtil.getAudioManager(context);
incomingRinger.stop();
outgoingRinger.stop();
if (playDisconnected) {
float volume = ringVolumeWithMinimum(audioManager);
soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f);
}
audioManager.setMode(AudioManager.MODE_NORMAL);
audioManagerCompat.abandonCallAudioFocus();
}
private static float ringVolumeWithMinimum(@NonNull AudioManager audioManager) {
int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING);
int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING);
float volume = logVolume(currentVolume, maxVolume);
float minVolume = logVolume(15, 100);
return Math.max(volume, minVolume);
}
private static float logVolume(int volume, int maxVolume) {
if (maxVolume == 0 || volume > maxVolume) {
return 0.5f;
}
return (float) (1 - (Math.log(maxVolume + 1 - volume) / Math.log(maxVolume + 1)));
}
}

View File

@@ -0,0 +1,373 @@
package org.thoughtcrime.securesms.webrtc.audio
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.media.SoundPool
import android.net.Uri
import android.os.Build
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.whispersystems.libsignal.util.guava.Preconditions
private val TAG = Log.tag(SignalAudioManager::class.java)
/**
* Manage all audio and bluetooth routing for calling. Primarily, operates by maintaining a list
* of available devices (wired, speaker, bluetooth, earpiece) and then using a state machine to determine
* which device to use. Inputs into the decision include the [defaultAudioDevice] (set based on if audio
* only or video call) and [userSelectedAudioDevice] (set by user interaction with UI). [autoSwitchToWiredHeadset]
* and [autoSwitchToBluetooth] also impact the decision by forcing the user selection to the respective device
* when initially discovered. If the user switches to another device while bluetooth or wired headset are
* connected, the system will not auto switch back until the audio device is disconnected and reconnected.
*
* For example, call starts with speaker, then a bluetooth headset is connected. The audio will automatically
* switch to the headset. The user can then switch back to speaker through a manual interaction. If the
* bluetooth headset is then disconnected, and reconnected, the audio will again automatically switch to
* the bluetooth headset.
*/
class SignalAudioManager(private val context: Context, private val eventListener: EventListener?) {
private var commandAndControlThread = SignalExecutors.getAndStartHandlerThread("call-audio")
private val handler = SignalAudioHandler(commandAndControlThread.looper)
private val androidAudioManager = ApplicationDependencies.getAndroidCallAudioManager()
private val signalBluetoothManager = SignalBluetoothManager(context, this, handler)
private var state: State = State.UNINITIALIZED
private var savedAudioMode = AudioManager.MODE_INVALID
private var savedIsSpeakerPhoneOn = false
private var savedIsMicrophoneMute = false
private var hasWiredHeadset = false
private var autoSwitchToWiredHeadset = true
private var autoSwitchToBluetooth = true
private var defaultAudioDevice: AudioDevice = AudioDevice.EARPIECE
private var selectedAudioDevice: AudioDevice = AudioDevice.NONE
private var userSelectedAudioDevice: AudioDevice = AudioDevice.NONE
private var audioDevices: MutableSet<AudioDevice> = mutableSetOf()
private val soundPool: SoundPool = androidAudioManager.createSoundPool()
private val connectedSoundId = soundPool.load(context, R.raw.webrtc_completed, 1)
private val disconnectedSoundId = soundPool.load(context, R.raw.webrtc_disconnected, 1)
private val incomingRinger = IncomingRinger(context)
private val outgoingRinger = OutgoingRinger(context)
private var wiredHeadsetReceiver: WiredHeadsetReceiver? = null
fun handleCommand(command: AudioManagerCommand) {
handler.post {
when (command) {
is AudioManagerCommand.Initialize -> initialize()
is AudioManagerCommand.Start -> start()
is AudioManagerCommand.Stop -> stop(command.playDisconnect)
is AudioManagerCommand.SetDefaultDevice -> setDefaultAudioDevice(command.device, command.clearUserEarpieceSelection)
is AudioManagerCommand.SetUserDevice -> selectAudioDevice(command.device)
is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger(command.ringtoneUri, command.vibrate)
is AudioManagerCommand.SilenceIncomingRinger -> silenceIncomingRinger()
is AudioManagerCommand.StartOutgoingRinger -> startOutgoingRinger()
}
}
}
private fun initialize() {
Log.i(TAG, "Initializing audio manager state: $state")
if (state == State.UNINITIALIZED) {
savedAudioMode = androidAudioManager.mode
savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn
savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute
hasWiredHeadset = androidAudioManager.isWiredHeadsetOn
androidAudioManager.requestCallAudioFocus()
setMicrophoneMute(false)
audioDevices.clear()
signalBluetoothManager.start()
updateAudioDeviceState()
wiredHeadsetReceiver = WiredHeadsetReceiver()
context.registerReceiver(wiredHeadsetReceiver, IntentFilter(if (Build.VERSION.SDK_INT >= 21) AudioManager.ACTION_HEADSET_PLUG else Intent.ACTION_HEADSET_PLUG))
state = State.PREINITIALIZED
Log.d(TAG, "Initialized")
}
}
private fun start() {
Log.d(TAG, "Starting. state: $state")
if (state == State.RUNNING) {
Log.w(TAG, "Skipping, already active")
return
}
incomingRinger.stop()
outgoingRinger.stop()
state = State.RUNNING
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f)
Log.d(TAG, "Started")
}
private fun stop(playDisconnect: Boolean) {
Log.d(TAG, "Stopping. state: $state")
if (state == State.UNINITIALIZED) {
Log.i(TAG, "Trying to stop AudioManager in incorrect state: $state")
return
}
incomingRinger.stop()
outgoingRinger.stop()
if (playDisconnect) {
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f)
}
state = State.UNINITIALIZED
context.safeUnregisterReceiver(wiredHeadsetReceiver)
wiredHeadsetReceiver = null
signalBluetoothManager.stop()
setSpeakerphoneOn(savedIsSpeakerPhoneOn)
setMicrophoneMute(savedIsMicrophoneMute)
androidAudioManager.mode = savedAudioMode
androidAudioManager.abandonCallAudioFocus()
Log.d(TAG, "Abandoned audio focus for VOICE_CALL streams")
Log.d(TAG, "Stopped")
}
fun shutdown() {
handler.post {
stop(false)
if (commandAndControlThread != null) {
Log.i(TAG, "Shutting down command and control")
commandAndControlThread.quitSafely()
commandAndControlThread = null
}
}
}
fun updateAudioDeviceState() {
handler.assertHandlerThread()
Log.i(
TAG,
"updateAudioDeviceState(): " +
"wired: $hasWiredHeadset " +
"bt: ${signalBluetoothManager.state} " +
"available: $audioDevices " +
"selected: $selectedAudioDevice " +
"userSelected: $userSelectedAudioDevice"
)
if (signalBluetoothManager.state.shouldUpdate()) {
signalBluetoothManager.updateDevice()
}
val newAudioDevices = mutableSetOf(AudioDevice.SPEAKER_PHONE)
if (signalBluetoothManager.state.hasDevice()) {
newAudioDevices += AudioDevice.BLUETOOTH
}
if (hasWiredHeadset) {
newAudioDevices += AudioDevice.WIRED_HEADSET
} else {
autoSwitchToWiredHeadset = true
if (androidAudioManager.hasEarpiece(context)) {
newAudioDevices += AudioDevice.EARPIECE
}
}
var audioDeviceSetUpdated = audioDevices != newAudioDevices
audioDevices = newAudioDevices
if (signalBluetoothManager.state == SignalBluetoothManager.State.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
userSelectedAudioDevice = AudioDevice.NONE
}
if (hasWiredHeadset && autoSwitchToWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
userSelectedAudioDevice = AudioDevice.WIRED_HEADSET
autoSwitchToWiredHeadset = false
}
if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE
}
val needBluetoothAudioStart = signalBluetoothManager.state == SignalBluetoothManager.State.AVAILABLE &&
(userSelectedAudioDevice == AudioDevice.NONE || userSelectedAudioDevice == AudioDevice.BLUETOOTH || autoSwitchToBluetooth)
val needBluetoothAudioStop = (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED || signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTING) &&
(userSelectedAudioDevice != AudioDevice.NONE && userSelectedAudioDevice != AudioDevice.BLUETOOTH)
if (signalBluetoothManager.state.hasDevice()) {
Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop")
}
if (needBluetoothAudioStop) {
signalBluetoothManager.stopScoAudio()
signalBluetoothManager.updateDevice()
}
if (!autoSwitchToBluetooth && signalBluetoothManager.state == SignalBluetoothManager.State.UNAVAILABLE) {
autoSwitchToBluetooth = true
}
if (needBluetoothAudioStart && !needBluetoothAudioStop) {
if (!signalBluetoothManager.startScoAudio()) {
audioDevices.remove(AudioDevice.BLUETOOTH)
audioDeviceSetUpdated = true
}
}
if (autoSwitchToBluetooth && signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
userSelectedAudioDevice = AudioDevice.BLUETOOTH
autoSwitchToBluetooth = false
}
val newAudioDevice: AudioDevice = when {
audioDevices.contains(userSelectedAudioDevice) -> userSelectedAudioDevice
audioDevices.contains(defaultAudioDevice) -> defaultAudioDevice
else -> AudioDevice.SPEAKER_PHONE
}
if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) {
setAudioDevice(newAudioDevice)
Log.i(TAG, "New device status: available: $audioDevices, selected: $newAudioDevice")
eventListener?.onAudioDeviceChanged(selectedAudioDevice, audioDevices)
}
}
private fun setDefaultAudioDevice(newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) {
Log.d(TAG, "setDefaultAudioDevice(): currentDefault: $defaultAudioDevice device: $newDefaultDevice clearUser: $clearUserEarpieceSelection")
defaultAudioDevice = when (newDefaultDevice) {
AudioDevice.SPEAKER_PHONE -> newDefaultDevice
AudioDevice.EARPIECE -> {
if (androidAudioManager.hasEarpiece(context)) {
newDefaultDevice
} else {
AudioDevice.SPEAKER_PHONE
}
}
else -> throw AssertionError("Invalid default audio device selection")
}
if (clearUserEarpieceSelection && userSelectedAudioDevice == AudioDevice.EARPIECE) {
Log.d(TAG, "Clearing user setting of earpiece")
userSelectedAudioDevice = AudioDevice.NONE
}
Log.d(TAG, "New default: $defaultAudioDevice userSelected: $userSelectedAudioDevice")
updateAudioDeviceState()
}
private fun selectAudioDevice(device: AudioDevice) {
val actualDevice = if (device == AudioDevice.EARPIECE && audioDevices.contains(AudioDevice.WIRED_HEADSET)) AudioDevice.WIRED_HEADSET else device
Log.d(TAG, "selectAudioDevice(): device: $device actualDevice: $actualDevice")
if (!audioDevices.contains(actualDevice)) {
Log.w(TAG, "Can not select $actualDevice from available $audioDevices")
}
userSelectedAudioDevice = actualDevice
updateAudioDeviceState()
}
private fun setAudioDevice(device: AudioDevice) {
Log.d(TAG, "setAudioDevice(): device: $device")
Preconditions.checkArgument(audioDevices.contains(device))
when (device) {
AudioDevice.SPEAKER_PHONE -> setSpeakerphoneOn(true)
AudioDevice.EARPIECE -> setSpeakerphoneOn(false)
AudioDevice.WIRED_HEADSET -> setSpeakerphoneOn(false)
AudioDevice.BLUETOOTH -> setSpeakerphoneOn(false)
else -> throw AssertionError("Invalid audio device selection")
}
selectedAudioDevice = device
}
private fun setSpeakerphoneOn(on: Boolean) {
if (androidAudioManager.isSpeakerphoneOn != on) {
androidAudioManager.isSpeakerphoneOn = on
}
}
private fun setMicrophoneMute(on: Boolean) {
if (androidAudioManager.isMicrophoneMute != on) {
androidAudioManager.isMicrophoneMute = on
}
}
private fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) {
Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate")
androidAudioManager.mode = AudioManager.MODE_RINGTONE
setMicrophoneMute(false)
setDefaultAudioDevice(AudioDevice.SPEAKER_PHONE, false)
incomingRinger.start(ringtoneUri, vibrate)
}
private fun silenceIncomingRinger() {
Log.i(TAG, "silenceIncomingRinger():")
incomingRinger.stop()
}
private fun startOutgoingRinger() {
Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice")
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setMicrophoneMute(false)
outgoingRinger.start(OutgoingRinger.Type.RINGING)
}
private fun onWiredHeadsetChange(pluggedIn: Boolean, hasMic: Boolean) {
Log.i(TAG, "onWiredHeadsetChange state: $state plug: $pluggedIn mic: $hasMic")
hasWiredHeadset = pluggedIn
updateAudioDeviceState()
}
private inner class WiredHeadsetReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pluggedIn = intent.getIntExtra("state", 0) == 1
val hasMic = intent.getIntExtra("microphone", 0) == 1
handler.post { onWiredHeadsetChange(pluggedIn, hasMic) }
}
}
enum class AudioDevice {
SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE
}
enum class State {
UNINITIALIZED, PREINITIALIZED, RUNNING
}
interface EventListener {
@JvmSuppressWildcards
fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set<AudioDevice>)
}
}

View File

@@ -0,0 +1,355 @@
package org.thoughtcrime.securesms.webrtc.audio
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 org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import java.util.concurrent.TimeUnit
/**
* Manages the bluetooth lifecycle with a headset. This class doesn't make any
* determination on if bluetooth should be used. It determines if a device is connected,
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [SignalAudioManager].
*/
class SignalBluetoothManager(
private val context: Context,
private val audioManager: SignalAudioManager,
private val handler: SignalAudioHandler
) {
var state: State = State.UNINITIALIZED
get() {
handler.assertHandlerThread()
return field
}
private set
private var bluetoothAdapter: BluetoothAdapter? = null
private var bluetoothDevice: BluetoothDevice? = null
private var bluetoothHeadset: BluetoothHeadset? = null
private var scoConnectionAttempts = 0
private val androidAudioManager = ApplicationDependencies.getAndroidCallAudioManager()
private val bluetoothListener = BluetoothServiceListener()
private var bluetoothReceiver: BluetoothHeadsetBroadcastReceiver? = null
private val bluetoothTimeout = { onBluetoothTimeout() }
fun start() {
handler.assertHandlerThread()
Log.d(TAG, "start(): $state")
if (state != State.UNINITIALIZED) {
Log.w(TAG, "Invalid starting state")
return
}
bluetoothHeadset = null
bluetoothDevice = null
scoConnectionAttempts = 0
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
Log.i(TAG, "Device does not support Bluetooth")
return
}
if (!androidAudioManager.isBluetoothScoAvailableOffCall) {
Log.w(TAG, "Bluetooth SCO audio is not available off call")
return
}
if (bluetoothAdapter?.getProfileProxy(context, bluetoothListener, BluetoothProfile.HEADSET) != true) {
Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed")
return
}
val bluetoothHeadsetFilter = IntentFilter().apply {
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
}
bluetoothReceiver = BluetoothHeadsetBroadcastReceiver()
context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter)
Log.i(TAG, "Headset profile state: ${bluetoothAdapter?.getProfileConnectionState(BluetoothProfile.HEADSET)?.toStateString()}")
Log.i(TAG, "Bluetooth proxy for headset profile has started")
state = State.UNAVAILABLE
}
fun stop() {
handler.assertHandlerThread()
Log.d(TAG, "stop(): state: $state")
if (bluetoothAdapter == null) {
return
}
stopScoAudio()
if (state == State.UNINITIALIZED) {
return
}
context.safeUnregisterReceiver(bluetoothReceiver)
bluetoothReceiver = null
cancelTimer()
if (bluetoothHeadset != null) {
bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset)
bluetoothHeadset = null
}
bluetoothAdapter = null
bluetoothDevice = null
state = State.UNINITIALIZED
}
fun startScoAudio(): Boolean {
handler.assertHandlerThread()
Log.i(TAG, "startScoAudio(): $state attempts: $scoConnectionAttempts")
if (scoConnectionAttempts >= MAX_CONNECTION_ATTEMPTS) {
Log.w(TAG, "SCO connection attempts maxed out")
return false
}
if (state != State.AVAILABLE) {
Log.w(TAG, "SCO connection failed as no headset available")
return false
}
state = State.CONNECTING
androidAudioManager.startBluetoothSco()
androidAudioManager.isBluetoothScoOn = true
scoConnectionAttempts++
startTimer()
return true
}
fun stopScoAudio() {
handler.assertHandlerThread()
Log.i(TAG, "stopScoAudio(): $state")
if (state != State.CONNECTING && state != State.CONNECTED) {
return
}
cancelTimer()
androidAudioManager.stopBluetoothSco()
androidAudioManager.isBluetoothScoOn = false
state = State.DISCONNECTING
}
fun updateDevice() {
handler.assertHandlerThread()
Log.d(TAG, "updateDevice(): state: $state")
if (state == State.UNINITIALIZED || bluetoothHeadset == null) {
return
}
val devices: List<BluetoothDevice>? = bluetoothHeadset?.connectedDevices
if (devices == null || devices.isEmpty()) {
bluetoothDevice = null
state = State.UNAVAILABLE
Log.i(TAG, "No connected bluetooth headset")
} else {
bluetoothDevice = devices[0]
state = State.AVAILABLE
Log.i(TAG, "Connected bluetooth headset. headsetState: ${bluetoothHeadset?.getConnectionState(bluetoothDevice)?.toStateString()} scoAudio: ${bluetoothHeadset?.isAudioConnected(bluetoothDevice)}")
}
}
private fun updateAudioDeviceState() {
audioManager.updateAudioDeviceState()
}
private fun startTimer() {
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
}
private fun cancelTimer() {
handler.removeCallbacks(bluetoothTimeout)
}
private fun onBluetoothTimeout() {
Log.i(TAG, "onBluetoothTimeout: state: $state bluetoothHeadset: $bluetoothHeadset")
if (state == State.UNINITIALIZED || bluetoothHeadset == null || state != State.CONNECTING) {
return
}
var scoConnected = false
val devices: List<BluetoothDevice>? = bluetoothHeadset?.connectedDevices
if (devices != null && devices.isNotEmpty()) {
bluetoothDevice = devices[0]
if (bluetoothHeadset?.isAudioConnected(bluetoothDevice) == true) {
Log.d(TAG, "Connected with $bluetoothDevice")
scoConnected = true
} else {
Log.d(TAG, "Not connected with $bluetoothDevice")
}
}
if (scoConnected) {
Log.i(TAG, "Device actually connected and not timed out")
state = State.CONNECTED
scoConnectionAttempts = 0
} else {
Log.w(TAG, "Failed to connect after timeout")
stopScoAudio()
}
updateAudioDeviceState()
}
private fun onServiceConnected(proxy: BluetoothHeadset?) {
bluetoothHeadset = proxy
updateAudioDeviceState()
}
private fun onServiceDisconnected() {
stopScoAudio()
bluetoothHeadset = null
bluetoothDevice = null
state = State.UNAVAILABLE
updateAudioDeviceState()
}
private fun onHeadsetConnectionStateChanged(connectionState: Int) {
Log.i(TAG, "onHeadsetConnectionStateChanged: state: $state connectionState: ${connectionState.toStateString()}")
when (connectionState) {
BluetoothHeadset.STATE_CONNECTED -> {
scoConnectionAttempts = 0
updateAudioDeviceState()
}
BluetoothHeadset.STATE_DISCONNECTED -> {
stopScoAudio()
updateAudioDeviceState()
}
}
}
private fun onAudioStateChanged(audioState: Int, isInitialStateChange: Boolean) {
Log.i(TAG, "onAudioStateChanged: state: $state audioState: ${audioState.toStateString()} initialSticky: $isInitialStateChange")
if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
cancelTimer()
if (state === State.CONNECTING) {
Log.d(TAG, "Bluetooth audio SCO is now connected")
state = State.CONNECTED
scoConnectionAttempts = 0
updateAudioDeviceState()
} else {
Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
}
} else if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
Log.d(TAG, "Bluetooth audio SCO is now connecting...")
} else if (audioState == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
Log.d(TAG, "Bluetooth audio SCO is now disconnected")
if (isInitialStateChange) {
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
return
}
updateAudioDeviceState()
}
}
private inner class BluetoothServiceListener : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
if (profile == BluetoothProfile.HEADSET) {
handler.post {
if (state != State.UNINITIALIZED) {
onServiceConnected(proxy as? BluetoothHeadset)
}
}
}
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.HEADSET) {
handler.post {
if (state != State.UNINITIALIZED) {
onServiceDisconnected()
}
}
}
}
}
private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)
handler.post {
if (state != State.UNINITIALIZED) {
onHeadsetConnectionStateChanged(connectionState)
}
}
} else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) {
val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
handler.post {
if (state != State.UNINITIALIZED) {
onAudioStateChanged(connectionState, isInitialStickyBroadcast)
}
}
}
}
}
enum class State {
UNINITIALIZED,
UNAVAILABLE,
AVAILABLE,
DISCONNECTING,
CONNECTING,
CONNECTED,
ERROR;
fun shouldUpdate(): Boolean {
return this == AVAILABLE || this == UNAVAILABLE || this == DISCONNECTING
}
fun hasDevice(): Boolean {
return this == CONNECTED || this == CONNECTING || this == AVAILABLE
}
}
companion object {
private val TAG = Log.tag(SignalBluetoothManager::class.java)
private val SCO_TIMEOUT = TimeUnit.SECONDS.toMillis(4)
private const val MAX_CONNECTION_ATTEMPTS = 2
}
}
private fun Int.toStateString(): String {
return when (this) {
BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED"
BluetoothAdapter.STATE_CONNECTED -> "CONNECTED"
BluetoothAdapter.STATE_CONNECTING -> "CONNECTING"
BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING"
BluetoothAdapter.STATE_OFF -> "OFF"
BluetoothAdapter.STATE_ON -> "ON"
BluetoothAdapter.STATE_TURNING_OFF -> "TURNING_OFF"
BluetoothAdapter.STATE_TURNING_ON -> "TURNING_ON"
else -> "UNKNOWN"
}
}