Headset support

+ detect plug/unplugg
This commit is contained in:
Valere 2020-06-18 11:56:55 +02:00
parent 30d47b4fa6
commit 3e2d892fb5
8 changed files with 229 additions and 50 deletions

View File

@ -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<String, CallConnection>()
@ -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)
}
}

View File

@ -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<HeadsetEventListener>? = 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)
}
}
}

View File

@ -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<SoundDevice> {
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")

View File

@ -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<CallAudioManager.SoundDevice>, 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)
}
}
}

View File

@ -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 -> {
}
}
}

View File

@ -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<CallAudioManager.SoundDevice> = emptyList(),
val otherUserMatrixItem: Async<MatrixItem> = Uninitialized,
val callState: Async<CallState> = 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<CallAudioManager.SoundDevice>,
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<TurnServerResponse> {
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
}

View File

@ -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<CurrentCallListener>().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 {

View File

@ -217,6 +217,7 @@
<string name="call_select_sound_device">Select Sound Device</string>
<string name="sound_device_phone">Phone</string>
<string name="sound_device_speaker">Speaker</string>
<string name="sound_device_headset">Headset</string>
<string name="option_send_files">Send files</string>