From f1fd29a4777c0dcfd7f9711d71443ecd94e31aaf Mon Sep 17 00:00:00 2001 From: Nicholas Date: Thu, 4 May 2023 15:58:24 -0400 Subject: [PATCH] Use Bluetooth headset mic to record voice notes. --- .../audio/AudioDeviceUpdatedListener.kt | 13 ++ .../securesms/audio/BluetoothVoiceNoteUtil.kt | 139 ++++++++++++++++++ .../audio/SignalBluetoothManager.kt | 41 +++--- .../ConversationParentFragment.java | 69 ++++++--- .../securesms/util/ServiceUtil.java | 5 + .../webrtc/audio/AudioDeviceMapping.kt | 2 +- .../webrtc/audio/AudioManagerCompat.java | 47 ++++-- .../webrtc/audio/SignalAudioManager.kt | 19 ++- app/src/main/res/values/strings.xml | 12 +- 9 files changed, 281 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/audio/AudioDeviceUpdatedListener.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothVoiceNoteUtil.kt rename app/src/main/java/org/thoughtcrime/securesms/{webrtc => }/audio/SignalBluetoothManager.kt (92%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioDeviceUpdatedListener.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioDeviceUpdatedListener.kt new file mode 100644 index 0000000000..f7a0d4c48b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioDeviceUpdatedListener.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothVoiceNoteUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothVoiceNoteUtil.kt new file mode 100644 index 0000000000..affc9e9445 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/BluetoothVoiceNoteUtil.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/SignalBluetoothManager.kt similarity index 92% rename from app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt rename to app/src/main/java/org/thoughtcrime/securesms/audio/SignalBluetoothManager.kt index 195b6a12c7..c03aba182e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/SignalBluetoothManager.kt @@ -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.bluetooth.BluetoothAdapter @@ -13,18 +18,19 @@ import android.media.AudioManager import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.util.safeUnregisterReceiver +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler 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]. + * reports that to the [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager], and then handles connecting/disconnecting + * to the device if requested by [org.thoughtcrime.securesms.webrtc.audio.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 audioDeviceUpdatedListener: AudioDeviceUpdatedListener, private val handler: SignalAudioHandler ) { @@ -139,11 +145,6 @@ class SignalBluetoothManager( return false } - if (androidAudioManager.isBluetoothScoOn) { - Log.i(TAG, "SCO connection already started") - return true - } - state = State.CONNECTING androidAudioManager.startBluetoothSco() androidAudioManager.isBluetoothScoOn = true @@ -202,10 +203,6 @@ class SignalBluetoothManager( } } - private fun updateAudioDeviceState() { - audioManager.updateAudioDeviceState() - } - private fun startTimer() { handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT) } @@ -243,12 +240,12 @@ class SignalBluetoothManager( stopScoAudio() } - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } private fun onServiceConnected(proxy: BluetoothHeadset?) { bluetoothHeadset = proxy - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } private fun onServiceDisconnected() { @@ -256,7 +253,7 @@ class SignalBluetoothManager( bluetoothHeadset = null bluetoothDevice = null state = State.UNAVAILABLE - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } private fun onHeadsetConnectionStateChanged(connectionState: Int) { @@ -265,12 +262,12 @@ class SignalBluetoothManager( when (connectionState) { BluetoothHeadset.STATE_CONNECTED -> { scoConnectionAttempts = 0 - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } BluetoothHeadset.STATE_DISCONNECTED -> { stopScoAudio() - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } } } @@ -284,7 +281,7 @@ class SignalBluetoothManager( Log.d(TAG, "Bluetooth audio SCO is now connected") state = State.CONNECTED scoConnectionAttempts = 0 - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } else { Log.w(TAG, "Unexpected state ${audioState.toStateString()}") } @@ -296,7 +293,7 @@ class SignalBluetoothManager( Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.") return } - updateAudioDeviceState() + audioDeviceUpdatedListener.onAudioDeviceUpdated() } } @@ -347,7 +344,9 @@ class SignalBluetoothManager( } } else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) { if (wasScoDisconnected(intent)) { - handler.post(::updateAudioDeviceState) + handler.post { + audioDeviceUpdatedListener.onAudioDeviceUpdated() + } } } else { Log.d(TAG, "Received broadcast of ${intent.action}") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 0befe95964..31b9418e24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -1,18 +1,6 @@ /* - * Copyright (C) 2011 Whisper Systems - * - * 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 . + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only */ 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.StringUtil; 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.SimpleTask; 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.TombstoneAttachment; 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.components.AnimatingToggle; 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.FullscreenHelper; import org.thoughtcrime.securesms.util.IdentityUtil; -import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.util.Material3OnScrollHelper; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.MessageConstraintsUtil; +import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.MessageUtil; import org.thoughtcrime.securesms.util.PlayStoreUtil; 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.wallpaper.ChatWallpaper; import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil; +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; import org.whispersystems.signalservice.api.SignalSessionLock; import java.io.IOException; @@ -421,8 +412,10 @@ public class ConversationParentFragment extends Fragment private Stub voiceNotePlayerViewStub; private View navigationBarBackground; - private AttachmentManager attachmentManager; - private AudioRecorder audioRecorder; + private AttachmentManager attachmentManager; + private BluetoothVoiceNoteUtil bluetoothVoiceNoteUtil; + private AudioRecorder audioRecorder; + private RecordingSession recordingSession; private BroadcastReceiver securityUpdateReceiver; private Stub emojiDrawerStub; @@ -519,6 +512,7 @@ public class ConversationParentFragment extends Fragment voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), true); 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. new FullscreenHelper(requireActivity()).showSystemUI(); @@ -676,8 +670,9 @@ public class ConversationParentFragment extends Fragment @Override public void onDestroy() { - if (securityUpdateReceiver != null) requireActivity().unregisterReceiver(securityUpdateReceiver); - if (pinnedShortcutReceiver != null) requireActivity().unregisterReceiver(pinnedShortcutReceiver); + if (securityUpdateReceiver != null) requireActivity().unregisterReceiver(securityUpdateReceiver); + if (pinnedShortcutReceiver != null) requireActivity().unregisterReceiver(pinnedShortcutReceiver); + if (bluetoothVoiceNoteUtil != null) bluetoothVoiceNoteUtil.destroy(); super.onDestroy(); } @@ -3251,6 +3246,25 @@ public class ConversationParentFragment extends Fragment @Override 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.vibrate(20); @@ -3260,6 +3274,18 @@ public class ConversationParentFragment extends Fragment voiceNoteMediaController.pausePlayback(); recordingSession = new RecordingSession(audioRecorder.startRecording()); 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 @@ -3271,6 +3297,7 @@ public class ConversationParentFragment extends Fragment @Override public void onRecorderFinished() { + bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection(); voiceRecorderWakeLock.release(); updateToggleButtonState(); Vibrator vibrator = ServiceUtil.getVibrator(requireContext()); @@ -4092,7 +4119,7 @@ public class ConversationParentFragment extends Fragment } else { 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); slideDeck = new SlideDeck(); slideDeck.addSlide(MediaUtil.getSlideForAttachment(requireContext(), attachment)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java index e5294a025f..ac81b7c50b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java @@ -6,6 +6,7 @@ import android.app.AlarmManager; import android.app.KeyguardManager; import android.app.NotificationManager; import android.app.job.JobScheduler; +import android.bluetooth.BluetoothManager; import android.content.ClipboardManager; import android.content.Context; import android.hardware.SensorManager; @@ -107,4 +108,8 @@ public class ServiceUtil { public static KeyguardManager getKeyguardManager(@NotNull Context context) { return ContextCompat.getSystemService(context, KeyguardManager.class); } + + public static BluetoothManager getBluetoothManager(@NotNull Context context) { + return ContextCompat.getSystemService(context, BluetoothManager.class); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioDeviceMapping.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioDeviceMapping.kt index b8f36e96b2..0c68d54538 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioDeviceMapping.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioDeviceMapping.kt @@ -7,7 +7,7 @@ import androidx.annotation.RequiresApi object AudioDeviceMapping { private val systemDeviceTypeMap: Map> = 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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java index 3b3643b88b..3995e3a575 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java @@ -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 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(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt index 5a7b4bd4d8..deac5b868e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -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 = 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() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2451339ed3..b61269bef4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1686,7 +1686,7 @@ Please enable the \"Nearby devices\" permission to use bluetooth during a call. Open settings - + Not now @@ -2437,6 +2437,16 @@ Donation for a friend + + + Bluetooth permission denied + + Please enable the \"Nearby devices\" permission to use bluetooth to record voice messages. + + Open settings + + Not now + Scroll to the bottom