From 9f6eb142d28a72eac1e0a9cdcc9493d2c47c7bb8 Mon Sep 17 00:00:00 2001 From: Nicholas Date: Wed, 14 Dec 2022 13:53:02 -0500 Subject: [PATCH] Switch to BT mic if available for voice memo recording. Addresses #12016. --- .../securesms/audio/AudioRecorder.java | 18 ++- .../audio/BluetoothScoSessionManager.kt | 110 ++++++++++++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothScoSessionManager.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index 74d39c1a59..54ba18f6eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -26,15 +26,17 @@ public class AudioRecorder { private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder"); - private final Context context; - private final AudioRecorderFocusManager audioFocusManager; + private final Context context; + private final AudioRecorderFocusManager audioFocusManager; + private final BluetoothScoSessionManager bluetoothScoSessionManager; private Recorder recorder; private Uri captureUri; public AudioRecorder(@NonNull Context context) { this.context = context; - audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording()); + audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording()); + bluetoothScoSessionManager = new BluetoothScoSessionManager(context); } public void startRecording() { @@ -53,13 +55,18 @@ public class AudioRecorder { .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) .withMimeType(MediaUtil.AUDIO_AAC) .createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e)); - recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec(); int focusResult = audioFocusManager.requestAudioFocus(); if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult); } - recorder.start(fds[1]); + if (bluetoothScoSessionManager.isBluetoothScoCapable()) { + Log.i(TAG, "Starting voice memo recording with Bluetooth mic."); + bluetoothScoSessionManager.startBluetooth(recorder, fds[1]); + } else { + Log.i(TAG, "Starting voice memo recording with built-in mic."); + recorder.start(fds[1]); + } } catch (IOException e) { Log.w(TAG, e); } @@ -79,6 +86,7 @@ public class AudioRecorder { audioFocusManager.abandonAudioFocus(); recorder.stop(); + bluetoothScoSessionManager.stopBluetooth(); try { long size = MediaUtil.getMediaSize(context, captureUri); diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothScoSessionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothScoSessionManager.kt new file mode 100644 index 0000000000..4a138d247b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothScoSessionManager.kt @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.audio + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothHeadset +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.ParcelFileDescriptor +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.util.ServiceUtil +import java.io.IOException + +/** + * This class manages the SCO (Synchronous Connection Oriented) Bluetooth link for voice memos. + * A consumer of this class should first check if the hardware is prepared to receive input from a bluetooth device using [isBluetoothScoCapable] + * Then they can use [startBluetooth] to initiate a session. + * We send initialize an SCO link and receive its state updates as a system Broadcast. + * Once the connection is established, we start storing audio via the provided [Recorder] + * It is the responsibility of the owner of this object to close the Bluetooth link when recording is finished. + * + * Note: in testing, closing the SCO link does not interrupt an in-progress recording, and a user is free to continue recording on the device's mic. + */ +class BluetoothScoSessionManager(val context: Context) : BroadcastReceiver() { + private val audioManager: AudioManager = ServiceUtil.getAudioManager(context) + private var bluetoothSessionAlive: Boolean = false + private var callback: Recorder? = null + private var fileDescriptor: ParcelFileDescriptor? = null + + private fun register() { + Log.d(TAG, "Registering Bluetooth SCO broadcast receiver.") + val filter = IntentFilter() + filter.addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) + context.registerReceiver(this, filter) + } + + private fun unregister() { + Log.d(TAG, "Unregistering Bluetooth SCO broadcast receiver.") + context.unregisterReceiver(this) + } + + fun isBluetoothScoCapable(): Boolean { + if (!audioManager.isBluetoothScoAvailableOffCall) { + return false + } + + return if (Build.VERSION.SDK_INT >= 31) { + audioManager.availableCommunicationDevices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } + } else if (Build.VERSION.SDK_INT >= 23) { + audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } + } else { + hasBluetoothMicConnectedLegacy() + } + } + + @SuppressLint("MissingPermission") + private fun hasBluetoothMicConnectedLegacy(): Boolean { + val mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled && + mBluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothHeadset.STATE_CONNECTED + } + + fun startBluetooth(callback: Recorder, fileDescriptor: ParcelFileDescriptor) { + Log.d(TAG, "Starting Bluetooth SCO for voice memo.") + this.callback = callback + this.fileDescriptor = fileDescriptor + register() + audioManager.startBluetoothSco() + } + + fun stopBluetooth() { + if (bluetoothSessionAlive) { + Log.d(TAG, "Stopping Bluetooth SCO for voice memo.") + bluetoothSessionAlive = false + unregister() + if (audioManager.isBluetoothScoOn) { + audioManager.stopBluetoothSco() + } + } + } + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action ?: return + if (action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) { + val state: Int = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1) + when (state) { + AudioManager.SCO_AUDIO_STATE_CONNECTED -> try { + bluetoothSessionAlive = true + callback?.start(fileDescriptor) + Log.d(TAG, "Bluetooth SCO connected.") + } catch (e: IOException) { + Log.w(TAG, e) + } + AudioManager.SCO_AUDIO_STATE_CONNECTING -> Log.d(TAG, "Bluetooth SCO in connecting state.") + AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> { + Log.d(TAG, "Bluetooth SCO disconnected.") + stopBluetooth() + } + } + } + } + + companion object { + const val TAG = "BluetoothMicManager" + } +}