diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5452b8b8d7..0b5c2cbae1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -30,6 +30,7 @@
android:name="android.hardware.touchscreen"
android:required="false" />
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
index cfb7be88cb..6675e94ad9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
@@ -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);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallComponent.kt
new file mode 100644
index 0000000000..6f10390dd1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallComponent.kt
@@ -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
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt
new file mode 100644
index 0000000000..2becfdf54d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt
@@ -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)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt
index 78bc402294..0a80d4f5ae 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt
@@ -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) {
+ TODO("Not yet implemented")
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt
index 7b1d69a80d..7bc0d0bd5e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt
@@ -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()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt
index dde706a507..4c0a4ead2e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt
@@ -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()
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/RTCAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/RTCAudioManager.kt
deleted file mode 100644
index 6440871ad4..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/RTCAudioManager.kt
+++ /dev/null
@@ -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)->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()
-
- interface EventListener {
- fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set)
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java
new file mode 100644
index 0000000000..da50cd0846
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java
@@ -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);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt
index 86acba6a4b..bacc7a0598 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt
@@ -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)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.kt
new file mode 100644
index 0000000000..465866931a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt
new file mode 100644
index 0000000000..89eba2a3aa
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt
new file mode 100644
index 0000000000..f0ab170830
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt
@@ -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 = 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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt
new file mode 100644
index 0000000000..e7f93f5f8d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt
@@ -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? = 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? = 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"
+ }
+}
diff --git a/app/src/main/res/raw/redphone_busy.mp3 b/app/src/main/res/raw/redphone_busy.mp3
new file mode 100644
index 0000000000..865005e512
Binary files /dev/null and b/app/src/main/res/raw/redphone_busy.mp3 differ
diff --git a/app/src/main/res/raw/redphone_outring.mp3 b/app/src/main/res/raw/redphone_outring.mp3
new file mode 100644
index 0000000000..7442bf7d5b
Binary files /dev/null and b/app/src/main/res/raw/redphone_outring.mp3 differ
diff --git a/app/src/main/res/raw/webrtc_completed.mp3 b/app/src/main/res/raw/webrtc_completed.mp3
new file mode 100644
index 0000000000..22ec4647ef
Binary files /dev/null and b/app/src/main/res/raw/webrtc_completed.mp3 differ
diff --git a/app/src/main/res/raw/webrtc_disconnected.mp3 b/app/src/main/res/raw/webrtc_disconnected.mp3
new file mode 100644
index 0000000000..9985151315
Binary files /dev/null and b/app/src/main/res/raw/webrtc_disconnected.mp3 differ