Bluetooth headset support

This commit is contained in:
Valere 2020-06-18 17:46:50 +02:00
parent 4c61dfef62
commit 5dfa08ace6
7 changed files with 268 additions and 49 deletions

View File

@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="im.vector.riotx"> package="im.vector.riotx">
<!-- Needed for VOIP call to detect and switch to headset-->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

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

View File

@ -33,7 +33,7 @@ import timber.log.Timber
/** /**
* Foreground service to manage calls * Foreground service to manage calls
*/ */
class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener { class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener {
private val connections = mutableMapOf<String, CallConnection>() private val connections = mutableMapOf<String, CallConnection>()
@ -43,10 +43,11 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
private var callRingPlayer: CallRingPlayer? = null private var callRingPlayer: CallRingPlayer? = null
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
// A media button receiver receives and helps translate hardware media playback buttons, // 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 // 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() { private val mediaSessionButtonCallback = object : MediaSessionCompat.Callback() {
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean { override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
val keyEvent = mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) ?: return false val keyEvent = mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) ?: return false
@ -64,6 +65,7 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager() webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager()
callRingPlayer = CallRingPlayer(applicationContext) callRingPlayer = CallRingPlayer(applicationContext)
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this) wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this)
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this)
} }
override fun onDestroy() { override fun onDestroy() {
@ -71,6 +73,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
callRingPlayer?.stop() callRingPlayer?.stop()
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) } wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) }
wiredHeadsetStateReceiver = null wiredHeadsetStateReceiver = null
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) }
bluetoothHeadsetStateReceiver = null
mediaSession?.release() mediaSession?.release()
mediaSession = null mediaSession = null
} }
@ -365,6 +369,11 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP: onHeadsetEvent $event") 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)
} }
} }

View File

@ -16,28 +16,59 @@
package im.vector.riotx.features.call package im.vector.riotx.features.call
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioManager import android.media.AudioManager
import im.vector.matrix.android.api.session.call.MxCall import im.vector.matrix.android.api.session.call.MxCall
import im.vector.riotx.core.services.WiredHeadsetStateReceiver
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.Executors
class CallAudioManager( class CallAudioManager(
val applicationContext: Context val applicationContext: Context,
val configChange: (() -> Unit)?
) { ) {
enum class SoundDevice { enum class SoundDevice {
PHONE, PHONE,
SPEAKER, 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 savedIsSpeakerPhoneOn = false
private var savedIsMicrophoneMute = false private var savedIsMicrophoneMute = false
private var savedAudioMode = AudioManager.MODE_INVALID 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 -> private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
// Called on the listener to notify if the audio focus for this listener has been changed. // 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) { fun startForCall(mxCall: MxCall) {
Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}") Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}")
val audioManager = audioManager ?: return
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn
savedIsMicrophoneMute = audioManager.isMicrophoneMute savedIsMicrophoneMute = audioManager.isMicrophoneMute
savedAudioMode = audioManager.mode savedAudioMode = audioManager.mode
@ -72,77 +104,150 @@ class CallAudioManager(
// Always disable microphone mute during a WebRTC call. // Always disable microphone mute during a WebRTC call.
setMicrophoneMute(false) setMicrophoneMute(false)
// If there are no headset, start video output in speaker executor.execute {
// (you can't watch the video and have the phone close to your ear) // If there are no headset, start video output in speaker
if (mxCall.isVideoCall && !isHeadsetOn()) { // (you can't watch the video and have the phone close to your ear)
setSpeakerphoneOn(true) if (mxCall.isVideoCall && !isHeadsetOn()) {
} else { Timber.v("##VOIP: AudioManager default to speaker ")
// if a headset is plugged, sound will be directed to it setCurrentSoundDevice(SoundDevice.SPEAKER)
// (can't really force earpiece when headset is plugged) } else {
setSpeakerphoneOn(false) // 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<SoundDevice> { fun getAvailableSoundDevices(): List<SoundDevice> {
return listOf( return ArrayList<SoundDevice>().apply {
if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE, if (isBluetoothHeadsetOn()) add(SoundDevice.WIRELESS_HEADSET)
SoundDevice.SPEAKER add(if (isWiredHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE)
) add(SoundDevice.SPEAKER)
}
} }
fun stop() { fun stop() {
Timber.v("## VOIP: AudioManager stopCall") Timber.v("## VOIP: AudioManager stopCall")
executor.execute {
// Restore previously stored audio states.
setSpeakerphoneOn(savedIsSpeakerPhoneOn)
setMicrophoneMute(savedIsMicrophoneMute)
audioManager?.mode = savedAudioMode
// Restore previously stored audio states. @Suppress("DEPRECATION")
setSpeakerphoneOn(savedIsSpeakerPhoneOn) audioManager?.abandonAudioFocus(audioFocusChangeListener)
setMicrophoneMute(savedIsMicrophoneMute) }
audioManager.mode = savedAudioMode
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
} }
fun getCurrentSoundDevice(): SoundDevice { fun getCurrentSoundDevice(): SoundDevice {
val audioManager = audioManager ?: return SoundDevice.PHONE
if (audioManager.isSpeakerphoneOn) { if (audioManager.isSpeakerphoneOn) {
return SoundDevice.SPEAKER return SoundDevice.SPEAKER
} else { } else {
if (isBluetoothHeadsetOn() && (wantsBluetoothConnection || audioManager.isBluetoothScoOn)) return SoundDevice.WIRELESS_HEADSET
return if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE return if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE
} }
} }
fun setCurrentSoundDevice(device: SoundDevice) { fun setCurrentSoundDevice(device: SoundDevice) {
when (device) { executor.execute {
SoundDevice.HEADSET, Timber.v("## VOIP setCurrentSoundDevice $device")
SoundDevice.PHONE -> setSpeakerphoneOn(false) when (device) {
SoundDevice.SPEAKER -> setSpeakerphoneOn(true) 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 { private fun isHeadsetOn(): Boolean {
return isWiredHeadsetOn() || isBluetoothHeadsetOn()
}
private fun isWiredHeadsetOn(): Boolean {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
return audioManager.isWiredHeadsetOn || audioManager.isBluetoothScoOn return audioManager?.isWiredHeadsetOn ?: false
}
private fun isBluetoothHeadsetOn(): Boolean {
return connectedBlueToothHeadset != null
} }
/** Sets the speaker phone mode. */ /** Sets the speaker phone mode. */
private fun setSpeakerphoneOn(on: Boolean) { private fun setSpeakerphoneOn(on: Boolean) {
Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on") Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on")
val wasOn = audioManager.isSpeakerphoneOn val wasOn = audioManager?.isSpeakerphoneOn ?: false
if (wasOn == on) { if (wasOn == on) {
return return
} }
audioManager.isSpeakerphoneOn = on audioManager?.isSpeakerphoneOn = on
} }
/** Sets the microphone mute state. */ /** Sets the microphone mute state. */
private fun setMicrophoneMute(on: Boolean) { private fun setMicrophoneMute(on: Boolean) {
Timber.v("## VOIP: AudioManager setMicrophoneMute $on") Timber.v("## VOIP: AudioManager setMicrophoneMute $on")
val wasMuted = audioManager.isMicrophoneMute val wasMuted = audioManager?.isMicrophoneMute ?: false
if (wasMuted == on) { if (wasMuted == on) {
return return
} }
audioManager.isMicrophoneMute = on audioManager?.isMicrophoneMute = on
audioManager.isMusicActive
} }
/** true if the device has a telephony radio with data /** true if the device has a telephony radio with data

View File

@ -55,6 +55,10 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() {
private fun showSoundDeviceChooser(available: List<CallAudioManager.SoundDevice>, current: CallAudioManager.SoundDevice) { private fun showSoundDeviceChooser(available: List<CallAudioManager.SoundDevice>, current: CallAudioManager.SoundDevice) {
val soundDevices = available.map { val soundDevices = available.map {
when (it) { 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 { CallAudioManager.SoundDevice.PHONE -> span {
text = getString(R.string.sound_device_phone) text = getString(R.string.sound_device_phone)
textStyle = if (current == it) "bold" else "normal" textStyle = if (current == it) "bold" else "normal"
@ -82,6 +86,9 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() {
getString(R.string.sound_device_headset) -> { getString(R.string.sound_device_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.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) .setNegativeButton(R.string.cancel, null)
@ -104,6 +111,7 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() {
CallAudioManager.SoundDevice.PHONE -> getString(R.string.sound_device_phone) CallAudioManager.SoundDevice.PHONE -> getString(R.string.sound_device_phone)
CallAudioManager.SoundDevice.SPEAKER -> getString(R.string.sound_device_speaker) CallAudioManager.SoundDevice.SPEAKER -> getString(R.string.sound_device_speaker)
CallAudioManager.SoundDevice.HEADSET -> getString(R.string.sound_device_headset) CallAudioManager.SoundDevice.HEADSET -> getString(R.string.sound_device_headset)
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset)
} }
} }
} }

View File

@ -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.CallHangupContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.riotx.core.di.ActiveSessionHolder 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.CallService
import im.vector.riotx.core.services.WiredHeadsetStateReceiver import im.vector.riotx.core.services.WiredHeadsetStateReceiver
import io.reactivex.disposables.Disposable import io.reactivex.disposables.Disposable
@ -88,7 +89,11 @@ class WebRtcPeerConnectionManager @Inject constructor(
currentCallsListeners.remove(listener) currentCallsListeners.remove(listener)
} }
val audioManager = CallAudioManager(context.applicationContext) val audioManager = CallAudioManager(context.applicationContext) {
currentCallsListeners.forEach {
tryThis { it.onAudioDevicesChange(this) }
}
}
data class CallContext( data class CallContext(
val mxCall: MxCall, val mxCall: MxCall,
@ -672,19 +677,16 @@ class WebRtcPeerConnectionManager @Inject constructor(
close() close()
} }
fun onWireDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { fun onWiredDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP onWiredDeviceEvent $event")
currentCall ?: return currentCall ?: return
// if it's plugged and speaker is on we should route to headset // sometimes we received un-wanted unplugged...
if (event.plugged && audioManager.getCurrentSoundDevice() == CallAudioManager.SoundDevice.SPEAKER) { audioManager.wiredStateChange(event)
audioManager.setCurrentSoundDevice(CallAudioManager.SoundDevice.HEADSET) }
} else if (!event.plugged) {
// if it's unplugged ? always route to speaker? fun onWirelessDeviceEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
// this is questionable? Timber.v("## VOIP onWirelessDeviceEvent $event")
audioManager.setCurrentSoundDevice(CallAudioManager.SoundDevice.SPEAKER) audioManager.bluetoothStateChange(event.plugged)
}
currentCallsListeners.forEach {
it.onAudioDevicesChange(this)
}
} }
override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) {

View File

@ -218,6 +218,7 @@
<string name="sound_device_phone">Phone</string> <string name="sound_device_phone">Phone</string>
<string name="sound_device_speaker">Speaker</string> <string name="sound_device_speaker">Speaker</string>
<string name="sound_device_headset">Headset</string> <string name="sound_device_headset">Headset</string>
<string name="sound_device_wireless_headset">Wireless Headset</string>
<string name="option_send_files">Send files</string> <string name="option_send_files">Send files</string>