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

@@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
/**
* A listener for when audio devices are added or removed, for example if a wired headset is plugged/unplugged or Bluetooth connected/disconnected.
*/
interface AudioDeviceUpdatedListener {
fun onAudioDeviceUpdated()
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
import android.content.Context
import android.media.AudioDeviceInfo
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.RequiresApi
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
internal const val TAG = "BluetoothVoiceNoteUtil"
sealed interface BluetoothVoiceNoteUtil {
fun connectBluetoothScoConnection()
fun disconnectBluetoothScoConnection()
fun destroy()
companion object {
fun create(context: Context, listener: () -> Unit, bluetoothPermissionDeniedHandler: () -> Unit): BluetoothVoiceNoteUtil {
return if (Build.VERSION.SDK_INT >= 31) BluetoothVoiceNoteUtil31(listener) else BluetoothVoiceNoteUtilLegacy(context, listener, bluetoothPermissionDeniedHandler)
}
}
}
@RequiresApi(31)
private class BluetoothVoiceNoteUtil31(val listener: () -> Unit) : BluetoothVoiceNoteUtil {
override fun connectBluetoothScoConnection() {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
}
listener()
}
override fun disconnectBluetoothScoConnection() = Unit
override fun destroy() = Unit
}
/**
* Encapsulated logic for managing a Bluetooth connection withing the Fragment lifecycle for voice notes.
*
* @param context Context with reference to the main thread.
* @param listener This will be executed on the main thread after the Bluetooth connection connects, or if it doesn't.
* @param bluetoothPermissionDeniedHandler called when we detect the Bluetooth permission has been denied to our app.
*/
private class BluetoothVoiceNoteUtilLegacy(val context: Context, val listener: () -> Unit, val bluetoothPermissionDeniedHandler: () -> Unit) : BluetoothVoiceNoteUtil {
private val commandAndControlThread: HandlerThread = SignalExecutors.getAndStartHandlerThread("voice-note-audio", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
private val uiThreadHandler = Handler(context.mainLooper)
private val audioHandler: SignalAudioHandler = SignalAudioHandler(commandAndControlThread.looper)
private val deviceUpdatedListener: AudioDeviceUpdatedListener = object : AudioDeviceUpdatedListener {
override fun onAudioDeviceUpdated() {
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
Log.d(TAG, "Bluetooth SCO connected. Starting voice note recording on UI thread.")
uiThreadHandler.post { listener() }
}
}
}
private val signalBluetoothManager: SignalBluetoothManager = SignalBluetoothManager(context, deviceUpdatedListener, audioHandler)
private var hasWarnedAboutBluetooth = false
init {
if (Build.VERSION.SDK_INT < 31) {
audioHandler.post {
signalBluetoothManager.start()
Log.d(TAG, "Bluetooth manager started.")
}
}
}
override fun connectBluetoothScoConnection() {
if (Build.VERSION.SDK_INT >= 31) {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
}
listener()
} else {
audioHandler.post {
if (signalBluetoothManager.state.shouldUpdate()) {
signalBluetoothManager.updateDevice()
}
val currentState = signalBluetoothManager.state
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
signalBluetoothManager.startScoAudio()
} else {
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
uiThreadHandler.post {
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
bluetoothPermissionDeniedHandler()
hasWarnedAboutBluetooth = true
}
listener()
}
}
}
}
}
override fun disconnectBluetoothScoConnection() {
audioHandler.post {
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
signalBluetoothManager.stopScoAudio()
}
}
}
override fun destroy() {
audioHandler.post {
signalBluetoothManager.stop()
}
}
}

View File

@@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.webrtc.audio /*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
@@ -13,18 +18,19 @@ import android.media.AudioManager
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Manages the bluetooth lifecycle with a headset. This class doesn't make any * 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, * determination on if bluetooth should be used. It determines if a device is connected,
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting * reports that to the [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [SignalAudioManager]. * to the device if requested by [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager].
*/ */
@SuppressLint("MissingPermission") // targetSdkVersion is still 30 (https://issuetracker.google.com/issues/201454155) @SuppressLint("MissingPermission") // targetSdkVersion is still 30 (https://issuetracker.google.com/issues/201454155)
class SignalBluetoothManager( class SignalBluetoothManager(
private val context: Context, private val context: Context,
private val audioManager: FullSignalAudioManager, private val audioDeviceUpdatedListener: AudioDeviceUpdatedListener,
private val handler: SignalAudioHandler private val handler: SignalAudioHandler
) { ) {
@@ -139,11 +145,6 @@ class SignalBluetoothManager(
return false return false
} }
if (androidAudioManager.isBluetoothScoOn) {
Log.i(TAG, "SCO connection already started")
return true
}
state = State.CONNECTING state = State.CONNECTING
androidAudioManager.startBluetoothSco() androidAudioManager.startBluetoothSco()
androidAudioManager.isBluetoothScoOn = true androidAudioManager.isBluetoothScoOn = true
@@ -202,10 +203,6 @@ class SignalBluetoothManager(
} }
} }
private fun updateAudioDeviceState() {
audioManager.updateAudioDeviceState()
}
private fun startTimer() { private fun startTimer() {
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT) handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
} }
@@ -243,12 +240,12 @@ class SignalBluetoothManager(
stopScoAudio() stopScoAudio()
} }
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} }
private fun onServiceConnected(proxy: BluetoothHeadset?) { private fun onServiceConnected(proxy: BluetoothHeadset?) {
bluetoothHeadset = proxy bluetoothHeadset = proxy
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} }
private fun onServiceDisconnected() { private fun onServiceDisconnected() {
@@ -256,7 +253,7 @@ class SignalBluetoothManager(
bluetoothHeadset = null bluetoothHeadset = null
bluetoothDevice = null bluetoothDevice = null
state = State.UNAVAILABLE state = State.UNAVAILABLE
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} }
private fun onHeadsetConnectionStateChanged(connectionState: Int) { private fun onHeadsetConnectionStateChanged(connectionState: Int) {
@@ -265,12 +262,12 @@ class SignalBluetoothManager(
when (connectionState) { when (connectionState) {
BluetoothHeadset.STATE_CONNECTED -> { BluetoothHeadset.STATE_CONNECTED -> {
scoConnectionAttempts = 0 scoConnectionAttempts = 0
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} }
BluetoothHeadset.STATE_DISCONNECTED -> { BluetoothHeadset.STATE_DISCONNECTED -> {
stopScoAudio() stopScoAudio()
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} }
} }
} }
@@ -284,7 +281,7 @@ class SignalBluetoothManager(
Log.d(TAG, "Bluetooth audio SCO is now connected") Log.d(TAG, "Bluetooth audio SCO is now connected")
state = State.CONNECTED state = State.CONNECTED
scoConnectionAttempts = 0 scoConnectionAttempts = 0
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} else { } else {
Log.w(TAG, "Unexpected state ${audioState.toStateString()}") Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
} }
@@ -296,7 +293,7 @@ class SignalBluetoothManager(
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.") Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
return return
} }
updateAudioDeviceState() audioDeviceUpdatedListener.onAudioDeviceUpdated()
} }
} }
@@ -347,7 +344,9 @@ class SignalBluetoothManager(
} }
} else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) { } else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) {
if (wasScoDisconnected(intent)) { if (wasScoDisconnected(intent)) {
handler.post(::updateAudioDeviceState) handler.post {
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
} }
} else { } else {
Log.d(TAG, "Received broadcast of ${intent.action}") Log.d(TAG, "Received broadcast of ${intent.action}")

View File

@@ -1,18 +1,6 @@
/* /*
* Copyright (C) 2011 Whisper Systems * Copyright 2023 Signal Messenger, LLC
* * SPDX-License-Identifier: AGPL-3.0-only
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.thoughtcrime.securesms.conversation; package org.thoughtcrime.securesms.conversation;
@@ -101,6 +89,7 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.PendingIntentFlags; import org.signal.core.util.PendingIntentFlags;
import org.signal.core.util.StringUtil; import org.signal.core.util.StringUtil;
import org.signal.core.util.ThreadUtil; import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
@@ -116,6 +105,8 @@ import org.thoughtcrime.securesms.ShortcutLauncherActivity;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment; import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder; import org.thoughtcrime.securesms.audio.AudioRecorder;
import org.thoughtcrime.securesms.audio.BluetoothVoiceNoteUtil;
import org.thoughtcrime.securesms.audio.BluetoothVoiceNoteUtilKt;
import org.thoughtcrime.securesms.badges.gifts.thanks.GiftThanksSheet; import org.thoughtcrime.securesms.badges.gifts.thanks.GiftThanksSheet;
import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.ComposeText; import org.thoughtcrime.securesms.components.ComposeText;
@@ -286,11 +277,10 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper; import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.IdentityUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper; import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil; import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageUtil; import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil; import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -309,6 +299,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil; import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil;
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat;
import org.whispersystems.signalservice.api.SignalSessionLock; import org.whispersystems.signalservice.api.SignalSessionLock;
import java.io.IOException; import java.io.IOException;
@@ -422,7 +413,9 @@ public class ConversationParentFragment extends Fragment
private View navigationBarBackground; private View navigationBarBackground;
private AttachmentManager attachmentManager; private AttachmentManager attachmentManager;
private BluetoothVoiceNoteUtil bluetoothVoiceNoteUtil;
private AudioRecorder audioRecorder; private AudioRecorder audioRecorder;
private RecordingSession recordingSession; private RecordingSession recordingSession;
private BroadcastReceiver securityUpdateReceiver; private BroadcastReceiver securityUpdateReceiver;
private Stub<MediaKeyboard> emojiDrawerStub; private Stub<MediaKeyboard> emojiDrawerStub;
@@ -519,6 +512,7 @@ public class ConversationParentFragment extends Fragment
voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), true); voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), true);
voiceRecorderWakeLock = new VoiceRecorderWakeLock(requireActivity()); voiceRecorderWakeLock = new VoiceRecorderWakeLock(requireActivity());
bluetoothVoiceNoteUtil = BluetoothVoiceNoteUtil.Companion.create(requireContext(), this::beginRecording, this::onBluetoothPermissionDenied);
// TODO [alex] LargeScreenSupport -- Should be removed once we move to multi-pane layout. // TODO [alex] LargeScreenSupport -- Should be removed once we move to multi-pane layout.
new FullscreenHelper(requireActivity()).showSystemUI(); new FullscreenHelper(requireActivity()).showSystemUI();
@@ -678,6 +672,7 @@ public class ConversationParentFragment extends Fragment
public void onDestroy() { public void onDestroy() {
if (securityUpdateReceiver != null) requireActivity().unregisterReceiver(securityUpdateReceiver); if (securityUpdateReceiver != null) requireActivity().unregisterReceiver(securityUpdateReceiver);
if (pinnedShortcutReceiver != null) requireActivity().unregisterReceiver(pinnedShortcutReceiver); if (pinnedShortcutReceiver != null) requireActivity().unregisterReceiver(pinnedShortcutReceiver);
if (bluetoothVoiceNoteUtil != null) bluetoothVoiceNoteUtil.destroy();
super.onDestroy(); super.onDestroy();
} }
@@ -3251,6 +3246,25 @@ public class ConversationParentFragment extends Fragment
@Override @Override
public void onRecorderStarted() { public void onRecorderStarted() {
final AudioManagerCompat audioManager = ApplicationDependencies.getAndroidCallAudioManager();
if (audioManager.isBluetoothAvailable()) {
connectToBluetoothAndBeginRecording();
} else {
Log.d(TAG, "Recording from phone mic because no bluetooth devices were available.");
beginRecording();
}
}
private void connectToBluetoothAndBeginRecording() {
if (bluetoothVoiceNoteUtil != null) {
Log.d(TAG, "Initiating Bluetooth SCO connection...");
bluetoothVoiceNoteUtil.connectBluetoothScoConnection();
} else {
Log.e(TAG, "Unable to instantiate BluetoothVoiceNoteUtil.");
}
}
private Unit beginRecording() {
Vibrator vibrator = ServiceUtil.getVibrator(requireContext()); Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
vibrator.vibrate(20); vibrator.vibrate(20);
@@ -3260,6 +3274,18 @@ public class ConversationParentFragment extends Fragment
voiceNoteMediaController.pausePlayback(); voiceNoteMediaController.pausePlayback();
recordingSession = new RecordingSession(audioRecorder.startRecording()); recordingSession = new RecordingSession(audioRecorder.startRecording());
disposables.add(recordingSession); disposables.add(recordingSession);
return Unit.INSTANCE;
}
private Unit onBluetoothPermissionDenied() {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationParentFragment__bluetooth_permission_denied)
.setMessage(R.string.ConversationParentFragment__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call)
.setPositiveButton(R.string.ConversationParentFragment__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(requireContext())))
.setNegativeButton(R.string.ConversationParentFragment__not_now, null)
.show();
return Unit.INSTANCE;
} }
@Override @Override
@@ -3271,6 +3297,7 @@ public class ConversationParentFragment extends Fragment
@Override @Override
public void onRecorderFinished() { public void onRecorderFinished() {
bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection();
voiceRecorderWakeLock.release(); voiceRecorderWakeLock.release();
updateToggleButtonState(); updateToggleButtonState();
Vibrator vibrator = ServiceUtil.getVibrator(requireContext()); Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
@@ -4092,7 +4119,7 @@ public class ConversationParentFragment extends Fragment
} else { } else {
SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck(); SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck();
if (messageRecord.isMms() && ((MmsMessageRecord) messageRecord).isViewOnce()) { if (messageRecord.isMms() && messageRecord.isViewOnce()) {
Attachment attachment = new TombstoneAttachment(MediaUtil.VIEW_ONCE, true); Attachment attachment = new TombstoneAttachment(MediaUtil.VIEW_ONCE, true);
slideDeck = new SlideDeck(); slideDeck = new SlideDeck();
slideDeck.addSlide(MediaUtil.getSlideForAttachment(requireContext(), attachment)); slideDeck.addSlide(MediaUtil.getSlideForAttachment(requireContext(), attachment));

View File

@@ -6,6 +6,7 @@ import android.app.AlarmManager;
import android.app.KeyguardManager; import android.app.KeyguardManager;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.job.JobScheduler; import android.app.job.JobScheduler;
import android.bluetooth.BluetoothManager;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.hardware.SensorManager; import android.hardware.SensorManager;
@@ -107,4 +108,8 @@ public class ServiceUtil {
public static KeyguardManager getKeyguardManager(@NotNull Context context) { public static KeyguardManager getKeyguardManager(@NotNull Context context) {
return ContextCompat.getSystemService(context, KeyguardManager.class); return ContextCompat.getSystemService(context, KeyguardManager.class);
} }
public static BluetoothManager getBluetoothManager(@NotNull Context context) {
return ContextCompat.getSystemService(context, BluetoothManager.class);
}
} }

View File

@@ -7,7 +7,7 @@ import androidx.annotation.RequiresApi
object AudioDeviceMapping { object AudioDeviceMapping {
private val systemDeviceTypeMap: Map<SignalAudioManager.AudioDevice, List<Int>> = mapOf( 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.EARPIECE to listOf(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE),
SignalAudioManager.AudioDevice.SPEAKER_PHONE to listOf(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE), 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), 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(); 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() { public boolean isBluetoothConnected() {
if (Build.VERSION.SDK_INT >= 31) { if (Build.VERSION.SDK_INT >= 31) {
final SignalAudioManager.AudioDevice audioDevice = AudioDeviceMapping.fromPlatformType(audioManager.getCommunicationDevice().getType()); final SignalAudioManager.AudioDevice audioDevice = AudioDeviceMapping.fromPlatformType(audioManager.getCommunicationDevice().getType());
@@ -97,6 +105,11 @@ public abstract class AudioManagerCompat {
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); 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) @RequiresApi(31)
public List<AudioDeviceInfo> getAvailableCommunicationDevices() { public List<AudioDeviceInfo> getAvailableCommunicationDevices() {
return audioManager.getAvailableCommunicationDevices(); return audioManager.getAvailableCommunicationDevices();
@@ -163,7 +176,9 @@ public abstract class AudioManagerCompat {
} }
abstract public SoundPool createSoundPool(); abstract public SoundPool createSoundPool();
abstract public boolean requestCallAudioFocus(); abstract public boolean requestCallAudioFocus();
abstract public void abandonCallAudioFocus(); abstract public void abandonCallAudioFocus();
public static AudioManagerCompat create(@NonNull Context context) { public static AudioManagerCompat create(@NonNull Context context) {

View File

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

View File

@@ -1686,7 +1686,7 @@
<string name="WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call">Please enable the \"Nearby devices\" permission to use bluetooth during a call.</string> <string name="WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call">Please enable the \"Nearby devices\" permission to use bluetooth during a call.</string>
<!-- Positive action for bluetooth warning dialog to open settings --> <!-- Positive action for bluetooth warning dialog to open settings -->
<string name="WebRtcCallActivity__open_settings">Open settings</string> <string name="WebRtcCallActivity__open_settings">Open settings</string>
<!-- Negative aciton for bluetooth warning dialog to dismiss dialog --> <!-- Negative action for bluetooth warning dialog to dismiss dialog -->
<string name="WebRtcCallActivity__not_now">Not now</string> <string name="WebRtcCallActivity__not_now">Not now</string>
<!-- WebRtcCallView --> <!-- WebRtcCallView -->
@@ -2437,6 +2437,16 @@
<!-- Label for quoted gift --> <!-- Label for quoted gift -->
<string name="QuoteView__donation_for_a_friend">Donation for a friend</string> <string name="QuoteView__donation_for_a_friend">Donation for a friend</string>
<!-- ConversationParentFragment -->
<!-- Title for dialog warning about lacking bluetooth permissions during a voice message -->
<string name="ConversationParentFragment__bluetooth_permission_denied">Bluetooth permission denied</string>
<!-- Message for dialog warning about lacking bluetooth permissions during a voice message and references the permission needed by name -->
<string name="ConversationParentFragment__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call">Please enable the \"Nearby devices\" permission to use bluetooth to record voice messages.</string>
<!-- Positive action for bluetooth warning dialog to open settings -->
<string name="ConversationParentFragment__open_settings">Open settings</string>
<!-- Negative action for bluetooth warning dialog to dismiss dialog -->
<string name="ConversationParentFragment__not_now">Not now</string>
<!-- conversation_fragment --> <!-- conversation_fragment -->
<string name="conversation_fragment__scroll_to_the_bottom_content_description">Scroll to the bottom</string> <string name="conversation_fragment__scroll_to_the_bottom_content_description">Scroll to the bottom</string>