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 index c7b2ccae16..88ba87ffde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt @@ -43,6 +43,11 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener private val communicationDeviceChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device -> if (device != null) { Log.i(TAG, "OnCommunicationDeviceChangedListener: id: ${device.id} type: ${getDeviceTypeName(device.type)}") + if (state == State.RUNNING && userSelectedAudioDevice != null && device.id != userSelectedAudioDevice?.id) { + Log.w(TAG, "OnCommunicationDeviceChangedListener: Device changed to ${device.id} but user selected ${userSelectedAudioDevice?.id}. Re-asserting user selection.") + logRoutingContext("OnCommunicationDeviceChangedListener", device) + updateAudioDeviceState() + } } else { Log.w(TAG, "OnCommunicationDeviceChangedListener: null") } @@ -52,6 +57,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener Log.i(TAG, "OnModeChangedListener: ${getModeName(mode)}") if (state == State.RUNNING && mode != AudioManager.MODE_IN_COMMUNICATION) { Log.w(TAG, "OnModeChangedListener: Not MODE_IN_COMMUNICATION during a call. state: $state") + logRoutingContext("OnModeChangedListener") } } @@ -280,6 +286,36 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener } } } + private fun logRoutingContext(event: String, callbackDevice: AudioDeviceInfo? = null) { + val mode = androidAudioManager.mode + val currentDevice: AudioDeviceInfo? = androidAudioManager.communicationDevice + val availableDevices: List = androidAudioManager.availableCommunicationDevices + val selectedStillAvailable = userSelectedAudioDevice?.let { selected -> + availableDevices.any { it.id == selected.id } + } ?: false + val probableCause = when { + mode != AudioManager.MODE_IN_COMMUNICATION -> "mode_not_in_communication" + userSelectedAudioDevice != null && !selectedStillAvailable -> "user_selected_device_disconnected" + else -> "platform_or_competing_app_reroute" + } + Log.w( + TAG, + "$event: probableCause: $probableCause state: $state mode: ${getModeName(mode)} " + + "defaultDevice: $defaultAudioDevice callbackDevice: ${describeDevice(callbackDevice)} " + + "userSelected: ${describeDevice(userSelectedAudioDevice)} " + + "currentDevice: ${describeDevice(currentDevice)} availableDevices: ${describeDevices(availableDevices)}" + ) + } + private fun describeDevices(devices: List): String { + return devices.joinToString(prefix = "[", postfix = "]") { describeDevice(it) } + } + private fun describeDevice(device: AudioDeviceInfo?): String { + if (device == null) { + return "null" + } + val productName = device.productName?.toString()?.takeIf { it.isNotBlank() } ?: "unknown" + return "${device.id}:${getDeviceTypeName(device.type)}:$productName" + } private fun getModeName(mode: Int): String { return when (mode) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt b/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt new file mode 100644 index 0000000000..7651fa8b81 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.webrtc.audio + +import android.app.Application +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.SoundPool +import assertk.assertThat +import assertk.assertions.isTrue +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule +import org.thoughtcrime.securesms.testutil.SystemOutLogger +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class, sdk = [31]) +class FullSignalAudioManagerApi31Test { + + companion object { + @JvmStatic + @BeforeClass + fun setUpClass() { + Log.initialize(SystemOutLogger()) + } + } + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + private lateinit var androidAudioManager: AudioManagerCompat + private lateinit var eventListener: SignalAudioManager.EventListener + + @Before + fun setUp() { + androidAudioManager = AppDependencies.androidCallAudioManager + eventListener = mockk(relaxed = true) + + val soundPool: SoundPool = mockk(relaxed = true) + every { androidAudioManager.createSoundPool() } returns soundPool + every { soundPool.load(any(), any(), any()) } returns 1 + } + + @Test + fun `reasserts user selected device when Android reports a different communication device`() { + val userSelectedDevice = createDevice(10, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, "Phone speaker") + val systemChangedDevice = createDevice(11, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, "Phone earpiece") + + var currentCommunicationDevice: AudioDeviceInfo? = systemChangedDevice + every { androidAudioManager.communicationDevice } answers { currentCommunicationDevice } + every { androidAudioManager.availableCommunicationDevices } returns listOf(userSelectedDevice, systemChangedDevice) + every { androidAudioManager.setCommunicationDevice(any()) } answers { + currentCommunicationDevice = firstArg() + true + } + + val manager = FullSignalAudioManagerApi31(AppDependencies.application, eventListener) + + try { + setState(manager, SignalAudioManager.State.RUNNING) + setUserSelectedAudioDevice(manager, userSelectedDevice) + + clearMocks(androidAudioManager, answers = false, recordedCalls = true) + clearMocks(eventListener, answers = false, recordedCalls = true) + + triggerCommunicationDeviceChanged(manager, systemChangedDevice) + + verify(timeout = 2_000) { androidAudioManager.setCommunicationDevice(userSelectedDevice) } + verify(timeout = 2_000) { + eventListener.onAudioDeviceChanged( + SignalAudioManager.AudioDevice.SPEAKER_PHONE, + setOf(SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.EARPIECE) + ) + } + verify(exactly = 0) { eventListener.onAudioDeviceChangeFailed() } + } finally { + shutdownManager(manager) + } + } + + @Test + fun `does not reassert when Android reports the same user selected communication device`() { + val selectedDevice = createDevice(20, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, "Phone speaker") + + every { androidAudioManager.communicationDevice } returns selectedDevice + every { androidAudioManager.availableCommunicationDevices } returns listOf(selectedDevice) + + val manager = FullSignalAudioManagerApi31(AppDependencies.application, eventListener) + + try { + setState(manager, SignalAudioManager.State.RUNNING) + setUserSelectedAudioDevice(manager, selectedDevice) + + clearMocks(androidAudioManager, answers = false, recordedCalls = true) + clearMocks(eventListener, answers = false, recordedCalls = true) + + triggerCommunicationDeviceChanged(manager, selectedDevice) + + verify(exactly = 0) { androidAudioManager.setCommunicationDevice(any()) } + verify(exactly = 0) { eventListener.onAudioDeviceChanged(any(), any()) } + verify(exactly = 0) { eventListener.onAudioDeviceChangeFailed() } + } finally { + shutdownManager(manager) + } + } + + private fun createDevice(id: Int, type: Int, productName: String): AudioDeviceInfo { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.type } returns type + every { this@mockk.productName } returns productName + } + } + + private fun triggerCommunicationDeviceChanged(manager: FullSignalAudioManagerApi31, device: AudioDeviceInfo) { + val listenerField = FullSignalAudioManagerApi31::class.java.getDeclaredField("communicationDeviceChangedListener") + listenerField.isAccessible = true + val listener = listenerField.get(manager) as AudioManager.OnCommunicationDeviceChangedListener + + val handlerField = SignalAudioManager::class.java.getDeclaredField("handler") + handlerField.isAccessible = true + val handler = handlerField.get(manager) as SignalAudioHandler + + val latch = CountDownLatch(1) + val posted = handler.post { + listener.onCommunicationDeviceChanged(device) + latch.countDown() + } + + assertThat(posted).isTrue() + assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue() + } + + private fun setState(manager: FullSignalAudioManagerApi31, state: SignalAudioManager.State) { + val stateField = SignalAudioManager::class.java.getDeclaredField("state") + stateField.isAccessible = true + stateField.set(manager, state) + } + + private fun setUserSelectedAudioDevice(manager: FullSignalAudioManagerApi31, device: AudioDeviceInfo?) { + val userSelectedField = FullSignalAudioManagerApi31::class.java.getDeclaredField("userSelectedAudioDevice") + userSelectedField.isAccessible = true + userSelectedField.set(manager, device) + } + + private fun shutdownManager(manager: FullSignalAudioManagerApi31) { + setState(manager, SignalAudioManager.State.UNINITIALIZED) + setUserSelectedAudioDevice(manager, null) + manager.shutdown() + + val threadField = SignalAudioManager::class.java.getDeclaredField("commandAndControlThread") + threadField.isAccessible = true + + val start = System.nanoTime() + while (threadField.get(manager) != null && TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) <= 2_000) { + Thread.sleep(10) + } + } +}