mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 12:38:33 +00:00
Use Bluetooth headset mic to record voice notes.
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user