diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt index 4576675d4a..229c20f95d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt @@ -126,7 +126,16 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa private fun handleCallRejectEvent(event: Event) { val content = event.getClearContent().toModel() ?: 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) { diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 420f57c57a..e9b29d99ba 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -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 diff --git a/vector/build.gradle b/vector/build.gradle index 496582e41c..a59b41d910 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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" diff --git a/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt b/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt index d5d8bb14dd..f725742711 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt @@ -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() + 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()!! + 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) { diff --git a/vector/src/main/java/im/vector/app/core/services/CallService.kt b/vector/src/main/java/im/vector/app/core/services/CallService.kt index 7571b6a205..2b7d878d49 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallService.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallService.kt @@ -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() private val knownCalls = mutableSetOf() @@ -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) - } } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt index ef6f3e431a..59418147d7 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/call/CallAudioManager.kt b/vector/src/main/java/im/vector/app/features/call/CallAudioManager.kt deleted file mode 100644 index 3a24cf6d48..0000000000 --- a/vector/src/main/java/im/vector/app/features/call/CallAudioManager.kt +++ /dev/null @@ -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() - 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 { - return ArrayList().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) - } -} diff --git a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt index 7c8c4eb465..735ce2fd22 100644 --- a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt @@ -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, current: CallAudioManager.SoundDevice) { + private fun showSoundDeviceChooser(available: Set, 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 { - 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 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 diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt index adb8897a51..32a1deb266 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt index 832c4fe944..5ffeca6f66 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt @@ -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, - val current: CallAudioManager.SoundDevice + val available: Set, + val current: CallAudioManager.Device ) : VectorCallViewEvents() object ShowCallTransferScreen: VectorCallViewEvents() // data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt index 0c621dcfe4..d12add5014 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt @@ -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 -> { diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt index 1d12b780b1..cdd002114a 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt @@ -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 = emptyList(), + val device: CallAudioManager.Device = CallAudioManager.Device.PHONE, + val availableDevices: Set = emptySet(), val callState: Async = Uninitialized, val otherKnownCallInfo: CallInfo? = null, val callInfo: CallInfo = CallInfo(callId), diff --git a/vector/src/main/java/im/vector/app/features/call/audio/API21AudioDeviceDetector.kt b/vector/src/main/java/im/vector/app/features/call/audio/API21AudioDeviceDetector.kt new file mode 100644 index 0000000000..32b243aa2b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/API21AudioDeviceDetector.kt @@ -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 { + return HashSet().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() + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/API23AudioDeviceDetector.kt b/vector/src/main/java/im/vector/app/features/call/audio/API23AudioDeviceDetector.kt new file mode 100644 index 0000000000..7174554d5f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/API23AudioDeviceDetector.kt @@ -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 = 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) { + Timber.d(" Audio devices added") + onAudioDeviceChange() + } + + override fun onAudioDevicesRemoved( + removedDevices: Array) { + 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 + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/CallAudioManager.kt b/vector/src/main/java/im/vector/app/features/call/audio/CallAudioManager.kt new file mode 100644 index 0000000000..66370763e1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/CallAudioManager.kt @@ -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 = HashSet() + val availableDevices: Set + 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) { + _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() + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/DefaultAudioDeviceRouter.kt b/vector/src/main/java/im/vector/app/features/call/audio/DefaultAudioDeviceRouter.kt new file mode 100644 index 0000000000..c252cc9f89 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/DefaultAudioDeviceRouter.kt @@ -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 + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/telecom/CallConnectionService.java b/vector/src/main/java/im/vector/app/features/call/telecom/CallConnectionService.java new file mode 100644 index 0000000000..f3fbfc9ac4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/telecom/CallConnectionService.java @@ -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 { +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt b/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt index 410a4621e8..e289537177 100644 --- a/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt +++ b/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt @@ -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 { diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt index f6e2caf72c..9b4adcf955 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt @@ -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. diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt index 28ca503507..bc55c3e924 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -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, private val peerConnectionFactoryProvider: Provider, 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() diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt index 18720a6c19..86cf3972c9 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt @@ -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) } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index d1e52b2a78..e7cafc6a9b 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -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. diff --git a/vector/src/main/res/layout/view_bottom_sheet_action_button.xml b/vector/src/main/res/layout/view_bottom_sheet_action_button.xml index c0e0ba7bcb..ec2e7d2bfe 100644 --- a/vector/src/main/res/layout/view_bottom_sheet_action_button.xml +++ b/vector/src/main/res/layout/view_bottom_sheet_action_button.xml @@ -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"