mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-24 22:17:25 +00:00
feat: audio manager call service boilerplate
This commit is contained in:
@@ -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" />
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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
|
||||
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
@@ -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")
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
@@ -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()
|
||||
|
||||
}
|
@@ -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>)
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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>)
|
||||
}
|
||||
}
|
@@ -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"
|
||||
}
|
||||
}
|
BIN
app/src/main/res/raw/redphone_busy.mp3
Normal file
BIN
app/src/main/res/raw/redphone_busy.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/redphone_outring.mp3
Normal file
BIN
app/src/main/res/raw/redphone_outring.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/webrtc_completed.mp3
Normal file
BIN
app/src/main/res/raw/webrtc_completed.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/webrtc_disconnected.mp3
Normal file
BIN
app/src/main/res/raw/webrtc_disconnected.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user