Use Bluetooth headset mic to record voice notes.

This commit is contained in:
Nicholas
2023-05-04 15:58:24 -04:00
committed by Alex Hart
parent fc9a6b98d1
commit f1fd29a477
9 changed files with 281 additions and 66 deletions

View File

@@ -7,7 +7,7 @@ import androidx.annotation.RequiresApi
object AudioDeviceMapping {
private val systemDeviceTypeMap: Map<SignalAudioManager.AudioDevice, List<Int>> = mapOf(
SignalAudioManager.AudioDevice.BLUETOOTH to listOf(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_HEARING_AID),
SignalAudioManager.AudioDevice.BLUETOOTH to listOf(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_HEARING_AID),
SignalAudioManager.AudioDevice.EARPIECE to listOf(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE),
SignalAudioManager.AudioDevice.SPEAKER_PHONE to listOf(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE),
SignalAudioManager.AudioDevice.WIRED_HEADSET to listOf(AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_USB_HEADSET),

View File

@@ -52,6 +52,14 @@ public abstract class AudioManagerCompat {
audioManager.stopBluetoothSco();
}
public boolean isBluetoothAvailable() {
if (Build.VERSION.SDK_INT >= 31) {
return audioManager.getAvailableCommunicationDevices().stream().anyMatch(it -> AudioDeviceMapping.fromPlatformType(it.getType()) == SignalAudioManager.AudioDevice.BLUETOOTH);
} else {
return isBluetoothScoAvailableOffCall();
}
}
public boolean isBluetoothConnected() {
if (Build.VERSION.SDK_INT >= 31) {
final SignalAudioManager.AudioDevice audioDevice = AudioDeviceMapping.fromPlatformType(audioManager.getCommunicationDevice().getType());
@@ -97,6 +105,11 @@ public abstract class AudioManagerCompat {
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
}
@RequiresApi(31)
public @Nullable AudioDeviceInfo getConnectedBluetoothDevice() {
return getAvailableCommunicationDevices().stream().filter(it -> AudioDeviceMapping.fromPlatformType(it.getType()) == SignalAudioManager.AudioDevice.BLUETOOTH).findAny().orElse(null);
}
@RequiresApi(31)
public List<AudioDeviceInfo> getAvailableCommunicationDevices() {
return audioManager.getAvailableCommunicationDevices();
@@ -163,7 +176,9 @@ public abstract class AudioManagerCompat {
}
abstract public SoundPool createSoundPool();
abstract public boolean requestCallAudioFocus();
abstract public void abandonCallAudioFocus();
public static AudioManagerCompat create(@NonNull Context context) {
@@ -178,9 +193,9 @@ public abstract class AudioManagerCompat {
private static class Api26AudioManagerCompat extends AudioManagerCompat {
private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build();
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build();
private AudioFocusRequest audioFocusRequest;
@@ -191,9 +206,9 @@ public abstract class AudioManagerCompat {
@Override
public SoundPool createSoundPool() {
return new SoundPool.Builder()
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setMaxStreams(1)
.build();
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setMaxStreams(1)
.build();
}
@Override
@@ -205,9 +220,9 @@ public abstract class AudioManagerCompat {
if (audioFocusRequest == null) {
audioFocusRequest = new AudioFocusRequest.Builder(AUDIOFOCUS_GAIN)
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setOnAudioFocusChangeListener(onAudioFocusChangeListener)
.build();
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setOnAudioFocusChangeListener(onAudioFocusChangeListener)
.build();
} else {
Log.w(TAG, "Trying again to request audio focus");
}
@@ -243,10 +258,10 @@ public abstract class AudioManagerCompat {
private static class Api21AudioManagerCompat extends Api19AudioManagerCompat {
private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
.build();
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
.build();
private Api21AudioManagerCompat(@NonNull Context context) {
super(context);
@@ -255,9 +270,9 @@ public abstract class AudioManagerCompat {
@Override
public SoundPool createSoundPool() {
return new SoundPool.Builder()
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setMaxStreams(1)
.build();
.setAudioAttributes(AUDIO_ATTRIBUTES)
.setMaxStreams(1)
.build();
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.webrtc.audio
import android.content.BroadcastReceiver
@@ -12,6 +17,8 @@ import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.audio.AudioDeviceUpdatedListener
import org.thoughtcrime.securesms.audio.SignalBluetoothManager
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
@@ -138,7 +145,7 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev
* bluetooth headset is then disconnected, and reconnected, the audio will again automatically switch to
* the bluetooth headset.
*/
class FullSignalAudioManager(context: Context, eventListener: EventListener?) : SignalAudioManager(context, eventListener) {
class FullSignalAudioManager(context: Context, eventListener: EventListener?) : SignalAudioManager(context, eventListener), AudioDeviceUpdatedListener {
private val signalBluetoothManager = SignalBluetoothManager(context, this, handler)
private var audioDevices: MutableSet<AudioDevice> = mutableSetOf()
@@ -175,7 +182,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
signalBluetoothManager.start()
updateAudioDeviceState()
onAudioDeviceUpdated()
wiredHeadsetReceiver = WiredHeadsetReceiver()
context.registerReceiver(wiredHeadsetReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG))
@@ -239,7 +246,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
Log.d(TAG, "Stopped")
}
fun updateAudioDeviceState() {
override fun onAudioDeviceUpdated() {
handler.assertHandlerThread()
Log.i(
@@ -356,7 +363,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
}
Log.d(TAG, "New default: $defaultAudioDevice userSelected: $userSelectedAudioDevice")
updateAudioDeviceState()
onAudioDeviceUpdated()
}
override fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean) {
@@ -371,7 +378,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
Log.w(TAG, "Can not select $actualDevice from available $audioDevices")
}
userSelectedAudioDevice = actualDevice
updateAudioDeviceState()
onAudioDeviceUpdated()
}
private fun setAudioDevice(device: AudioDevice) {
@@ -420,7 +427,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
private fun onWiredHeadsetChange(pluggedIn: Boolean, hasMic: Boolean) {
Log.i(TAG, "onWiredHeadsetChange state: $state plug: $pluggedIn mic: $hasMic")
hasWiredHeadset = pluggedIn
updateAudioDeviceState()
onAudioDeviceUpdated()
}
private inner class WiredHeadsetReceiver : BroadcastReceiver() {

View File

@@ -1,411 +0,0 @@
package org.thoughtcrime.securesms.webrtc.audio
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import java.util.concurrent.TimeUnit
/**
* Manages the bluetooth lifecycle with a headset. This class doesn't make any
* determination on if bluetooth should be used. It determines if a device is connected,
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [SignalAudioManager].
*/
@SuppressLint("MissingPermission") // targetSdkVersion is still 30 (https://issuetracker.google.com/issues/201454155)
class SignalBluetoothManager(
private val context: Context,
private val audioManager: FullSignalAudioManager,
private val handler: SignalAudioHandler
) {
var state: State = State.UNINITIALIZED
get() {
handler.assertHandlerThread()
return field
}
private set(value) {
Log.d(TAG, "Updating STATE from $field to $value")
field = value
}
private var bluetoothAdapter: BluetoothAdapter? = null
private var bluetoothDevice: BluetoothDevice? = null
private var bluetoothHeadset: BluetoothHeadset? = null
private var scoConnectionAttempts = 0
private val androidAudioManager = ApplicationDependencies.getAndroidCallAudioManager()
private val bluetoothListener = BluetoothServiceListener()
private var bluetoothReceiver: BluetoothHeadsetBroadcastReceiver? = null
private val bluetoothTimeout = { onBluetoothTimeout() }
fun start() {
handler.assertHandlerThread()
Log.d(TAG, "start(): $state")
if (state != State.UNINITIALIZED) {
Log.w(TAG, "Invalid starting state")
return
}
bluetoothHeadset = null
bluetoothDevice = null
scoConnectionAttempts = 0
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
Log.i(TAG, "Device does not support Bluetooth")
return
}
if (!androidAudioManager.isBluetoothScoAvailableOffCall) {
Log.w(TAG, "Bluetooth SCO audio is not available off call")
return
}
if (bluetoothAdapter?.getProfileProxy(context, bluetoothListener, BluetoothProfile.HEADSET) != true) {
Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed")
return
}
val bluetoothHeadsetFilter = IntentFilter().apply {
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
addAction(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT)
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED)
addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
addAction(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED)
}
bluetoothReceiver = BluetoothHeadsetBroadcastReceiver()
context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter)
Log.i(TAG, "Headset profile state: ${bluetoothAdapter?.getProfileConnectionState(BluetoothProfile.HEADSET)?.toStateString()}")
Log.i(TAG, "Bluetooth proxy for headset profile has started")
state = State.UNAVAILABLE
}
fun stop() {
handler.assertHandlerThread()
Log.d(TAG, "stop(): state: $state")
if (bluetoothAdapter == null) {
return
}
stopScoAudio()
context.safeUnregisterReceiver(bluetoothReceiver)
bluetoothReceiver = null
cancelTimer()
if (bluetoothHeadset != null) {
bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset)
bluetoothHeadset = null
}
bluetoothAdapter = null
bluetoothDevice = null
state = State.UNINITIALIZED
}
fun startScoAudio(): Boolean {
handler.assertHandlerThread()
Log.i(TAG, "startScoAudio(): $state attempts: $scoConnectionAttempts")
if (scoConnectionAttempts >= MAX_CONNECTION_ATTEMPTS) {
Log.w(TAG, "SCO connection attempts maxed out")
return false
}
if (state != State.AVAILABLE) {
Log.w(TAG, "SCO connection failed as no headset available")
return false
}
if (androidAudioManager.isBluetoothScoOn) {
Log.i(TAG, "SCO connection already started")
return true
}
state = State.CONNECTING
androidAudioManager.startBluetoothSco()
androidAudioManager.isBluetoothScoOn = true
scoConnectionAttempts++
startTimer()
Log.i(TAG, "SCO audio started successfully.")
return true
}
fun stopScoAudio() {
handler.assertHandlerThread()
Log.i(TAG, "stopScoAudio(): $state")
if (state != State.CONNECTING && state != State.CONNECTED) {
Log.i(TAG, "Skipping SCO stop due to state.")
return
}
cancelTimer()
androidAudioManager.stopBluetoothSco()
androidAudioManager.isBluetoothScoOn = false
state = State.DISCONNECTING
Log.i(TAG, "SCO audio stopped successfully.")
}
fun updateDevice() {
handler.assertHandlerThread()
Log.d(TAG, "updateDevice(): state: $state")
if (state == State.UNINITIALIZED || bluetoothHeadset == null) {
return
}
val devices: List<BluetoothDevice>?
try {
devices = bluetoothHeadset?.connectedDevices
} catch (e: SecurityException) {
Log.w(TAG, "Unable to get bluetooth devices", e)
stop()
state = State.PERMISSION_DENIED
return
}
if (devices.isNullOrEmpty()) {
bluetoothDevice = null
state = State.UNAVAILABLE
Log.i(TAG, "No connected bluetooth headset")
} else {
bluetoothDevice = devices[0]
val audioConnected = bluetoothHeadset?.isAudioConnected(bluetoothDevice) == true
state = if (audioConnected) State.CONNECTED else State.AVAILABLE
Log.i(TAG, "Connected bluetooth headset. headsetState: ${bluetoothHeadset?.getConnectionState(bluetoothDevice)?.toStateString()} scoAudio: $audioConnected")
}
}
private fun updateAudioDeviceState() {
audioManager.updateAudioDeviceState()
}
private fun startTimer() {
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
}
private fun cancelTimer() {
handler.removeCallbacks(bluetoothTimeout)
}
private fun onBluetoothTimeout() {
Log.i(TAG, "onBluetoothTimeout: state: $state bluetoothHeadset: $bluetoothHeadset")
if (state == State.UNINITIALIZED || bluetoothHeadset == null || state != State.CONNECTING) {
return
}
var scoConnected = false
val devices: List<BluetoothDevice>? = bluetoothHeadset?.connectedDevices
if (!devices.isNullOrEmpty()) {
bluetoothDevice = devices[0]
if (bluetoothHeadset?.isAudioConnected(bluetoothDevice) == true) {
Log.d(TAG, "Connected with $bluetoothDevice")
scoConnected = true
} else {
Log.d(TAG, "Not connected with $bluetoothDevice")
}
}
if (scoConnected) {
Log.i(TAG, "Device actually connected and not timed out")
state = State.CONNECTED
scoConnectionAttempts = 0
} else {
Log.w(TAG, "Failed to connect after timeout")
stopScoAudio()
}
updateAudioDeviceState()
}
private fun onServiceConnected(proxy: BluetoothHeadset?) {
bluetoothHeadset = proxy
updateAudioDeviceState()
}
private fun onServiceDisconnected() {
stopScoAudio()
bluetoothHeadset = null
bluetoothDevice = null
state = State.UNAVAILABLE
updateAudioDeviceState()
}
private fun onHeadsetConnectionStateChanged(connectionState: Int) {
Log.i(TAG, "onHeadsetConnectionStateChanged: state: $state connectionState: ${connectionState.toStateString()}")
when (connectionState) {
BluetoothHeadset.STATE_CONNECTED -> {
scoConnectionAttempts = 0
updateAudioDeviceState()
}
BluetoothHeadset.STATE_DISCONNECTED -> {
stopScoAudio()
updateAudioDeviceState()
}
}
}
private fun onAudioStateChanged(audioState: Int, isInitialStateChange: Boolean) {
Log.i(TAG, "onAudioStateChanged: state: $state audioState: ${audioState.toStateString()} initialSticky: $isInitialStateChange")
if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
cancelTimer()
if (state === State.CONNECTING) {
Log.d(TAG, "Bluetooth audio SCO is now connected")
state = State.CONNECTED
scoConnectionAttempts = 0
updateAudioDeviceState()
} else {
Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
}
} else if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
Log.d(TAG, "Bluetooth audio SCO is now connecting...")
} else if (audioState == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
Log.d(TAG, "Bluetooth audio SCO is now disconnected")
if (isInitialStateChange) {
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
return
}
updateAudioDeviceState()
}
}
private inner class BluetoothServiceListener : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
if (profile == BluetoothProfile.HEADSET) {
handler.post {
if (state != State.UNINITIALIZED) {
onServiceConnected(proxy as? BluetoothHeadset)
}
}
}
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.HEADSET) {
handler.post {
if (state != State.UNINITIALIZED) {
onServiceDisconnected()
}
}
}
}
}
private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)
handler.post {
if (state != State.UNINITIALIZED) {
onHeadsetConnectionStateChanged(connectionState)
}
}
} else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) {
if (wasAudioStateInterrupted(intent)) {
handler.post {
scoConnectionAttempts = 0
updateDevice()
}
} else {
handler.post {
if (state != State.UNINITIALIZED) {
val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
onAudioStateChanged(connectionState, isInitialStickyBroadcast)
}
}
}
} else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) {
if (wasScoDisconnected(intent)) {
handler.post(::updateAudioDeviceState)
}
} else {
Log.d(TAG, "Received broadcast of ${intent.action}")
}
}
private fun wasAudioStateInterrupted(intent: Intent): Boolean {
val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, -1)
val prevConnectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_PREVIOUS_STATE, -1)
val bluetoothAudioDevice: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)!!
Log.i(TAG, "${bluetoothAudioDevice?.name} audio state changed from $prevConnectionState to $connectionState")
return prevConnectionState == BluetoothHeadset.STATE_AUDIO_CONNECTED && connectionState == BluetoothHeadset.STATE_AUDIO_DISCONNECTED && bluetoothHeadset?.getConnectionState(bluetoothAudioDevice) == BluetoothProfile.STATE_CONNECTED
}
private fun wasScoDisconnected(intent: Intent): Boolean {
val scoState = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)
val prevScoState = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE, -1)
Log.i(TAG, "SCO state updated from $prevScoState to $scoState")
return prevScoState == AudioManager.SCO_AUDIO_STATE_CONNECTED && scoState == AudioManager.SCO_AUDIO_STATE_DISCONNECTED
}
}
enum class State {
UNINITIALIZED,
UNAVAILABLE,
AVAILABLE,
DISCONNECTING,
CONNECTING,
CONNECTED,
PERMISSION_DENIED,
ERROR;
fun shouldUpdate(): Boolean {
return this == AVAILABLE || this == UNAVAILABLE || this == DISCONNECTING
}
fun hasDevice(): Boolean {
return this == CONNECTED || this == CONNECTING || this == AVAILABLE
}
}
companion object {
private val TAG = Log.tag(SignalBluetoothManager::class.java)
private val SCO_TIMEOUT = TimeUnit.SECONDS.toMillis(4)
private const val MAX_CONNECTION_ATTEMPTS = 2
}
}
private fun Int.toStateString(): String {
return when (this) {
BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED"
BluetoothAdapter.STATE_CONNECTED -> "CONNECTED"
BluetoothAdapter.STATE_CONNECTING -> "CONNECTING"
BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING"
BluetoothAdapter.STATE_OFF -> "OFF"
BluetoothAdapter.STATE_ON -> "ON"
BluetoothAdapter.STATE_TURNING_OFF -> "TURNING_OFF"
BluetoothAdapter.STATE_TURNING_ON -> "TURNING_ON"
else -> "UNKNOWN"
}
}