From 3e2d892fb563f43397de425fc843ece509d316c9 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 18 Jun 2020 11:56:55 +0200 Subject: [PATCH] Headset support + detect plug/unplugg --- .../vector/riotx/core/services/CallService.kt | 22 +++-- .../services/WiredHeadsetStateReceiver.kt | 85 +++++++++++++++++++ .../riotx/features/call/CallAudioManager.kt | 29 +++++-- .../features/call/CallControlsBottomSheet.kt | 65 ++++++++++---- .../riotx/features/call/VectorCallActivity.kt | 6 +- .../features/call/VectorCallViewModel.kt | 54 ++++++++---- .../call/WebRtcPeerConnectionManager.kt | 17 ++++ vector/src/main/res/values/strings.xml | 1 + 8 files changed, 229 insertions(+), 50 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt diff --git a/vector/src/main/java/im/vector/riotx/core/services/CallService.kt b/vector/src/main/java/im/vector/riotx/core/services/CallService.kt index f10bbd908d..d8addd46a7 100644 --- a/vector/src/main/java/im/vector/riotx/core/services/CallService.kt +++ b/vector/src/main/java/im/vector/riotx/core/services/CallService.kt @@ -30,7 +30,7 @@ import timber.log.Timber /** * Foreground service to manage calls */ -class CallService : VectorService() { +class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener { private val connections = mutableMapOf() @@ -49,11 +49,21 @@ class CallService : VectorService() { private var callRingPlayer: CallRingPlayer? = null + private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null + override fun onCreate() { super.onCreate() notificationUtils = vectorComponent().notificationUtils() webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager() callRingPlayer = CallRingPlayer(applicationContext) + wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this) + } + + override fun onDestroy() { + super.onDestroy() + callRingPlayer?.stop() + wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) } + wiredHeadsetStateReceiver = null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -217,11 +227,6 @@ class CallService : VectorService() { myStopSelf() } - override fun onDestroy() { - super.onDestroy() - callRingPlayer?.stop() - } - fun addConnection(callConnection: CallConnection) { connections[callConnection.callId] = callConnection } @@ -335,4 +340,9 @@ class CallService : VectorService() { return this@CallService } } + + override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + Timber.v("## VOIP: onHeadsetEvent $event") + webRtcPeerConnectionManager.onWireDeviceEvent(event) + } } diff --git a/vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt b/vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt new file mode 100644 index 0000000000..e63c7f5049 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt @@ -0,0 +1,85 @@ +/* + * 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.riotx.core.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.os.Build +import timber.log.Timber +import java.lang.ref.WeakReference + +/** + * Dynamic broadcast receiver to detect headset plug/unplug + */ +class WiredHeadsetStateReceiver : BroadcastReceiver() { + + interface HeadsetEventListener { + fun onHeadsetEvent(event: HeadsetPlugEvent) + } + + var delegate: WeakReference? = null + + data class HeadsetPlugEvent( + val plugged: Boolean, + val headsetName: String?, + val hasMicrophone: Boolean + ) + + override fun onReceive(context: Context?, intent: Intent?) { + // The intent will have the following extra values: + // state 0 for unplugged, 1 for plugged + // name Headset type, human readable string + // microphone 1 if headset has a microphone, 0 otherwise + + val isPlugged = when (intent?.getIntExtra("state", -1)) { + 0 -> false + 1 -> true + else -> return Unit.also { + Timber.v("## VOIP WiredHeadsetStateReceiver invalid state") + } + } + val hasMicrophone = when (intent.getIntExtra("microphone", -1)) { + 1 -> true + else -> false + } + + delegate?.get()?.onHeadsetEvent( + HeadsetPlugEvent(plugged = isPlugged, headsetName = intent.getStringExtra("name"), hasMicrophone = hasMicrophone) + ) + } + + companion object { + fun createAndRegister(context: Context, listener: HeadsetEventListener): WiredHeadsetStateReceiver { + val receiver = WiredHeadsetStateReceiver() + receiver.delegate = WeakReference(listener) + val action = if (Build.VERSION.SDK_INT >= 21) { + AudioManager.ACTION_HEADSET_PLUG + } else { + Intent.ACTION_HEADSET_PLUG + } + context.registerReceiver(receiver, IntentFilter(action)) + return receiver + } + + fun unRegister(context: Context, receiver: WiredHeadsetStateReceiver) { + context.unregisterReceiver(receiver) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt b/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt index b3bdc23707..ad2eae510f 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt @@ -28,7 +28,8 @@ class CallAudioManager( enum class SoundDevice { PHONE, - SPEAKER + SPEAKER, + HEADSET } private val audioManager: AudioManager = applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -71,14 +72,24 @@ class CallAudioManager( // Always disable microphone mute during a WebRTC call. setMicrophoneMute(false) - // TODO check if there are headsets? - if (mxCall.isVideoCall) { + // If there are no headset, start video output in speaker + // (you can't watch the video and have the phone close to your ear) + if (mxCall.isVideoCall && !isHeadsetOn()) { setSpeakerphoneOn(true) } else { + // if a headset is plugged, sound will be directed to it + // (can't really force earpiece when headset is plugged) setSpeakerphoneOn(false) } } + fun getAvailableSoundDevices(): List { + return listOf( + if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE, + SoundDevice.SPEAKER + ) + } + fun stop() { Timber.v("## VOIP: AudioManager stopCall") @@ -91,21 +102,27 @@ class CallAudioManager( audioManager.abandonAudioFocus(audioFocusChangeListener) } - fun getCurrentSoundDevice() : SoundDevice { + fun getCurrentSoundDevice(): SoundDevice { if (audioManager.isSpeakerphoneOn) { return SoundDevice.SPEAKER } else { - return SoundDevice.PHONE + return if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE } } - fun setCurrentSoundDevice(device: SoundDevice) { + fun setCurrentSoundDevice(device: SoundDevice) { when (device) { + SoundDevice.HEADSET, SoundDevice.PHONE -> setSpeakerphoneOn(false) SoundDevice.SPEAKER -> setSpeakerphoneOn(true) } } + private fun isHeadsetOn(): Boolean { + @Suppress("DEPRECATION") + return audioManager.isWiredHeadsetOn || audioManager.isBluetoothScoOn + } + /** Sets the speaker phone mode. */ private fun setSpeakerphoneOn(on: Boolean) { Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on") diff --git a/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt index 6e4a304932..70c6113160 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt @@ -23,6 +23,7 @@ import com.airbnb.mvrx.activityViewModel import im.vector.riotx.R import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment import kotlinx.android.synthetic.main.bottom_sheet_call_controls.* +import me.gujun.android.span.span class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() { override fun getLayoutResId() = R.layout.bottom_sheet_call_controls @@ -37,25 +38,54 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() { } callControlsSoundDevice.clickableView.debouncedClicks { - val soundDevices = listOf( - getString(R.string.sound_device_phone), - getString(R.string.sound_device_speaker) - ) - AlertDialog.Builder(requireContext()) - .setItems(soundDevices.toTypedArray()) { d, n -> - d.cancel() - when (soundDevices[n]) { - getString(R.string.sound_device_phone) -> { - callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.PHONE)) - } - getString(R.string.sound_device_speaker) -> { - callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.SPEAKER)) - } + callViewModel.handle(VectorCallViewActions.SwitchSoundDevice) + } + + callViewModel.observeViewEvents { + when (it) { + is VectorCallViewEvents.ShowSoundDeviceChooser -> { + showSoundDeviceChooser(it.available, it.current) + } + else -> { + } + } + } + } + + private fun showSoundDeviceChooser(available: List, current: CallAudioManager.SoundDevice) { + val soundDevices = available.map { + when (it) { + CallAudioManager.SoundDevice.PHONE -> span { + text = getString(R.string.sound_device_phone) + textStyle = if (current == it) "bold" else "normal" + } + CallAudioManager.SoundDevice.SPEAKER -> span { + text = getString(R.string.sound_device_speaker) + textStyle = if (current == it) "bold" else "normal" + } + CallAudioManager.SoundDevice.HEADSET -> span { + text = getString(R.string.sound_device_headset) + textStyle = if (current == it) "bold" else "normal" + } + } + } + AlertDialog.Builder(requireContext()) + .setItems(soundDevices.toTypedArray()) { d, n -> + d.cancel() + when (soundDevices[n].toString()) { + getString(R.string.sound_device_phone) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.PHONE)) + } + getString(R.string.sound_device_speaker) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.SPEAKER)) + } + getString(R.string.sound_device_headset) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.HEADSET)) } } - .setNegativeButton(R.string.cancel, null) - .show() - } + } + .setNegativeButton(R.string.cancel, null) + .show() } // override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -73,6 +103,7 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() { 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) } } } diff --git a/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt index b0fe9865fd..67ecd42a70 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt @@ -340,14 +340,14 @@ class VectorCallActivity : VectorBaseActivity(), CallControlsView.InteractionLis private fun handleViewEvents(event: VectorCallViewEvents?) { Timber.v("## VOIP handleViewEvents $event") when (event) { - VectorCallViewEvents.DismissNoCall -> { + VectorCallViewEvents.DismissNoCall -> { CallService.onNoActiveCall(this) finish() } - is VectorCallViewEvents.ConnectionTimout -> { + is VectorCallViewEvents.ConnectionTimeout -> { onErrorTimoutConnect(event.turn) } - null -> { + null -> { } } } diff --git a/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt index ea15c07841..b721d31cd5 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt @@ -49,6 +49,7 @@ data class VectorCallViewState( val isVideoEnabled: Boolean = true, val isVideoCaptureInError: Boolean = false, val soundDevice: CallAudioManager.SoundDevice = CallAudioManager.SoundDevice.PHONE, + val availableSoundDevices: List = emptyList(), val otherUserMatrixItem: Async = Uninitialized, val callState: Async = Uninitialized ) : MvRxState @@ -60,12 +61,17 @@ sealed class VectorCallViewActions : VectorViewModelAction { object ToggleMute : VectorCallViewActions() object ToggleVideo : VectorCallViewActions() data class ChangeAudioDevice(val device: CallAudioManager.SoundDevice) : VectorCallViewActions() + object SwitchSoundDevice : VectorCallViewActions() } sealed class VectorCallViewEvents : VectorViewEvents { object DismissNoCall : VectorCallViewEvents() - data class ConnectionTimout(val turn: TurnServerResponse?) : VectorCallViewEvents() + data class ConnectionTimeout(val turn: TurnServerResponse?) : VectorCallViewEvents() + data class ShowSoundDeviceChooser( + val available: List, + val current: CallAudioManager.SoundDevice + ) : VectorCallViewEvents() // data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents() // data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents() // object CallAccepted : VectorCallViewEvents() @@ -96,11 +102,11 @@ class VectorCallViewModel @AssistedInject constructor( override fun run() { session.callSignalingService().getTurnServer(object : MatrixCallback { override fun onFailure(failure: Throwable) { - _viewEvents.post(VectorCallViewEvents.ConnectionTimout(null)) + _viewEvents.post(VectorCallViewEvents.ConnectionTimeout(null)) } override fun onSuccess(data: TurnServerResponse) { - _viewEvents.post(VectorCallViewEvents.ConnectionTimout(data)) + _viewEvents.post(VectorCallViewEvents.ConnectionTimeout(data)) } }) } @@ -124,6 +130,15 @@ class VectorCallViewModel @AssistedInject constructor( copy(isVideoCaptureInError = captureInError) } } + + override fun onAudioDevicesChange(mgr: WebRtcPeerConnectionManager) { + setState { + copy( + availableSoundDevices = mgr.audioManager.getAvailableSoundDevices(), + soundDevice = mgr.audioManager.getCurrentSoundDevice() + ) + } + } } init { @@ -143,7 +158,8 @@ class VectorCallViewModel @AssistedInject constructor( isVideoCall = mxCall.isVideoCall, callState = Success(mxCall.state), otherUserMatrixItem = item?.let { Success(it) } ?: Uninitialized, - soundDevice = webRtcPeerConnectionManager.audioManager.getCurrentSoundDevice() + soundDevice = webRtcPeerConnectionManager.audioManager.getCurrentSoundDevice(), + availableSoundDevices = webRtcPeerConnectionManager.audioManager.getAvailableSoundDevices() ) } } ?: run { @@ -163,7 +179,7 @@ class VectorCallViewModel @AssistedInject constructor( super.onCleared() } - override fun handle(action: VectorCallViewActions) = withState { + override fun handle(action: VectorCallViewActions) = withState { state -> when (action) { VectorCallViewActions.EndCall -> webRtcPeerConnectionManager.endCall() VectorCallViewActions.AcceptCall -> { @@ -179,24 +195,21 @@ class VectorCallViewModel @AssistedInject constructor( webRtcPeerConnectionManager.endCall() } VectorCallViewActions.ToggleMute -> { - withState { - val muted = it.isAudioMuted - webRtcPeerConnectionManager.muteCall(!muted) - setState { - copy(isAudioMuted = !muted) - } + val muted = state.isAudioMuted + webRtcPeerConnectionManager.muteCall(!muted) + setState { + copy(isAudioMuted = !muted) } } VectorCallViewActions.ToggleVideo -> { - withState { - if (it.isVideoCall) { - val videoEnabled = it.isVideoEnabled - webRtcPeerConnectionManager.enableVideo(!videoEnabled) - setState { - copy(isVideoEnabled = !videoEnabled) - } + if (state.isVideoCall) { + val videoEnabled = state.isVideoEnabled + webRtcPeerConnectionManager.enableVideo(!videoEnabled) + setState { + copy(isVideoEnabled = !videoEnabled) } } + Unit } is VectorCallViewActions.ChangeAudioDevice -> { webRtcPeerConnectionManager.audioManager.setCurrentSoundDevice(action.device) @@ -206,6 +219,11 @@ class VectorCallViewModel @AssistedInject constructor( ) } } + VectorCallViewActions.SwitchSoundDevice -> { + _viewEvents.post( + VectorCallViewEvents.ShowSoundDeviceChooser(state.availableSoundDevices, state.soundDevice) + ) + } }.exhaustive } diff --git a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt index d91bf270fe..8b2e1ef74c 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt @@ -33,6 +33,7 @@ import im.vector.matrix.android.api.session.room.model.call.CallHangupContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.services.CallService +import im.vector.riotx.core.services.WiredHeadsetStateReceiver import io.reactivex.disposables.Disposable import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.ReplaySubject @@ -75,6 +76,7 @@ class WebRtcPeerConnectionManager @Inject constructor( interface CurrentCallListener { fun onCurrentCallChange(call: MxCall?) fun onCaptureStateChanged(captureInError: Boolean) + fun onAudioDevicesChange(mgr: WebRtcPeerConnectionManager) {} } private val currentCallsListeners = emptyList().toMutableList() @@ -657,6 +659,21 @@ class WebRtcPeerConnectionManager @Inject constructor( close() } + fun onWireDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + currentCall ?: return + // if it's plugged and speaker is on we should route to headset + if (event.plugged && audioManager.getCurrentSoundDevice() == CallAudioManager.SoundDevice.SPEAKER) { + audioManager.setCurrentSoundDevice(CallAudioManager.SoundDevice.HEADSET) + } else if (!event.plugged) { + // if it's unplugged ? always route to speaker? + // this is questionable? + audioManager.setCurrentSoundDevice(CallAudioManager.SoundDevice.SPEAKER) + } + currentCallsListeners.forEach { + it.onAudioDevicesChange(this) + } + } + override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { val call = currentCall ?: return if (call.mxCall.callId != callAnswerContent.callId) return Unit.also { diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 0a6d46e937..7d67e30a70 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -217,6 +217,7 @@ Select Sound Device Phone Speaker + Headset Send files