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