Merge pull request #2727 from vector-im/feature/fga/voip_fix_audio

Feature/fga/voip fix audio
This commit is contained in:
ganfra 2021-01-29 16:32:48 +01:00 committed by GitHub
commit 73f9ef4232
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 806 additions and 473 deletions

View file

@ -126,7 +126,16 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
private fun handleCallRejectEvent(event: Event) {
val content = event.getClearContent().toModel<CallRejectContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
activeCallHandler.removeCall(content.callId)
if (event.senderId == userId) {
// discard current call, it's rejected by another of my session
callListenersDispatcher.onCallManagedByOtherSession(content.callId)
return
}
// No need to check party_id for reject because if we'd received either
// an answer or reject, we wouldn't be in state InviteSent
if (call.state != CallState.Dialing) {
@ -177,6 +186,7 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
}
if (event.senderId == userId) {
// discard current call, it's answered by another of my session
activeCallHandler.removeCall(call.callId)
callListenersDispatcher.onCallManagedByOtherSession(content.callId)
} else {
if (call.opponentPartyId != null) {

View file

@ -161,7 +161,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt
enum class===87
enum class===88
### Do not import temporary legacy classes
import org.matrix.android.sdk.internal.legacy.riot===3

View file

@ -322,6 +322,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation "androidx.sharetarget:sharetarget:1.0.0"
implementation 'androidx.core:core-ktx:1.3.2'
implementation "androidx.media:media:1.2.1"
implementation "org.threeten:threetenbp:1.4.0:no-tzdb"
implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.7.0"

View file

@ -16,33 +16,76 @@
package im.vector.app.core.services
import android.app.NotificationChannel
import android.content.Context
import android.media.Ringtone
import android.media.RingtoneManager
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.Ringtone
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.getSystemService
import im.vector.app.R
import im.vector.app.features.notifications.NotificationUtils
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
class CallRingPlayerIncoming(
context: Context
context: Context,
private val notificationUtils: NotificationUtils
) {
private val applicationContext = context.applicationContext
private var r: Ringtone? = null
private var ringtone: Ringtone? = null
private var vibrator: Vibrator? = null
fun start() {
val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
r = RingtoneManager.getRingtone(applicationContext, notification)
Timber.v("## VOIP Starting ringing incomming")
r?.play()
private val VIBRATE_PATTERN = longArrayOf(0, 400, 600)
fun start(fromBg: Boolean) {
val audioManager = applicationContext.getSystemService<AudioManager>()
val incomingCallChannel = notificationUtils.getChannelForIncomingCall(fromBg)
val ringerMode = audioManager?.ringerMode
if (ringerMode == AudioManager.RINGER_MODE_NORMAL) {
playRingtoneIfNeeded(incomingCallChannel)
} else if (ringerMode == AudioManager.RINGER_MODE_VIBRATE) {
vibrateIfNeeded(incomingCallChannel)
}
}
private fun playRingtoneIfNeeded(incomingCallChannel: NotificationChannel?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && incomingCallChannel?.sound != null) {
Timber.v("Ringtone already configured by notification channel")
return
}
val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
ringtone = RingtoneManager.getRingtone(applicationContext, ringtoneUri)
Timber.v("Play ringtone for incoming call")
ringtone?.play()
}
private fun vibrateIfNeeded(incomingCallChannel: NotificationChannel?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && incomingCallChannel?.shouldVibrate().orFalse()) {
Timber.v("## Vibration already configured by notification channel")
return
}
vibrator = applicationContext.getSystemService()
Timber.v("Vibrate for incoming call")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(VIBRATE_PATTERN, 0)
vibrator?.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
vibrator?.vibrate(VIBRATE_PATTERN, 0)
}
}
fun stop() {
r?.stop()
ringtone?.stop()
ringtone = null
vibrator?.cancel()
vibrator = null
}
}
@ -55,12 +98,12 @@ class CallRingPlayerOutgoing(
private var player: MediaPlayer? = null
fun start() {
val audioManager = applicationContext.getSystemService<AudioManager>()!!
val audioManager: AudioManager? = applicationContext.getSystemService()
player?.release()
player = createPlayer()
// Check if sound is enabled
val ringerMode = audioManager.ringerMode
val ringerMode = audioManager?.ringerMode
if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) {
try {
if (player?.isPlaying == false) {
@ -89,14 +132,14 @@ class CallRingPlayerOutgoing(
mediaPlayer.setOnErrorListener(MediaPlayerErrorListener())
mediaPlayer.isLooping = true
if (Build.VERSION.SDK_INT <= 21) {
@Suppress("DEPRECATION")
mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING)
} else {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
mediaPlayer.setAudioAttributes(AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build())
} else {
@Suppress("DEPRECATION")
mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING)
}
return mediaPlayer
} catch (failure: Throwable) {

View file

@ -44,7 +44,7 @@ import timber.log.Timber
/**
* Foreground service to manage calls
*/
class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener {
class CallService : VectorService() {
private val connections = mutableMapOf<String, CallConnection>()
private val knownCalls = mutableSetOf<String>()
@ -58,9 +58,6 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
private var callRingPlayerIncoming: CallRingPlayerIncoming? = null
private var callRingPlayerOutgoing: CallRingPlayerOutgoing? = null
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
// A media button receiver receives and helps translate hardware media playback buttons,
// such as those found on wired and wireless headsets, into the appropriate callbacks in your app
private var mediaSession: MediaSessionCompat? = null
@ -82,20 +79,14 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
callManager = vectorComponent().webRtcCallManager()
avatarRenderer = vectorComponent().avatarRenderer()
alertManager = vectorComponent().alertManager()
callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext)
callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext, notificationUtils)
callRingPlayerOutgoing = CallRingPlayerOutgoing(applicationContext)
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this)
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this)
}
override fun onDestroy() {
super.onDestroy()
callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop()
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) }
wiredHeadsetStateReceiver = null
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) }
bluetoothHeadsetStateReceiver = null
mediaSession?.release()
mediaSession = null
}
@ -107,21 +98,17 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
setCallback(mediaSessionButtonCallback)
}
}
if (intent == null) {
// Service started again by the system.
// TODO What do we do here?
return START_STICKY
}
mediaSession?.let {
// This ensures that the correct callbacks to MediaSessionCompat.Callback
// will be triggered based on the incoming KeyEvent.
MediaButtonReceiver.handleIntent(it, intent)
}
when (intent.action) {
when (intent?.action) {
ACTION_INCOMING_RINGING_CALL -> {
mediaSession?.isActive = true
callRingPlayerIncoming?.start()
val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false)
callRingPlayerIncoming?.start(fromBg)
displayIncomingCallNotification(intent)
}
ACTION_OUTGOING_RINGING_CALL -> {
@ -145,15 +132,12 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
handleCallTerminated(intent)
}
else -> {
// Should not happen
callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop()
myStopSelf()
handleUnexpectedState(null)
}
}
// We want the system to restore the service if killed
return START_STICKY
return START_REDELIVER_INTENT
}
// ================================================================================
@ -167,10 +151,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
private fun displayIncomingCallNotification(intent: Intent) {
Timber.v("## VOIP displayIncomingCallNotification $intent")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val call = callManager.getCallById(callId) ?: return
if (knownCalls.contains(callId)) {
Timber.v("Call already notified $callId$")
return
val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId)
}
val isVideoCall = call.mxCall.isVideoCall
val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false)
@ -211,13 +193,14 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
private fun handleCallTerminated(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
alertManager.cancelAlert(callId)
if (!knownCalls.remove(callId)) {
Timber.v("Call terminated for unknown call $callId$")
handleUnexpectedState(callId)
return
}
val notification = notificationUtils.buildCallEndedNotification()
notificationManager.notify(callId.hashCode(), notification)
alertManager.cancelAlert(callId)
if (knownCalls.isEmpty()) {
mediaSession?.isActive = false
myStopSelf()
@ -234,11 +217,9 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
}
private fun displayOutgoingRingingCallNotification(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
val call = callManager.getCallById(callId) ?: return
if (knownCalls.contains(callId)) {
Timber.v("Call already notified $callId$")
return
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId)
}
val opponentMatrixItem = getOpponentMatrixItem(call)
Timber.v("displayOutgoingCallNotification : display the dedicated notification")
@ -260,10 +241,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
private fun displayCallInProgressNotification(intent: Intent) {
Timber.v("## VOIP displayCallInProgressNotification")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val call = callManager.getCallById(callId) ?: return
if (!knownCalls.contains(callId)) {
Timber.v("Call in progress for unknown call $callId$")
return
val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId)
}
val opponentMatrixItem = getOpponentMatrixItem(call)
alertManager.cancelAlert(callId)
@ -271,7 +250,27 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
mxCall = call.mxCall,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId
)
notificationManager.notify(callId.hashCode(), notification)
if (knownCalls.isEmpty()) {
startForeground(callId.hashCode(), notification)
} else {
notificationManager.notify(callId.hashCode(), notification)
}
knownCalls.add(callId)
}
private fun handleUnexpectedState(callId: String?) {
Timber.v("Fallback to clear everything")
callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop()
if (callId != null) {
notificationManager.cancel(callId.hashCode())
}
val notification = notificationUtils.buildCallEndedNotification()
startForeground(DEFAULT_NOTIFICATION_ID, notification)
if (knownCalls.isEmpty()) {
mediaSession?.isActive = false
myStopSelf()
}
}
fun addConnection(callConnection: CallConnection) {
@ -283,7 +282,7 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
}
companion object {
private const val NOTIFICATION_ID = 6480
private const val DEFAULT_NOTIFICATION_ID = 6480
private const val ACTION_INCOMING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_INCOMING_RINGING_CALL"
private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_OUTGOING_RINGING_CALL"
@ -346,14 +345,4 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
return this@CallService
}
}
override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP: onHeadsetEvent $event")
callManager.onWiredDeviceEvent(event)
}
override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
Timber.v("## VOIP: onBTHeadsetEvent $event")
callManager.onWirelessDeviceEvent(event)
}
}

View file

@ -20,7 +20,6 @@ import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes

View file

@ -1,318 +0,0 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.call
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.WiredHeadsetStateReceiver
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
import timber.log.Timber
import java.util.concurrent.Executors
class CallAudioManager(
val applicationContext: Context,
val configChange: (() -> Unit)?
) {
enum class SoundDevice {
PHONE,
SPEAKER,
HEADSET,
WIRELESS_HEADSET
}
// if all calls to audio manager not in the same thread it's not working well.
private val executor = Executors.newSingleThreadExecutor()
private var audioManager: AudioManager? = null
private var savedIsSpeakerPhoneOn = false
private var savedIsMicrophoneMute = false
private var savedAudioMode = AudioManager.MODE_INVALID
private var connectedBlueToothHeadset: BluetoothProfile? = null
private var wantsBluetoothConnection = false
private var bluetoothAdapter: BluetoothAdapter? = null
init {
executor.execute {
audioManager = applicationContext.getSystemService()
}
val bm = applicationContext.getSystemService<BluetoothManager>()
val adapter = bm?.adapter
Timber.d("## VOIP Bluetooth adapter $adapter")
bluetoothAdapter = adapter
adapter?.getProfileProxy(applicationContext, object : BluetoothProfile.ServiceListener {
override fun onServiceDisconnected(profile: Int) {
Timber.d("## VOIP onServiceDisconnected $profile")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = null
configChange?.invoke()
}
}
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
Timber.d("## VOIP onServiceConnected $profile , proxy:$proxy")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = proxy
configChange?.invoke()
}
}
}, BluetoothProfile.HEADSET)
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
// Called on the listener to notify if the audio focus for this listener has been changed.
// The |focusChange| value indicates whether the focus was gained, whether the focus was lost,
// and whether that loss is transient, or whether the new focus holder will hold it for an
// unknown amount of time.
Timber.v("## VOIP: Audio focus change $focusChange")
}
fun startForCall(mxCall: MxCall) {
Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}")
}
private fun setupAudioManager(mxCall: MxCall) {
Timber.v("## VOIP: AudioManager setupAudioManager ${mxCall.callId}")
val audioManager = audioManager ?: return
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn
savedIsMicrophoneMute = audioManager.isMicrophoneMute
savedAudioMode = audioManager.mode
// Request audio playout focus (without ducking) and install listener for changes in focus.
// Remove the deprecation forces us to use 2 different method depending on API level
@Suppress("DEPRECATION") val result = audioManager.requestAudioFocus(audioFocusChangeListener,
AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Timber.d("## VOIP Audio focus request granted for VOICE_CALL streams")
} else {
Timber.d("## VOIP Audio focus request failed")
}
// Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
// required to be in this mode when playout and/or recording starts for
// best possible VoIP performance.
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
// Always disable microphone mute during a WebRTC call.
setMicrophoneMute(false)
adjustCurrentSoundDevice(mxCall)
}
private fun adjustCurrentSoundDevice(mxCall: MxCall) {
val audioManager = audioManager ?: return
executor.execute {
if (mxCall.state == CallState.LocalRinging && !isHeadsetOn()) {
// Always use speaker if incoming call is in ringing state and a headset is not connected
Timber.v("##VOIP: AudioManager default to SPEAKER (it is ringing)")
setCurrentSoundDevice(SoundDevice.SPEAKER)
} else if (mxCall.isVideoCall && !isHeadsetOn()) {
// If there are no headset, start video output in speaker
// (you can't watch the video and have the phone close to your ear)
Timber.v("##VOIP: AudioManager default to speaker ")
setCurrentSoundDevice(SoundDevice.SPEAKER)
} else {
// if a wired headset is plugged, sound will be directed to it
// (can't really force earpiece when headset is plugged)
if (isBluetoothHeadsetConnected(audioManager)) {
Timber.v("##VOIP: AudioManager default to WIRELESS_HEADSET ")
setCurrentSoundDevice(SoundDevice.WIRELESS_HEADSET)
// try now in case already connected?
audioManager.isBluetoothScoOn = true
} else {
Timber.v("##VOIP: AudioManager default to PHONE/HEADSET ")
setCurrentSoundDevice(if (isWiredHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE)
}
}
}
}
fun onCallConnected(mxCall: MxCall) {
Timber.v("##VOIP: AudioManager call answered, adjusting current sound device")
setupAudioManager(mxCall)
}
fun getAvailableSoundDevices(): List<SoundDevice> {
return ArrayList<SoundDevice>().apply {
if (isBluetoothHeadsetOn()) add(SoundDevice.WIRELESS_HEADSET)
add(if (isWiredHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE)
add(SoundDevice.SPEAKER)
}
}
fun stop() {
Timber.v("## VOIP: AudioManager stopCall")
executor.execute {
// Restore previously stored audio states.
setSpeakerphoneOn(savedIsSpeakerPhoneOn)
setMicrophoneMute(savedIsMicrophoneMute)
audioManager?.mode = savedAudioMode
connectedBlueToothHeadset?.let {
if (audioManager != null && isBluetoothHeadsetConnected(audioManager!!)) {
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
audioManager?.isSpeakerphoneOn = false
}
bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, it)
}
audioManager?.mode = AudioManager.MODE_NORMAL
@Suppress("DEPRECATION")
audioManager?.abandonAudioFocus(audioFocusChangeListener)
}
}
fun getCurrentSoundDevice(): SoundDevice {
val audioManager = audioManager ?: return SoundDevice.PHONE
if (audioManager.isSpeakerphoneOn) {
return SoundDevice.SPEAKER
} else {
if (isBluetoothHeadsetConnected(audioManager)) return SoundDevice.WIRELESS_HEADSET
return if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE
}
}
private fun isBluetoothHeadsetConnected(audioManager: AudioManager) =
isBluetoothHeadsetOn()
&& !connectedBlueToothHeadset?.connectedDevices.isNullOrEmpty()
&& (wantsBluetoothConnection || audioManager.isBluetoothScoOn)
fun setCurrentSoundDevice(device: SoundDevice) {
executor.execute {
Timber.v("## VOIP setCurrentSoundDevice $device")
when (device) {
SoundDevice.HEADSET,
SoundDevice.PHONE -> {
wantsBluetoothConnection = false
if (isBluetoothHeadsetOn()) {
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
}
setSpeakerphoneOn(false)
}
SoundDevice.SPEAKER -> {
setSpeakerphoneOn(true)
wantsBluetoothConnection = false
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
}
SoundDevice.WIRELESS_HEADSET -> {
setSpeakerphoneOn(false)
// I cannot directly do it, i have to start then wait that it's connected
// to route to bt
audioManager?.startBluetoothSco()
wantsBluetoothConnection = true
}
}
configChange?.invoke()
}
}
fun bluetoothStateChange(plugged: Boolean) {
executor.execute {
if (plugged && wantsBluetoothConnection) {
audioManager?.isBluetoothScoOn = true
} else if (!plugged && !wantsBluetoothConnection) {
audioManager?.stopBluetoothSco()
}
configChange?.invoke()
}
}
fun wiredStateChange(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
executor.execute {
// if it's plugged and speaker is on we should route to headset
if (event.plugged && getCurrentSoundDevice() == SoundDevice.SPEAKER) {
setCurrentSoundDevice(CallAudioManager.SoundDevice.HEADSET)
} else if (!event.plugged) {
// if it's unplugged ? always route to speaker?
// this is questionable?
if (!wantsBluetoothConnection) {
setCurrentSoundDevice(SoundDevice.SPEAKER)
}
}
configChange?.invoke()
}
}
private fun isHeadsetOn(): Boolean {
return isWiredHeadsetOn() || (audioManager?.let { isBluetoothHeadsetConnected(it) } ?: false)
}
private fun isWiredHeadsetOn(): Boolean {
@Suppress("DEPRECATION")
return audioManager?.isWiredHeadsetOn ?: false
}
private fun isBluetoothHeadsetOn(): Boolean {
Timber.v("## VOIP: AudioManager isBluetoothHeadsetOn")
try {
if (connectedBlueToothHeadset == null) return false.also {
Timber.v("## VOIP: AudioManager no connected bluetooth headset")
}
if (audioManager?.isBluetoothScoAvailableOffCall == false) return false.also {
Timber.v("## VOIP: AudioManager isBluetoothScoAvailableOffCall false")
}
return true
} catch (failure: Throwable) {
Timber.e("## VOIP: AudioManager isBluetoothHeadsetOn failure ${failure.localizedMessage}")
return false
}
}
/** Sets the speaker phone mode. */
private fun setSpeakerphoneOn(on: Boolean) {
Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on")
val wasOn = audioManager?.isSpeakerphoneOn ?: false
if (wasOn == on) {
return
}
audioManager?.isSpeakerphoneOn = on
}
/** Sets the microphone mute state. */
private fun setMicrophoneMute(on: Boolean) {
Timber.v("## VOIP: AudioManager setMicrophoneMute $on")
val wasMuted = audioManager?.isMicrophoneMute ?: false
if (wasMuted == on) {
return
}
audioManager?.isMicrophoneMute = on
}
/** true if the device has a telephony radio with data
* communication support. */
private fun isThisPhone(): Boolean {
return applicationContext.packageManager.hasSystemFeature(
PackageManager.FEATURE_TELEPHONY)
}
}

View file

@ -27,6 +27,7 @@ import com.airbnb.mvrx.activityViewModel
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetCallControlsBinding
import im.vector.app.features.call.audio.CallAudioManager
import me.gujun.android.span.span
@ -79,22 +80,22 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
}
}
private fun showSoundDeviceChooser(available: List<CallAudioManager.SoundDevice>, current: CallAudioManager.SoundDevice) {
private fun showSoundDeviceChooser(available: Set<CallAudioManager.Device>, current: CallAudioManager.Device) {
val soundDevices = available.map {
when (it) {
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> span {
CallAudioManager.Device.WIRELESS_HEADSET -> span {
text = getString(R.string.sound_device_wireless_headset)
textStyle = if (current == it) "bold" else "normal"
}
CallAudioManager.SoundDevice.PHONE -> span {
CallAudioManager.Device.PHONE -> span {
text = getString(R.string.sound_device_phone)
textStyle = if (current == it) "bold" else "normal"
}
CallAudioManager.SoundDevice.SPEAKER -> span {
CallAudioManager.Device.SPEAKER -> span {
text = getString(R.string.sound_device_speaker)
textStyle = if (current == it) "bold" else "normal"
}
CallAudioManager.SoundDevice.HEADSET -> span {
CallAudioManager.Device.HEADSET -> span {
text = getString(R.string.sound_device_headset)
textStyle = if (current == it) "bold" else "normal"
}
@ -106,16 +107,16 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
when (soundDevices[n].toString()) {
// TODO Make an adapter and handle multiple Bluetooth headsets. Also do not use translations.
getString(R.string.sound_device_phone) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.PHONE))
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.PHONE))
}
getString(R.string.sound_device_speaker) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.SPEAKER))
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.SPEAKER))
}
getString(R.string.sound_device_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.HEADSET))
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.HEADSET))
}
getString(R.string.sound_device_wireless_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.WIRELESS_HEADSET))
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.WIRELESS_HEADSET))
}
}
}
@ -125,11 +126,11 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
private fun renderState(state: VectorCallViewState) {
views.callControlsSoundDevice.title = getString(R.string.call_select_sound_device)
views.callControlsSoundDevice.subTitle = when (state.soundDevice) {
CallAudioManager.SoundDevice.PHONE -> getString(R.string.sound_device_phone)
CallAudioManager.SoundDevice.SPEAKER -> getString(R.string.sound_device_speaker)
CallAudioManager.SoundDevice.HEADSET -> getString(R.string.sound_device_headset)
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset)
views.callControlsSoundDevice.subTitle = when (state.device) {
CallAudioManager.Device.PHONE -> getString(R.string.sound_device_phone)
CallAudioManager.Device.SPEAKER -> getString(R.string.sound_device_speaker)
CallAudioManager.Device.HEADSET -> getString(R.string.sound_device_headset)
CallAudioManager.Device.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset)
}
views.callControlsSwitchCamera.isVisible = state.isVideoCall && state.canSwitchCamera

View file

@ -17,6 +17,7 @@
package im.vector.app.features.call
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.audio.CallAudioManager
sealed class VectorCallViewActions : VectorViewModelAction {
object EndCall : VectorCallViewActions()
@ -25,7 +26,7 @@ sealed class VectorCallViewActions : VectorViewModelAction {
object ToggleMute : VectorCallViewActions()
object ToggleVideo : VectorCallViewActions()
object ToggleHoldResume: VectorCallViewActions()
data class ChangeAudioDevice(val device: CallAudioManager.SoundDevice) : VectorCallViewActions()
data class ChangeAudioDevice(val device: CallAudioManager.Device) : VectorCallViewActions()
object SwitchSoundDevice : VectorCallViewActions()
object HeadSetButtonPressed : VectorCallViewActions()
object ToggleCamera : VectorCallViewActions()

View file

@ -17,6 +17,7 @@
package im.vector.app.features.call
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.call.audio.CallAudioManager
import org.matrix.android.sdk.api.session.call.TurnServerResponse
sealed class VectorCallViewEvents : VectorViewEvents {
@ -24,8 +25,8 @@ sealed class VectorCallViewEvents : VectorViewEvents {
object DismissNoCall : VectorCallViewEvents()
data class ConnectionTimeout(val turn: TurnServerResponse?) : VectorCallViewEvents()
data class ShowSoundDeviceChooser(
val available: List<CallAudioManager.SoundDevice>,
val current: CallAudioManager.SoundDevice
val available: Set<CallAudioManager.Device>,
val current: CallAudioManager.Device
) : VectorCallViewEvents()
object ShowCallTransferScreen: VectorCallViewEvents()
// data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents()

View file

@ -25,6 +25,7 @@ import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.audio.CallAudioManager
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
import org.matrix.android.sdk.api.MatrixCallback
@ -133,17 +134,16 @@ class VectorCallViewModel @AssistedInject constructor(
}
override fun onAudioDevicesChange() {
val currentSoundDevice = callManager.callAudioManager.getCurrentSoundDevice()
if (currentSoundDevice == CallAudioManager.SoundDevice.PHONE) {
val currentSoundDevice = callManager.audioManager.selectedDevice ?: return
if (currentSoundDevice == CallAudioManager.Device.PHONE) {
proximityManager.start()
} else {
proximityManager.stop()
}
setState {
copy(
availableSoundDevices = callManager.callAudioManager.getAvailableSoundDevices(),
soundDevice = currentSoundDevice
availableDevices = callManager.audioManager.availableDevices,
device = currentSoundDevice
)
}
}
@ -174,8 +174,8 @@ class VectorCallViewModel @AssistedInject constructor(
callManager.addCurrentCallListener(currentCallListener)
val item: MatrixItem? = session.getUser(webRtcCall.mxCall.opponentUserId)?.toMatrixItem()
webRtcCall.addListener(callListener)
val currentSoundDevice = callManager.callAudioManager.getCurrentSoundDevice()
if (currentSoundDevice == CallAudioManager.SoundDevice.PHONE) {
val currentSoundDevice = callManager.audioManager.selectedDevice
if (currentSoundDevice == CallAudioManager.Device.PHONE) {
proximityManager.start()
}
setState {
@ -183,10 +183,10 @@ class VectorCallViewModel @AssistedInject constructor(
isVideoCall = webRtcCall.mxCall.isVideoCall,
callState = Success(webRtcCall.mxCall.state),
callInfo = VectorCallViewState.CallInfo(callId, item),
soundDevice = currentSoundDevice,
device = currentSoundDevice ?: CallAudioManager.Device.PHONE,
isLocalOnHold = webRtcCall.isLocalOnHold,
isRemoteOnHold = webRtcCall.remoteOnHold,
availableSoundDevices = callManager.callAudioManager.getAvailableSoundDevices(),
availableDevices = callManager.audioManager.availableDevices,
isFrontCamera = webRtcCall.currentCameraType() == CameraType.FRONT,
canSwitchCamera = webRtcCall.canSwitchCamera(),
formattedDuration = webRtcCall.formattedDuration(),
@ -242,16 +242,11 @@ class VectorCallViewModel @AssistedInject constructor(
call?.updateRemoteOnHold(!isRemoteOnHold)
}
is VectorCallViewActions.ChangeAudioDevice -> {
callManager.callAudioManager.setCurrentSoundDevice(action.device)
setState {
copy(
soundDevice = callManager.callAudioManager.getCurrentSoundDevice()
)
}
callManager.audioManager.setAudioDevice(action.device)
}
VectorCallViewActions.SwitchSoundDevice -> {
_viewEvents.post(
VectorCallViewEvents.ShowSoundDeviceChooser(state.availableSoundDevices, state.soundDevice)
VectorCallViewEvents.ShowSoundDeviceChooser(state.availableDevices, state.device)
)
}
VectorCallViewActions.HeadSetButtonPressed -> {

View file

@ -19,6 +19,7 @@ package im.vector.app.features.call
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.call.audio.CallAudioManager
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.util.MatrixItem
@ -34,8 +35,8 @@ data class VectorCallViewState(
val isHD: Boolean = false,
val isFrontCamera: Boolean = true,
val canSwitchCamera: Boolean = true,
val soundDevice: CallAudioManager.SoundDevice = CallAudioManager.SoundDevice.PHONE,
val availableSoundDevices: List<CallAudioManager.SoundDevice> = emptyList(),
val device: CallAudioManager.Device = CallAudioManager.Device.PHONE,
val availableDevices: Set<CallAudioManager.Device> = emptySet(),
val callState: Async<CallState> = Uninitialized,
val otherKnownCallInfo: CallInfo? = null,
val callInfo: CallInfo = CallInfo(callId),

View file

@ -0,0 +1,133 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION")
package im.vector.app.features.call.audio
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.media.AudioManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.BluetoothHeadsetReceiver
import im.vector.app.core.services.WiredHeadsetStateReceiver
import timber.log.Timber
import java.util.HashSet
internal class API21AudioDeviceDetector(private val context: Context,
private val audioManager: AudioManager,
private val callAudioManager: CallAudioManager
) : CallAudioManager.AudioDeviceDetector, WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener {
private var bluetoothAdapter: BluetoothAdapter? = null
private var connectedBlueToothHeadset: BluetoothProfile? = null
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
private val onAudioDeviceChangeRunner = Runnable {
val devices = getAvailableSoundDevices()
callAudioManager.replaceDevices(devices)
Timber.i(" Available audio devices: $devices")
callAudioManager.updateAudioRoute()
}
private fun getAvailableSoundDevices(): Set<CallAudioManager.Device> {
return HashSet<CallAudioManager.Device>().apply {
if (isBluetoothHeadsetOn()) add(CallAudioManager.Device.WIRELESS_HEADSET)
if (isWiredHeadsetOn()) {
add(CallAudioManager.Device.HEADSET)
} else {
add(CallAudioManager.Device.PHONE)
}
add(CallAudioManager.Device.SPEAKER)
}
}
private fun isWiredHeadsetOn(): Boolean {
return audioManager.isWiredHeadsetOn
}
private fun isBluetoothHeadsetOn(): Boolean {
Timber.v("## VOIP: AudioManager isBluetoothHeadsetOn")
try {
if (connectedBlueToothHeadset == null) return false.also {
Timber.v("## VOIP: AudioManager no connected bluetooth headset")
}
if (!audioManager.isBluetoothScoAvailableOffCall) return false.also {
Timber.v("## VOIP: AudioManager isBluetoothScoAvailableOffCall false")
}
return true
} catch (failure: Throwable) {
Timber.e("## VOIP: AudioManager isBluetoothHeadsetOn failure ${failure.localizedMessage}")
return false
}
}
/**
* Helper method to trigger an audio route update when devices change. It
* makes sure the operation is performed on the audio thread.
*/
private fun onAudioDeviceChange() {
callAudioManager.runInAudioThread(onAudioDeviceChangeRunner)
}
override fun start() {
Timber.i("Start using $this as the audio device handler")
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(context, this)
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(context, this)
val bm: BluetoothManager? = context.getSystemService()
val adapter = bm?.adapter
Timber.d("## VOIP Bluetooth adapter $adapter")
bluetoothAdapter = adapter
adapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceDisconnected(profile: Int) {
Timber.d("## VOIP onServiceDisconnected $profile")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = null
onAudioDeviceChange()
}
}
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
Timber.d("## VOIP onServiceConnected $profile , proxy:$proxy")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = proxy
onAudioDeviceChange()
}
}
}, BluetoothProfile.HEADSET)
onAudioDeviceChange()
}
override fun stop() {
Timber.i("Stop using $this as the audio device handler")
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(context, it) }
wiredHeadsetStateReceiver = null
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(context, it) }
bluetoothHeadsetStateReceiver = null
}
override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("onHeadsetEvent $event")
onAudioDeviceChange()
}
override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
Timber.v("onBTHeadsetEvent $event")
onAudioDeviceChange()
}
}

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.call.audio
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber
import java.util.HashSet
@RequiresApi(Build.VERSION_CODES.M)
internal class API23AudioDeviceDetector(private val audioManager: AudioManager,
private val callAudioManager: CallAudioManager
) : CallAudioManager.AudioDeviceDetector {
private val onAudioDeviceChangeRunner = Runnable {
val devices: MutableSet<CallAudioManager.Device> = HashSet()
val deviceInfos = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
for (info in deviceInfos) {
when (info.type) {
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> devices.add(CallAudioManager.Device.WIRELESS_HEADSET)
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> devices.add(CallAudioManager.Device.PHONE)
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> devices.add(CallAudioManager.Device.SPEAKER)
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET, TYPE_USB_HEADSET -> devices.add(CallAudioManager.Device.HEADSET)
}
}
callAudioManager.replaceDevices(devices)
Timber.i(" Available audio devices: $devices")
callAudioManager.updateAudioRoute()
}
private val audioDeviceCallback: AudioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(
addedDevices: Array<AudioDeviceInfo>) {
Timber.d(" Audio devices added")
onAudioDeviceChange()
}
override fun onAudioDevicesRemoved(
removedDevices: Array<AudioDeviceInfo>) {
Timber.d(" Audio devices removed")
onAudioDeviceChange()
}
}
/**
* Helper method to trigger an audio route update when devices change. It
* makes sure the operation is performed on the audio thread.
*/
private fun onAudioDeviceChange() {
callAudioManager.runInAudioThread(onAudioDeviceChangeRunner)
}
override fun start() {
Timber.i("Using $this as the audio device handler")
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
onAudioDeviceChange()
}
override fun stop() {
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
companion object {
/**
* Constant defining a USB headset. Only available on API level >= 26.
* The value of: AudioDeviceInfo.TYPE_USB_HEADSET
*/
private const val TYPE_USB_HEADSET = 22
}
}

View file

@ -0,0 +1,260 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.call.audio
import android.content.Context
import android.media.AudioManager
import android.os.Build
import androidx.core.content.getSystemService
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import java.util.HashSet
import java.util.concurrent.Executors
class CallAudioManager(private val context: Context, val configChange: (() -> Unit)?) {
private val audioManager: AudioManager? = context.getSystemService()
private var audioDeviceDetector: AudioDeviceDetector? = null
private var audioDeviceRouter: AudioDeviceRouter? = null
enum class Device {
PHONE,
SPEAKER,
HEADSET,
WIRELESS_HEADSET
}
enum class Mode {
DEFAULT,
AUDIO_CALL,
VIDEO_CALL
}
private var mode = Mode.DEFAULT
private var _availableDevices: MutableSet<Device> = HashSet()
val availableDevices: Set<Device>
get() = _availableDevices
var selectedDevice: Device? = null
private set
private var userSelectedDevice: Device? = null
init {
runInAudioThread { setup() }
}
private fun setup() {
if (audioManager == null) {
return
}
audioDeviceDetector?.stop()
audioDeviceDetector = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
API23AudioDeviceDetector(audioManager, this)
} else {
API21AudioDeviceDetector(context, audioManager, this)
}
audioDeviceDetector?.start()
audioDeviceRouter = DefaultAudioDeviceRouter(audioManager, this)
}
fun runInAudioThread(runnable: Runnable) {
executor.execute(runnable)
}
/**
* Sets the user selected audio device as the active audio device.
*
* @param device the desired device which will become active.
*/
fun setAudioDevice(device: Device) {
runInAudioThread(Runnable {
if (!_availableDevices.contains(device)) {
Timber.w(" Audio device not available: $device")
userSelectedDevice = null
return@Runnable
}
if (mode != Mode.DEFAULT) {
Timber.i(" User selected device set to: $device")
userSelectedDevice = device
updateAudioRoute(mode, false)
}
})
}
/**
* Public method to set the current audio mode.
*
* @param mode the desired audio mode.
* could be updated successfully, and it will be rejected otherwise.
*/
fun setMode(mode: Mode) {
runInAudioThread {
var success: Boolean
try {
success = updateAudioRoute(mode, false)
} catch (e: Throwable) {
success = false
Timber.e(e, " Failed to update audio route for mode: " + mode)
}
if (success) {
this@CallAudioManager.mode = mode
}
}
}
/**
* Updates the audio route for the given mode.
*
* @param mode the audio mode to be used when computing the audio route.
* @return `true` if the audio route was updated successfully;
* `false`, otherwise.
*/
private fun updateAudioRoute(mode: Mode, force: Boolean): Boolean {
Timber.i(" Update audio route for mode: " + mode)
if (!audioDeviceRouter?.setMode(mode).orFalse()) {
return false
}
if (mode == Mode.DEFAULT) {
selectedDevice = null
userSelectedDevice = null
return true
}
val bluetoothAvailable = _availableDevices.contains(Device.WIRELESS_HEADSET)
val headsetAvailable = _availableDevices.contains(Device.HEADSET)
// Pick the desired device based on what's available and the mode.
var audioDevice: Device
audioDevice = if (bluetoothAvailable) {
Device.WIRELESS_HEADSET
} else if (headsetAvailable) {
Device.HEADSET
} else if (mode == Mode.VIDEO_CALL) {
Device.SPEAKER
} else {
Device.PHONE
}
// Consider the user's selection
if (userSelectedDevice != null && _availableDevices.contains(userSelectedDevice)) {
audioDevice = userSelectedDevice!!
}
// If the previously selected device and the current default one
// match, do nothing.
if (!force && selectedDevice != null && selectedDevice == audioDevice) {
return true
}
selectedDevice = audioDevice
Timber.i(" Selected audio device: " + audioDevice)
audioDeviceRouter?.setAudioRoute(audioDevice)
configChange?.invoke()
return true
}
/**
* Resets the current device selection.
*/
fun resetSelectedDevice() {
selectedDevice = null
userSelectedDevice = null
}
/**
* Adds a new device to the list of available devices.
*
* @param device The new device.
*/
fun addDevice(device: Device) {
_availableDevices.add(device)
resetSelectedDevice()
}
/**
* Removes a device from the list of available devices.
*
* @param device The old device to the removed.
*/
fun removeDevice(device: Device) {
_availableDevices.remove(device)
resetSelectedDevice()
}
/**
* Replaces the current list of available devices with a new one.
*
* @param devices The new devices list.
*/
fun replaceDevices(devices: Set<Device>) {
_availableDevices.clear()
_availableDevices.addAll(devices)
resetSelectedDevice()
}
/**
* Re-sets the current audio route. Needed when devices changes have happened.
*/
fun updateAudioRoute() {
if (mode != Mode.DEFAULT) {
updateAudioRoute(mode, false)
}
}
/**
* Re-sets the current audio route. Needed when focus is lost and regained.
*/
fun resetAudioRoute() {
if (mode != Mode.DEFAULT) {
updateAudioRoute(mode, true)
}
}
/**
* Interface for the modules implementing the actual audio device management.
*/
interface AudioDeviceDetector {
/**
* Start detecting audio device changes.
*/
fun start()
/**
* Stop audio device detection.
*/
fun stop()
}
interface AudioDeviceRouter {
/**
* Set the appropriate route for the given audio device.
*
* @param device Audio device for which the route must be set.
*/
fun setAudioRoute(device: Device)
/**
* Set the given audio mode.
*
* @param mode The new audio mode to be used.
* @return Whether the operation was successful or not.
*/
fun setMode(mode: Mode): Boolean
}
companion object {
// Every audio operations should be launched on single thread
private val executor = Executors.newSingleThreadExecutor()
}
}

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.call.audio
import android.media.AudioManager
import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
import timber.log.Timber
class DefaultAudioDeviceRouter(private val audioManager: AudioManager,
private val callAudioManager: CallAudioManager
) : CallAudioManager.AudioDeviceRouter, AudioManager.OnAudioFocusChangeListener {
private var audioFocusLost = false
private var focusRequestCompat: AudioFocusRequestCompat? = null
override fun setAudioRoute(device: CallAudioManager.Device) {
audioManager.isSpeakerphoneOn = device === CallAudioManager.Device.SPEAKER
setBluetoothAudioRoute(device === CallAudioManager.Device.WIRELESS_HEADSET)
}
override fun setMode(mode: CallAudioManager.Mode): Boolean {
if (mode === CallAudioManager.Mode.DEFAULT) {
audioFocusLost = false
audioManager.mode = AudioManager.MODE_NORMAL
focusRequestCompat?.also {
AudioManagerCompat.abandonAudioFocusRequest(audioManager, it)
}
focusRequestCompat = null
audioManager.isSpeakerphoneOn = false
setBluetoothAudioRoute(false)
return true
}
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.isMicrophoneMute = false
val audioFocusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributesCompat.Builder()
.setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
.build()
)
.setOnAudioFocusChangeListener(this)
.build()
.also {
focusRequestCompat = it
}
val gotFocus = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest)
if (gotFocus == AudioManager.AUDIOFOCUS_REQUEST_FAILED) {
Timber.w(" Audio focus request failed")
return false
}
return true
}
/**
* Helper method to set the output route to a Bluetooth device.
*
* @param enabled true if Bluetooth should use used, false otherwise.
*/
private fun setBluetoothAudioRoute(enabled: Boolean) {
if (enabled) {
audioManager.startBluetoothSco()
audioManager.isBluetoothScoOn = true
} else {
audioManager.isBluetoothScoOn = false
audioManager.stopBluetoothSco()
}
}
/**
* [AudioManager.OnAudioFocusChangeListener] interface method. Called
* when the audio focus of the system is updated.
*
* @param focusChange - The type of focus change.
*/
override fun onAudioFocusChange(focusChange: Int) {
callAudioManager.runInAudioThread {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Timber.d(" Audio focus gained")
if (audioFocusLost) {
callAudioManager.resetAudioRoute()
}
audioFocusLost = false
}
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Timber.d(" Audio focus lost")
audioFocusLost = true
}
}
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.call.telecom;
import android.os.Build;
import androidx.annotation.RequiresApi;
import org.jitsi.meet.sdk.ConnectionService;
@RequiresApi(api = Build.VERSION_CODES.O)
public class CallConnectionService extends ConnectionService {
}

View file

@ -71,7 +71,7 @@ import im.vector.app.core.services.CallService
bindService(Intent(applicationContext, CallService::class.java), CallServiceConnection(connection), 0)
connection.setInitializing()
return CallConnection(applicationContext, roomId, callId)
return connection
}
inner class CallServiceConnection(private val callConnection: CallConnection) : ServiceConnection {

View file

@ -16,7 +16,6 @@
package im.vector.app.features.call.webrtc
import im.vector.app.features.call.CallAudioManager
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
import org.webrtc.DataChannel
@ -26,8 +25,7 @@ import org.webrtc.PeerConnection
import org.webrtc.RtpReceiver
import timber.log.Timber
class PeerConnectionObserver(private val webRtcCall: WebRtcCall,
private val callAudioManager: CallAudioManager) : PeerConnection.Observer {
class PeerConnectionObserver(private val webRtcCall: WebRtcCall) : PeerConnection.Observer {
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
Timber.v("## VOIP StreamObserver onConnectionChange: $newState")
@ -38,7 +36,6 @@ class PeerConnectionObserver(private val webRtcCall: WebRtcCall,
*/
PeerConnection.PeerConnectionState.CONNECTED -> {
webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.CONNECTED)
callAudioManager.onCallConnected(webRtcCall.mxCall)
}
/**
* One or more of the ICE transports on the connection is in the "failed" state.

View file

@ -21,7 +21,6 @@ import android.hardware.camera2.CameraManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.features.call.CallAudioManager
import im.vector.app.features.call.CameraEventsHandlerAdapter
import im.vector.app.features.call.CameraProxy
import im.vector.app.features.call.CameraType
@ -86,14 +85,13 @@ private const val VIDEO_TRACK_ID = "ARDAMSv0"
private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints()
class WebRtcCall(val mxCall: MxCall,
private val callAudioManager: CallAudioManager,
private val rootEglBase: EglBase?,
private val context: Context,
private val dispatcher: CoroutineContext,
private val sessionProvider: Provider<Session?>,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit,
private val onCallEnded: (WebRtcCall) -> Unit) : MxCall.StateListener {
private val onCallEnded: (String) -> Unit) : MxCall.StateListener {
interface Listener : MxCall.StateListener {
fun onCaptureStateChanged() {}
@ -256,7 +254,7 @@ class WebRtcCall(val mxCall: MxCall,
val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
}
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, PeerConnectionObserver(this, callAudioManager))
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, PeerConnectionObserver(this))
}
fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) {
@ -317,6 +315,9 @@ class WebRtcCall(val mxCall: MxCall,
}
private suspend fun setupOutgoingCall() = withContext(dispatcher) {
tryOrNull {
onCallBecomeActive(this@WebRtcCall)
}
val turnServer = getTurnServer()
mxCall.state = CallState.CreateOffer
// 1. Create RTCPeerConnection
@ -723,7 +724,7 @@ class WebRtcCall(val mxCall: MxCall,
GlobalScope.launch(dispatcher) {
release()
}
onCallEnded(this)
onCallEnded(callId)
if (originatedByMe) {
if (wasRinging) {
mxCall.reject()

View file

@ -21,11 +21,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import im.vector.app.ActiveSessionDataSource
import im.vector.app.core.services.BluetoothHeadsetReceiver
import im.vector.app.core.services.CallService
import im.vector.app.core.services.WiredHeadsetStateReceiver
import im.vector.app.features.call.CallAudioManager
import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.audio.CallAudioManager
import im.vector.app.features.call.utils.EglUtils
import im.vector.app.push.fcm.FcmHelper
import kotlinx.coroutines.asCoroutineDispatcher
@ -79,10 +77,12 @@ class WebRtcCallManager @Inject constructor(
currentCallsListeners.remove(listener)
}
val callAudioManager = CallAudioManager(context) {
val audioManager = CallAudioManager(context) {
currentCallsListeners.forEach {
tryOrNull { it.onAudioDevicesChange() }
}
}.apply {
setMode(CallAudioManager.Mode.DEFAULT)
}
private var peerConnectionFactory: PeerConnectionFactory? = null
@ -180,25 +180,38 @@ class WebRtcCallManager @Inject constructor(
Timber.v("## VOIP WebRtcPeerConnectionManager onCall active: ${call.mxCall.callId}")
val currentCall = getCurrentCall().takeIf { it != call }
currentCall?.updateRemoteOnHold(onHold = true)
audioManager.setMode(if (call.mxCall.isVideoCall) CallAudioManager.Mode.VIDEO_CALL else CallAudioManager.Mode.AUDIO_CALL)
this.currentCall.setAndNotify(call)
}
private fun onCallEnded(call: WebRtcCall) {
Timber.v("## VOIP WebRtcPeerConnectionManager onCall ended: ${call.mxCall.callId}")
CallService.onCallTerminated(context, call.callId)
callAudioManager.stop()
callsByCallId.remove(call.mxCall.callId)
callsByRoomId[call.mxCall.roomId]?.remove(call)
if (getCurrentCall() == call) {
private fun onCallEnded(callId: String) {
Timber.v("## VOIP WebRtcPeerConnectionManager onCall ended: $callId")
val webRtcCall = callsByCallId.remove(callId) ?: return Unit.also {
Timber.v("On call ended for unknown call $callId")
}
CallService.onCallTerminated(context, callId)
callsByRoomId[webRtcCall.roomId]?.remove(webRtcCall)
if (getCurrentCall()?.callId == callId) {
val otherCall = getCalls().lastOrNull()
currentCall.setAndNotify(otherCall)
}
// This must be done in this thread
executor.execute {
// There is no active calls
if (getCurrentCall() == null) {
Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one")
peerConnectionFactory?.dispose()
peerConnectionFactory = null
audioManager.setMode(CallAudioManager.Mode.DEFAULT)
// did we start background sync? so we should stop it
if (isInBackground) {
if (FcmHelper.isPushSupported()) {
currentSession?.stopAnyBackgroundSync()
} else {
// for fdroid we should not stop, it should continue syncing
// maybe we should restore default timeout/delay though?
}
}
}
Timber.v("## VOIP WebRtcPeerConnectionManager close() executor done")
}
@ -222,7 +235,6 @@ class WebRtcCallManager @Inject constructor(
val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return
val webRtcCall = createWebRtcCall(mxCall)
currentCall.setAndNotify(webRtcCall)
callAudioManager.startForCall(mxCall)
CallService.onOutgoingCallRinging(
context = context.applicationContext,
@ -244,7 +256,6 @@ class WebRtcCallManager @Inject constructor(
private fun createWebRtcCall(mxCall: MxCall): WebRtcCall {
val webRtcCall = WebRtcCall(
mxCall = mxCall,
callAudioManager = callAudioManager,
rootEglBase = rootEglBase,
context = context,
dispatcher = dispatcher,
@ -259,6 +270,9 @@ class WebRtcCallManager @Inject constructor(
callsByCallId[mxCall.callId] = webRtcCall
callsByRoomId.getOrPut(mxCall.roomId) { ArrayList(1) }
.add(webRtcCall)
if (getCurrentCall() == null) {
currentCall.setAndNotify(webRtcCall)
}
return webRtcCall
}
@ -266,18 +280,6 @@ class WebRtcCallManager @Inject constructor(
callsByRoomId[roomId]?.forEach { it.endCall(originatedByMe) }
}
fun onWiredDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP onWiredDeviceEvent $event")
getCurrentCall() ?: return
// sometimes we received un-wanted unplugged...
callAudioManager.wiredStateChange(event)
}
fun onWirelessDeviceEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
Timber.v("## VOIP onWirelessDeviceEvent $event")
callAudioManager.bluetoothStateChange(event.plugged)
}
override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) {
Timber.v("## VOIP onCallInviteReceived callId ${mxCall.callId}")
if (getCallsByRoomId(mxCall.roomId).isNotEmpty()) {
@ -292,7 +294,6 @@ class WebRtcCallManager @Inject constructor(
createWebRtcCall(mxCall).apply {
offerSdp = callInviteContent.offer
}
callAudioManager.startForCall(mxCall)
// Start background service with notification
CallService.onIncomingCallRinging(
context = context,
@ -365,21 +366,6 @@ class WebRtcCallManager @Inject constructor(
override fun onCallManagedByOtherSession(callId: String) {
Timber.v("## VOIP onCallManagedByOtherSession: $callId")
val webRtcCall = callsByCallId.remove(callId)
if (webRtcCall != null) {
callsByRoomId[webRtcCall.mxCall.roomId]?.remove(webRtcCall)
}
// TODO: handle this properly
CallService.onCallTerminated(context, callId)
// did we start background sync? so we should stop it
if (isInBackground) {
if (FcmHelper.isPushSupported()) {
currentSession?.stopAnyBackgroundSync()
} else {
// for fdroid we should not stop, it should continue syncing
// maybe we should restore default timeout/delay though?
}
}
onCallEnded(callId)
}
}

View file

@ -208,6 +208,10 @@ class NotificationUtils @Inject constructor(private val context: Context,
})
}
fun getChannel(channelId: String): NotificationChannel? {
return notificationManager.getNotificationChannel(channelId)
}
/**
* Build a polling thread listener notification
*
@ -266,6 +270,11 @@ class NotificationUtils @Inject constructor(private val context: Context,
return notification
}
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return getChannel(notificationChannel)
}
/**
* Build an incoming call notification.
* This notification starts the VectorHomeActivity which is in charge of centralizing the incoming call flow.

View file

@ -44,7 +44,7 @@
android:textSize="16sp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@+id/bottomSheetActionSubTitle"
app:layout_constraintEnd_toStartOf="@+id/itemVerificationActionIcon"
app:layout_constraintEnd_toStartOf="@+id/bottomSheetActionIcon"
app:layout_constraintStart_toEndOf="@+id/bottomSheetActionLeftIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"