feat: audio manager call service boilerplate

This commit is contained in:
jubb
2021-10-27 15:50:00 +11:00
parent 40d9386a81
commit 7ed29cc7d8
18 changed files with 1147 additions and 37 deletions

View File

@@ -30,6 +30,7 @@
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />

View File

@@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.dependencies.CallComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.groups.OpenGroupManager;
@@ -140,6 +141,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return (ApplicationContext) context.getApplicationContext();
}
public CallComponent getCallComponent() {
return EntryPoints.get(getApplicationContext(), CallComponent.class);
}
public DatabaseComponent getDatabaseComponent() {
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CallComponent {
companion object {
@JvmStatic
fun get(context: Context) = ApplicationContext.getInstance(context).callComponent
}
fun callManagerCompat(): AudioManagerCompat
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.webrtc.CallManager
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CallModule {
@Provides
@Singleton
fun provideAudioManagerCompat(@ApplicationContext context: Context) = AudioManagerCompat.create(context)
@Provides
@Singleton
fun provideCallManager(@ApplicationContext context: Context, storage: Storage) =
CallManager(context, storage)
}

View File

@@ -6,23 +6,26 @@ import android.content.Intent
import android.os.IBinder
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import org.thoughtcrime.securesms.dependencies.CallComponent
import org.thoughtcrime.securesms.webrtc.AudioManagerCommand
import org.thoughtcrime.securesms.webrtc.CallManager
import org.thoughtcrime.securesms.webrtc.RTCAudioManager
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
import java.util.*
import javax.inject.Inject
@AndroidEntryPoint
class WebRtcCallService: Service(), RTCAudioManager.EventListener {
class WebRtcCallService: Service(), SignalAudioManager.EventListener {
@Inject lateinit var callManager: CallManager
val signalAudioManager: SignalAudioManager by lazy {
SignalAudioManager(this, this, CallComponent.get(this).callManagerCompat())
}
companion object {
private const val ACTION_UPDATE = "UPDATE"
private const val ACTION_STOP = "STOP"
private const val ACTION_DENY_CALL = "DENY_CALL"
private const val ACTION_LOCAL_HANGUP = "LOCAL_HANGUP"
private const val ACTION_WANTS_BLUETOOTH = "WANTS_BLUETOOTH"
private const val ACTION_CHANGE_POWER_BUTTON = "CHANGE_POWER_BUTTON"
private const val ACTION_SEND_AUDIO_COMMAND = "SEND_AUDIO_COMMAND"
@@ -84,4 +87,8 @@ class WebRtcCallService: Service(), RTCAudioManager.EventListener {
// unregister network receiver
// unregister power button
}
override fun onAudioDeviceChanged(activeDevice: SignalAudioManager.AudioDevice, devices: Set<SignalAudioManager.AudioDevice>) {
TODO("Not yet implemented")
}
}

View File

@@ -2,12 +2,32 @@ package org.thoughtcrime.securesms.webrtc
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
@Parcelize
sealed class AudioManagerCommand: Parcelable {
@Parcelize object StartOutgoingRinger: AudioManagerCommand()
@Parcelize object SilenceIncomingRinger: AudioManagerCommand()
@Parcelize object Start: AudioManagerCommand()
@Parcelize object Stop: AudioManagerCommand()
@Parcelize object SetUserDevice: AudioManagerCommand()
open class AudioManagerCommand: Parcelable {
@Parcelize
object Initialize: AudioManagerCommand()
@Parcelize
object StartOutgoingRinger: AudioManagerCommand()
@Parcelize
object SilenceIncomingRinger: AudioManagerCommand()
@Parcelize
object Start: AudioManagerCommand()
@Parcelize
data class Stop(val playDisconnect: Boolean): AudioManagerCommand()
@Parcelize
data class StartIncomingRinger(val vibrate: Boolean): AudioManagerCommand()
@Parcelize
data class SetUserDevice(val device: SignalAudioManager.AudioDevice): AudioManagerCommand()
@Parcelize
data class SetDefaultDevice(val device: SignalAudioManager.AudioDevice,
val clearUserEarpieceSelection: Boolean): AudioManagerCommand()
}

View File

@@ -1,4 +1,17 @@
package org.thoughtcrime.securesms.webrtc
class CallManager {
import android.content.Context
import com.android.mms.transaction.MessageSender
import org.thoughtcrime.securesms.database.Storage
import java.util.concurrent.Executors
import javax.inject.Inject
class CallManager @Inject constructor(
private val context: Context,
private val storage: Storage,
) {
private val serviceExecutor = Executors.newSingleThreadExecutor()
private val networkExecutor = Executors.newSingleThreadExecutor()
}

View File

@@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.webrtc
import android.content.Context
import android.media.AudioManager
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import org.thoughtcrime.securesms.webrtc.audio.IncomingRinger
class RTCAudioManager(context: Context, deviceChangeListener: (currentDevice: AudioDevice?, availableDevices: Collection<AudioDevice>)->Unit) {
enum class AudioDevice {
SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, NONE
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val incomingRinger = IncomingRinger(context)
private val stateChannel = Channel<AudioEvent>()
interface EventListener {
fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set<AudioDevice>)
}
}

View File

@@ -0,0 +1,223 @@
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;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.session.libsession.utilities.ServiceUtil;
import org.session.libsignal.utilities.Log;
public abstract class AudioManagerCompat {
private static final String TAG = Log.tag(AudioManagerCompat.class);
private static final int AUDIOFOCUS_GAIN = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE;
protected final AudioManager audioManager;
@SuppressWarnings("CodeBlock2Expr")
protected final AudioManager.OnAudioFocusChangeListener onAudioFocusChangeListener = focusChange -> {
Log.i(TAG, "onAudioFocusChangeListener: " + focusChange);
};
private AudioManagerCompat(@NonNull Context context) {
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() {
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();
public static AudioManagerCompat create(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= 26) {
return new Api26AudioManagerCompat(context);
} else {
return new Api21AudioManagerCompat(context);
}
}
@RequiresApi(26)
private static class Api26AudioManagerCompat extends AudioManagerCompat {
private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build();
private AudioFocusRequest audioFocusRequest;
private Api26AudioManagerCompat(@NonNull Context context) {
super(context);
}
@Override
public SoundPool createSoundPool() {
return new SoundPool.Builder()
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setMaxStreams(1)
.build();
}
@Override
public void requestCallAudioFocus() {
if (audioFocusRequest != null) {
Log.w(TAG, "Already requested audio focus. Ignoring...");
return;
}
audioFocusRequest = new AudioFocusRequest.Builder(AUDIOFOCUS_GAIN)
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setOnAudioFocusChangeListener(onAudioFocusChangeListener)
.build();
int result = audioManager.requestAudioFocus(audioFocusRequest);
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Audio focus not granted. Result code: " + result);
}
}
@Override
public void abandonCallAudioFocus() {
if (audioFocusRequest == null) {
Log.w(TAG, "Don't currently have audio focus. Ignoring...");
return;
}
int result = audioManager.abandonAudioFocusRequest(audioFocusRequest);
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Audio focus abandon failed. Result code: " + result);
}
audioFocusRequest = null;
}
}
@RequiresApi(21)
private static class Api21AudioManagerCompat extends AudioManagerCompat {
private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
.build();
private Api21AudioManagerCompat(@NonNull Context context) {
super(context);
}
@Override
public SoundPool createSoundPool() {
return new SoundPool.Builder()
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setMaxStreams(1)
.build();
}
@Override
public void requestCallAudioFocus() {
int result = audioManager.requestAudioFocus(onAudioFocusChangeListener, AudioManager.STREAM_VOICE_CALL, AUDIOFOCUS_GAIN);
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Audio focus not granted. Result code: " + result);
}
}
@Override
public void abandonCallAudioFocus() {
int result = audioManager.abandonAudioFocus(onAudioFocusChangeListener);
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Audio focus abandon failed. Result code: " + result);
}
}
}
}

View File

@@ -5,6 +5,7 @@ import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Vibrator
import org.session.libsession.utilities.ServiceUtil
import org.session.libsignal.utilities.Log
class IncomingRinger(private val context: Context) {
@@ -13,11 +14,11 @@ class IncomingRinger(private val context: Context) {
val PATTERN = longArrayOf(0L, 1000L, 1000L)
}
private val vibrator: Vibrator? = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator?
private val vibrator: Vibrator? = ServiceUtil.getVibrator(context)
var mediaPlayer: MediaPlayer? = null
fun start(vibrate: Boolean) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val audioManager = ServiceUtil.getAudioManager(context)
mediaPlayer?.release()
mediaPlayer = createMediaPlayer()
val ringerMode = audioManager.ringerMode
@@ -62,7 +63,7 @@ class IncomingRinger(private val context: Context) {
else ringerMode == AudioManager.RINGER_MODE_VIBRATE
}
fun createMediaPlayer(): MediaPlayer? {
private fun createMediaPlayer(): MediaPlayer? {
try {
val defaultRingtone = try {
RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.webrtc.audio
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import network.loki.messenger.R
import org.session.libsignal.utilities.Log
import java.io.IOException
import java.lang.IllegalArgumentException
import java.lang.IllegalStateException
class OutgoingRinger(private val context: Context) {
enum class Type {
RINGING, BUSY
}
private var mediaPlayer: MediaPlayer? = null
fun start(type: Type) {
val soundId: Int = if (type == Type.RINGING) R.raw.redphone_outring else if (type == Type.BUSY) R.raw.redphone_busy else throw IllegalArgumentException("Not a valid sound type")
if (mediaPlayer != null) {
mediaPlayer!!.release()
}
mediaPlayer = MediaPlayer()
mediaPlayer!!.setAudioAttributes(AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build())
mediaPlayer!!.isLooping = true
val packageName = context.packageName
val dataUri = Uri.parse("android.resource://$packageName/$soundId")
try {
mediaPlayer!!.setDataSource(context, dataUri)
mediaPlayer!!.prepare()
mediaPlayer!!.start()
} catch (e: IllegalArgumentException) {
Log.e(TAG, e)
} catch (e: SecurityException) {
Log.e(TAG, e)
} catch (e: IllegalStateException) {
Log.e(TAG, e)
} catch (e: IOException) {
Log.e(TAG, e)
}
}
fun stop() {
if (mediaPlayer == null) return
mediaPlayer!!.release()
mediaPlayer = null
}
companion object {
private val TAG: String = Log.tag(OutgoingRinger::class.java)
}
}

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

@@ -0,0 +1,379 @@
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 android.os.HandlerThread
import network.loki.messenger.R
import org.session.libsession.utilities.concurrent.SignalExecutors
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.webrtc.AudioManagerCommand
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 val androidAudioManager: AudioManagerCompat) {
private var commandAndControlThread: HandlerThread? = HandlerThread("call-audio").apply { start() }
private val handler = SignalAudioHandler(commandAndControlThread!!.looper)
private val signalBluetoothManager = SignalBluetoothManager(context, this, androidAudioManager, 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.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
wiredHeadsetReceiver?.let { receiver ->
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
Log.e(TAG, "error unregistering wiredHeadsetReceiver", e)
}
}
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.WIRED_HEADSET
autoSwitchToWiredHeadset = false
}
if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
userSelectedAudioDevice = AudioDevice.NONE
}
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")
if (!audioDevices.contains(device)) return
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(vibrate: Boolean) {
Log.i(TAG, "startIncomingRinger(): vibrate: $vibrate")
androidAudioManager.mode = AudioManager.MODE_RINGTONE
setMicrophoneMute(false)
setDefaultAudioDevice(AudioDevice.SPEAKER_PHONE, false)
incomingRinger.start(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,359 @@
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.session.libsignal.utilities.Log
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 androidAudioManager: AudioManagerCompat,
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 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
}
bluetoothReceiver?.let { receiver ->
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
Log.e(TAG,"error unregistering bluetoothReceiver", e)
}
}
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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.