From 5dfa08ace61549000bddbd1fc62ff35d12d7c61d Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 18 Jun 2020 17:46:50 +0200 Subject: [PATCH] Bluetooth headset support --- vector/src/main/AndroidManifest.xml | 2 + .../core/services/BluetoothHeadsetReceiver.kt | 92 ++++++++++ .../vector/riotx/core/services/CallService.kt | 15 +- .../riotx/features/call/CallAudioManager.kt | 171 ++++++++++++++---- .../features/call/CallControlsBottomSheet.kt | 8 + .../call/WebRtcPeerConnectionManager.kt | 28 +-- vector/src/main/res/values/strings.xml | 1 + 7 files changed, 268 insertions(+), 49 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index eafe99f86b..652534a7ff 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools" package="im.vector.riotx"> + + diff --git a/vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt b/vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt new file mode 100644 index 0000000000..a56c4c73c6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt @@ -0,0 +1,92 @@ +/* + * 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.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothClass +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import java.lang.ref.WeakReference + +class BluetoothHeadsetReceiver : BroadcastReceiver() { + + interface EventListener { + fun onBTHeadsetEvent(event: BTHeadsetPlugEvent) + } + + var delegate: WeakReference? = null + + data class BTHeadsetPlugEvent( + val plugged: Boolean, + val headsetName: String?, + /** + * BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE + * BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO + * AUDIO_VIDEO_WEARABLE_HEADSET + */ + val deviceClass: Int + ) + + override fun onReceive(context: Context?, intent: Intent?) { + // This intent will have 3 extras: + // EXTRA_CONNECTION_STATE - The current connection state + // EXTRA_PREVIOUS_CONNECTION_STATE}- The previous connection state. + // BluetoothDevice#EXTRA_DEVICE - The remote device. + // EXTRA_CONNECTION_STATE or EXTRA_PREVIOUS_CONNECTION_STATE can be any of + // STATE_DISCONNECTED}, STATE_CONNECTING, STATE_CONNECTED, STATE_DISCONNECTING + + val headsetConnected = when (intent?.getIntExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, -1)) { + BluetoothAdapter.STATE_CONNECTED -> true + BluetoothAdapter.STATE_DISCONNECTED -> false + else -> return // ignore intermediate states + } + + val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + val deviceName = device?.name + when (device?.bluetoothClass?.deviceClass) { + BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE, + BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO, + BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET -> { + // filter only device that we care about for + delegate?.get()?.onBTHeadsetEvent( + BTHeadsetPlugEvent( + plugged = headsetConnected, + headsetName = deviceName, + deviceClass = device.bluetoothClass.deviceClass + ) + ) + } + else -> return + } + } + + companion object { + fun createAndRegister(context: Context, listener: EventListener): BluetoothHeadsetReceiver { + val receiver = BluetoothHeadsetReceiver() + receiver.delegate = WeakReference(listener) + context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED)) + return receiver + } + + fun unRegister(context: Context, receiver: BluetoothHeadsetReceiver) { + context.unregisterReceiver(receiver) + } + } +} 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 832f484542..723cfe3add 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 @@ -33,7 +33,7 @@ import timber.log.Timber /** * Foreground service to manage calls */ -class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener { +class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener { private val connections = mutableMapOf() @@ -43,10 +43,11 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe private var callRingPlayer: CallRingPlayer? = 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 + private var mediaSession: MediaSessionCompat? = null private val mediaSessionButtonCallback = object : MediaSessionCompat.Callback() { override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean { val keyEvent = mediaButtonEvent?.getParcelableExtra(Intent.EXTRA_KEY_EVENT) ?: return false @@ -64,6 +65,7 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager() callRingPlayer = CallRingPlayer(applicationContext) wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this) + bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this) } override fun onDestroy() { @@ -71,6 +73,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe callRingPlayer?.stop() wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) } wiredHeadsetStateReceiver = null + bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) } + bluetoothHeadsetStateReceiver = null mediaSession?.release() mediaSession = null } @@ -365,6 +369,11 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { Timber.v("## VOIP: onHeadsetEvent $event") - webRtcPeerConnectionManager.onWireDeviceEvent(event) + webRtcPeerConnectionManager.onWiredDeviceEvent(event) + } + + override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) { + Timber.v("## VOIP: onBTHeadsetEvent $event") + webRtcPeerConnectionManager.onWirelessDeviceEvent(event) } } 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 ad2eae510f..9290d9a3e8 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 @@ -16,28 +16,59 @@ package im.vector.riotx.features.call +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile import android.content.Context import android.content.pm.PackageManager import android.media.AudioManager import im.vector.matrix.android.api.session.call.MxCall +import im.vector.riotx.core.services.WiredHeadsetStateReceiver import timber.log.Timber +import java.util.concurrent.Executors class CallAudioManager( - val applicationContext: Context + val applicationContext: Context, + val configChange: (() -> Unit)? ) { enum class SoundDevice { PHONE, SPEAKER, - HEADSET + HEADSET, + WIRELESS_HEADSET } - private val audioManager: AudioManager = applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + /* + * 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 + + init { + executor.execute { + audioManager = applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + val bm = applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + bm?.adapter?.getProfileProxy(applicationContext, object : BluetoothProfile.ServiceListener { + override fun onServiceDisconnected(profile: Int) { + connectedBlueToothHeadset = null + } + + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { + 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. @@ -49,6 +80,7 @@ class CallAudioManager( fun startForCall(mxCall: MxCall) { Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}") + val audioManager = audioManager ?: return savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn savedIsMicrophoneMute = audioManager.isMicrophoneMute savedAudioMode = audioManager.mode @@ -72,77 +104,150 @@ class CallAudioManager( // Always disable microphone mute during a WebRTC call. setMicrophoneMute(false) - // 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) + executor.execute { + // 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()) { + 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 (isBluetoothHeadsetOn()) { + 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 getAvailableSoundDevices(): List { - return listOf( - if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE, - SoundDevice.SPEAKER - ) + 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 - // Restore previously stored audio states. - setSpeakerphoneOn(savedIsSpeakerPhoneOn) - setMicrophoneMute(savedIsMicrophoneMute) - audioManager.mode = savedAudioMode - - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(audioFocusChangeListener) + @Suppress("DEPRECATION") + audioManager?.abandonAudioFocus(audioFocusChangeListener) + } } fun getCurrentSoundDevice(): SoundDevice { + val audioManager = audioManager ?: return SoundDevice.PHONE if (audioManager.isSpeakerphoneOn) { return SoundDevice.SPEAKER } else { + if (isBluetoothHeadsetOn() && (wantsBluetoothConnection || audioManager.isBluetoothScoOn)) return SoundDevice.WIRELESS_HEADSET return if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE } } fun setCurrentSoundDevice(device: SoundDevice) { - when (device) { - SoundDevice.HEADSET, - SoundDevice.PHONE -> setSpeakerphoneOn(false) - SoundDevice.SPEAKER -> setSpeakerphoneOn(true) + 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() || isBluetoothHeadsetOn() + } + + private fun isWiredHeadsetOn(): Boolean { @Suppress("DEPRECATION") - return audioManager.isWiredHeadsetOn || audioManager.isBluetoothScoOn + return audioManager?.isWiredHeadsetOn ?: false + } + + private fun isBluetoothHeadsetOn(): Boolean { + return connectedBlueToothHeadset != null } /** Sets the speaker phone mode. */ private fun setSpeakerphoneOn(on: Boolean) { Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on") - val wasOn = audioManager.isSpeakerphoneOn + val wasOn = audioManager?.isSpeakerphoneOn ?: false if (wasOn == on) { return } - audioManager.isSpeakerphoneOn = on + audioManager?.isSpeakerphoneOn = on } /** Sets the microphone mute state. */ private fun setMicrophoneMute(on: Boolean) { Timber.v("## VOIP: AudioManager setMicrophoneMute $on") - val wasMuted = audioManager.isMicrophoneMute + val wasMuted = audioManager?.isMicrophoneMute ?: false if (wasMuted == on) { return } - audioManager.isMicrophoneMute = on - - audioManager.isMusicActive + audioManager?.isMicrophoneMute = on } /** true if the device has a telephony radio with data 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 70c6113160..010a89cf2a 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 @@ -55,6 +55,10 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() { private fun showSoundDeviceChooser(available: List, current: CallAudioManager.SoundDevice) { val soundDevices = available.map { when (it) { + CallAudioManager.SoundDevice.WIRELESS_HEADSET -> span { + text = getString(R.string.sound_device_wireless_headset) + textStyle = if (current == it) "bold" else "normal" + } CallAudioManager.SoundDevice.PHONE -> span { text = getString(R.string.sound_device_phone) textStyle = if (current == it) "bold" else "normal" @@ -82,6 +86,9 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() { getString(R.string.sound_device_headset) -> { callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.HEADSET)) } + getString(R.string.sound_device_wireless_headset) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.WIRELESS_HEADSET)) + } } } .setNegativeButton(R.string.cancel, null) @@ -104,6 +111,7 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() { CallAudioManager.SoundDevice.PHONE -> getString(R.string.sound_device_phone) CallAudioManager.SoundDevice.SPEAKER -> getString(R.string.sound_device_speaker) CallAudioManager.SoundDevice.HEADSET -> getString(R.string.sound_device_headset) + CallAudioManager.SoundDevice.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset) } } } 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 9fbc38d816..57c3f75f55 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 @@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.room.model.call.CallCandidatesConten 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.BluetoothHeadsetReceiver import im.vector.riotx.core.services.CallService import im.vector.riotx.core.services.WiredHeadsetStateReceiver import io.reactivex.disposables.Disposable @@ -88,7 +89,11 @@ class WebRtcPeerConnectionManager @Inject constructor( currentCallsListeners.remove(listener) } - val audioManager = CallAudioManager(context.applicationContext) + val audioManager = CallAudioManager(context.applicationContext) { + currentCallsListeners.forEach { + tryThis { it.onAudioDevicesChange(this) } + } + } data class CallContext( val mxCall: MxCall, @@ -672,19 +677,16 @@ class WebRtcPeerConnectionManager @Inject constructor( close() } - fun onWireDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + fun onWiredDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + Timber.v("## VOIP onWiredDeviceEvent $event") 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) - } + // sometimes we received un-wanted unplugged... + audioManager.wiredStateChange(event) + } + + fun onWirelessDeviceEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) { + Timber.v("## VOIP onWirelessDeviceEvent $event") + audioManager.bluetoothStateChange(event.plugged) } override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 7d67e30a70..a94f6f80d3 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -218,6 +218,7 @@ Phone Speaker Headset + Wireless Headset Send files