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 f47384910..61a6ab11a 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 @@ -4,18 +4,23 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.PackageManager; import android.media.AudioAttributes; +import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; import android.media.SoundPool; import android.os.Build; +import android.os.Handler; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import org.jetbrains.annotations.Nullable; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.util.ServiceUtil; +import java.util.List; + public abstract class AudioManagerCompat { private static final String TAG = Log.tag(AudioManagerCompat.class); @@ -83,6 +88,36 @@ public abstract class AudioManagerCompat { return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); } + @RequiresApi(31) + public List getAvailableCommunicationDevices() { + return audioManager.getAvailableCommunicationDevices(); + } + + @RequiresApi(31) + public AudioDeviceInfo getCommunicationDevice() { + return audioManager.getCommunicationDevice(); + } + + @RequiresApi(31) + public boolean setCommunicationDevice(@Nullable AudioDeviceInfo device) { + return audioManager.setCommunicationDevice(device); + } + + @RequiresApi(31) + public void clearCommunicationDevice() { + audioManager.clearCommunicationDevice(); + } + + @RequiresApi(23) + public void registerAudioDeviceCallback(@NonNull AudioDeviceCallback deviceCallback, @NonNull Handler handler) { + audioManager.registerAudioDeviceCallback(deviceCallback, handler); + } + + @RequiresApi(23) + public void unregisterAudioDeviceCallback(@NonNull AudioDeviceCallback deviceCallback) { + audioManager.unregisterAudioDeviceCallback(deviceCallback); + } + @SuppressLint("WrongConstant") public boolean isWiredHeadsetOn() { if (Build.VERSION.SDK_INT < 23) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt new file mode 100644 index 000000000..a7bb165a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.net.Uri +import androidx.annotation.RequiresApi +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * API 31 introduces new audio manager methods to handle audio routing, including to Bluetooth devices. + * This is important because API 31 also introduces new, more restrictive bluetooth permissioning, + * and the previous SignalAudioManager implementation would have required us to ask for (poorly labeled & scary) Bluetooth permissions. + */ +@RequiresApi(31) +class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener?) : SignalAudioManager(context, eventListener) { + private val TAG = Log.tag(FullSignalAudioManagerApi31::class.java) + + private var defaultDevice = AudioDevice.NONE + private val deviceCallback = object : AudioDeviceCallback() { + + override fun onAudioDevicesAdded(addedDevices: Array) { + super.onAudioDevicesAdded(addedDevices) + if (state == State.RUNNING) { + // Switch to any new audio devices immediately. + Log.i(TAG, "onAudioDevicesAdded $addedDevices") + val firstNewlyAddedDevice = addedDevices.firstNotNullOf { fromPlatformType(it.type) } + selectAudioDevice(null, firstNewlyAddedDevice) + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + super.onAudioDevicesRemoved(removedDevices) + if (state == State.RUNNING) { + val currentDevice = androidAudioManager.communicationDevice + if (currentDevice != null && removedDevices.map { it.address }.contains(currentDevice.address)) { + selectAudioDevice(null, defaultDevice) + } + } + } + } + + private val systemDeviceTypeMap: Map> = mapOf( + AudioDevice.BLUETOOTH to listOf(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLE_HEADSET), + AudioDevice.EARPIECE to listOf(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE), + AudioDevice.SPEAKER_PHONE to listOf(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE), + AudioDevice.WIRED_HEADSET to listOf(AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_USB_HEADSET), + AudioDevice.NONE to emptyList() + ) + + private fun getEquivalentPlatformTypes(audioDevice: AudioDevice): List { + return systemDeviceTypeMap[audioDevice]!! + } + + private fun fromPlatformType(type: Int): AudioDevice { + for (kind in AudioDevice.values()) { + if (getEquivalentPlatformTypes(kind).contains(type)) return kind + } + return AudioDevice.NONE + } + + + override fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) { + defaultDevice = newDefaultDevice + } + + override fun initialize() { + val focusedGained = androidAudioManager.requestCallAudioFocus() + if (!focusedGained) { + handler.postDelayed({ androidAudioManager.requestCallAudioFocus() }, 500) + } + androidAudioManager.registerAudioDeviceCallback(deviceCallback, handler) + state = State.PREINITIALIZED + } + + override fun start() { + incomingRinger.stop() + outgoingRinger.stop() + + val focusedGained = androidAudioManager.requestCallAudioFocus() + if (!focusedGained) { + handler.postDelayed({ androidAudioManager.requestCallAudioFocus() }, 500) + } + + if (androidAudioManager.availableCommunicationDevices.any { getEquivalentPlatformTypes(AudioDevice.BLUETOOTH).contains(it.type) }) { + selectAudioDevice(null, AudioDevice.BLUETOOTH) + } else if (androidAudioManager.availableCommunicationDevices.any { getEquivalentPlatformTypes(AudioDevice.WIRED_HEADSET).contains(it.type) }) { + selectAudioDevice(null, AudioDevice.WIRED_HEADSET) + } + + state = State.RUNNING + androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + } + + override fun stop(playDisconnect: Boolean) { + incomingRinger.stop() + outgoingRinger.stop() + + if (playDisconnect && state != State.UNINITIALIZED) { + val volume: Float = androidAudioManager.ringVolumeWithMinimum() + soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f) + } + androidAudioManager.unregisterAudioDeviceCallback(deviceCallback) + androidAudioManager.clearCommunicationDevice() + state = State.UNINITIALIZED + + androidAudioManager.abandonCallAudioFocus() + } + + override fun selectAudioDevice(recipientId: RecipientId?, device: AudioDevice) { + val devices: List = androidAudioManager.availableCommunicationDevices + + try { + val chosenDevice: AudioDeviceInfo = devices.first { getEquivalentPlatformTypes(device).contains(it.type) } + val result = androidAudioManager.setCommunicationDevice(chosenDevice) + if (result) { + Log.i(TAG, "Set active device to ID ${chosenDevice.id}, type ${chosenDevice.type}") + eventListener?.onAudioDeviceChanged(activeDevice = device, devices = devices.map { fromPlatformType(it.type) }.toSet()) + } else { + Log.w(TAG, "Setting device $chosenDevice failed.") + } + } catch (e: NoSuchElementException) { + androidAudioManager.clearCommunicationDevice() + } + } + + override fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) { + Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate") + androidAudioManager.mode = AudioManager.MODE_RINGTONE + if (androidAudioManager.isMicrophoneMute) { + androidAudioManager.isMicrophoneMute = false + } + setDefaultAudioDevice(null, AudioDevice.SPEAKER_PHONE, false) + + incomingRinger.start(ringtoneUri, vibrate) + } + + override fun startOutgoingRinger() { + androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + if (androidAudioManager.isMicrophoneMute) { + androidAudioManager.isMicrophoneMute = false + } + outgoingRinger.start(OutgoingRinger.Type.RINGING) + } +} \ No newline at end of file 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 7437d3a75..33590e839 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 @@ -42,6 +42,8 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev fun create(context: Context, eventListener: EventListener?, isGroup: Boolean): SignalAudioManager { return if (AndroidTelecomUtil.telecomSupported && !isGroup) { TelecomAwareSignalAudioManager(context, eventListener) + } else if (Build.VERSION.SDK_INT >= 31) { + FullSignalAudioManagerApi31(context, eventListener) } else { FullSignalAudioManager(context, eventListener) }