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