mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 09:49:30 +01:00
Refactor call audio routing and bluetooth management.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user