From 8c8eb27a1dac2fc03b11328fd73a17f2cdb5ecbc Mon Sep 17 00:00:00 2001 From: Adam Hammer Date: Tue, 18 Mar 2025 11:09:13 -0700 Subject: [PATCH 1/3] audio refactoring --- .../redux/action/AudioDeviceAction.java | 103 +++++++++++ .../redux/reducer/AudioDeviceReducer.java | 129 ++++++++++++++ .../ui/calling/redux/state/AudioDevice.java | 74 ++++++++ .../calling/redux/state/AudioDeviceState.java | 121 +++++++++++++ .../calling/redux/state/AudioDeviceType.java | 47 +++++ .../calling/service/AudioDeviceService.java | 154 ++++++++++++++++ .../redux/reducer/AudioDeviceReducerTest.java | 130 ++++++++++++++ .../service/AudioDeviceServiceTest.java | 166 ++++++++++++++++++ 8 files changed, 924 insertions(+) create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDevice.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceType.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/service/AudioDeviceServiceTest.java diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java new file mode 100644 index 000000000..b2f457fb5 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java @@ -0,0 +1,103 @@ +package com.azure.android.communication.ui.calling.redux.action; + +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; + +import java.util.List; + +/** + * Actions related to audio device management. + */ +public abstract class AudioDeviceAction { + private AudioDeviceAction() { + } + + /** + * Action dispatched when a new audio device is connected. + */ + public static final class DeviceConnected { + private final AudioDevice device; + + public DeviceConnected(AudioDevice device) { + this.device = device; + } + + public AudioDevice getDevice() { + return device; + } + } + + /** + * Action dispatched when an audio device is disconnected. + */ + public static final class DeviceDisconnected { + private final AudioDevice device; + + public DeviceDisconnected(AudioDevice device) { + this.device = device; + } + + public AudioDevice getDevice() { + return device; + } + } + + /** + * Action dispatched when an audio device is manually selected. + */ + public static final class DeviceSelected { + private final AudioDevice device; + + public DeviceSelected(AudioDevice device) { + this.device = device; + } + + public AudioDevice getDevice() { + return device; + } + } + + /** + * Action dispatched when available audio devices are discovered. + */ + public static final class DevicesDiscovered { + private final List devices; + + public DevicesDiscovered(List devices) { + this.devices = devices; + } + + public List getDevices() { + return devices; + } + } + + /** + * Action dispatched when wired headset connection state changes. + */ + public static final class WiredHeadsetStateChanged { + private final boolean connected; + + public WiredHeadsetStateChanged(boolean connected) { + this.connected = connected; + } + + public boolean isConnected() { + return connected; + } + } + + /** + * Action dispatched when bluetooth connection state changes. + */ + public static final class BluetoothStateChanged { + private final boolean connected; + + public BluetoothStateChanged(boolean connected) { + this.connected = connected; + } + + public boolean isConnected() { + return connected; + } + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java new file mode 100644 index 000000000..62d9e362d --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java @@ -0,0 +1,129 @@ +package com.azure.android.communication.ui.calling.redux.reducer; + +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; + +import java.util.ArrayList; +import java.util.List; + +/** + * Reducer for handling audio device state changes. + */ +public final class AudioDeviceReducer { + private AudioDeviceReducer() { + } + + /** + * Reduces the state based on the action. + * + * @param state Current state + * @param action Action to process + * @return New state + */ + public static AudioDeviceState reduce(final AudioDeviceState state, final Object action) { + if (action instanceof AudioDeviceAction.DeviceConnected) { + return handleDeviceConnected(state, (AudioDeviceAction.DeviceConnected) action); + } else if (action instanceof AudioDeviceAction.DeviceDisconnected) { + return handleDeviceDisconnected(state, (AudioDeviceAction.DeviceDisconnected) action); + } else if (action instanceof AudioDeviceAction.DeviceSelected) { + return handleDeviceSelected(state, (AudioDeviceAction.DeviceSelected) action); + } else if (action instanceof AudioDeviceAction.DevicesDiscovered) { + return handleDevicesDiscovered(state, (AudioDeviceAction.DevicesDiscovered) action); + } else if (action instanceof AudioDeviceAction.WiredHeadsetStateChanged) { + return handleWiredHeadsetStateChanged(state, (AudioDeviceAction.WiredHeadsetStateChanged) action); + } else if (action instanceof AudioDeviceAction.BluetoothStateChanged) { + return handleBluetoothStateChanged(state, (AudioDeviceAction.BluetoothStateChanged) action); + } + return state; + } + + private static AudioDeviceState handleDeviceConnected( + AudioDeviceState state, + AudioDeviceAction.DeviceConnected action) { + AudioDevice newDevice = action.getDevice(); + List updatedDevices = new ArrayList<>(state.getAvailableDevices()); + + // Add device if not already present + if (!updatedDevices.contains(newDevice)) { + updatedDevices.add(newDevice); + } + + // Auto-switch based on priority + AudioDevice currentDevice = state.getCurrentDevice(); + if (newDevice.getType().hasHigherPriorityThan(currentDevice.getType())) { + return state.withAvailableDevices(updatedDevices).withCurrentDevice(newDevice); + } + + return state.withAvailableDevices(updatedDevices); + } + + private static AudioDeviceState handleDeviceDisconnected( + AudioDeviceState state, + AudioDeviceAction.DeviceDisconnected action) { + AudioDevice disconnectedDevice = action.getDevice(); + List updatedDevices = new ArrayList<>(state.getAvailableDevices()); + updatedDevices.remove(disconnectedDevice); + + // If current device was disconnected, switch to highest priority available device + if (state.getCurrentDevice().equals(disconnectedDevice)) { + AudioDevice newDevice = findHighestPriorityDevice(updatedDevices); + return state.withAvailableDevices(updatedDevices).withCurrentDevice(newDevice); + } + + return state.withAvailableDevices(updatedDevices); + } + + private static AudioDeviceState handleDeviceSelected( + AudioDeviceState state, + AudioDeviceAction.DeviceSelected action) { + AudioDevice selectedDevice = action.getDevice(); + if (state.getAvailableDevices().contains(selectedDevice)) { + return state.withCurrentDevice(selectedDevice); + } + return state; + } + + private static AudioDeviceState handleDevicesDiscovered( + AudioDeviceState state, + AudioDeviceAction.DevicesDiscovered action) { + List newDevices = action.getDevices(); + + // If current device is no longer available, switch to highest priority device + AudioDevice currentDevice = state.getCurrentDevice(); + if (!newDevices.contains(currentDevice)) { + AudioDevice newDevice = findHighestPriorityDevice(newDevices); + return state.withAvailableDevices(newDevices).withCurrentDevice(newDevice); + } + + return state.withAvailableDevices(newDevices); + } + + private static AudioDeviceState handleWiredHeadsetStateChanged( + AudioDeviceState state, + AudioDeviceAction.WiredHeadsetStateChanged action) { + return state.withWiredHeadsetConnected(action.isConnected()); + } + + private static AudioDeviceState handleBluetoothStateChanged( + AudioDeviceState state, + AudioDeviceAction.BluetoothStateChanged action) { + return state.withBluetoothConnected(action.isConnected()); + } + + private static AudioDevice findHighestPriorityDevice(List devices) { + AudioDevice highestPriority = null; + for (AudioDevice device : devices) { + if (highestPriority == null || + device.getType().hasHigherPriorityThan(highestPriority.getType())) { + highestPriority = device; + } + } + // Fallback to speaker if no devices available + if (highestPriority == null) { + highestPriority = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); + } + return highestPriority; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDevice.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDevice.java new file mode 100644 index 000000000..b4444be51 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDevice.java @@ -0,0 +1,74 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Represents an audio device in the system. + */ +public final class AudioDevice { + private final AudioDeviceType type; + private final String deviceId; + private final String deviceName; + + /** + * Creates a new instance of AudioDevice. + * + * @param type The type of audio device + * @param deviceId Unique identifier for the device + * @param deviceName Human-readable name of the device + */ + public AudioDevice(AudioDeviceType type, String deviceId, String deviceName) { + this.type = type; + this.deviceId = deviceId; + this.deviceName = deviceName; + } + + /** + * Gets the type of audio device. + * + * @return AudioDeviceType + */ + public AudioDeviceType getType() { + return type; + } + + /** + * Gets the device ID. + * + * @return Device ID string + */ + public String getDeviceId() { + return deviceId; + } + + /** + * Gets the device name. + * + * @return Device name string + */ + public String getDeviceName() { + return deviceName; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + AudioDevice other = (AudioDevice) obj; + return type == other.type && deviceId.equals(other.deviceId); + } + + @Override + public int hashCode() { + int result = type.hashCode(); + result = 31 * result + deviceId.hashCode(); + return result; + } + + @Override + public String toString() { + return "AudioDevice{" + + "type=" + type + + ", deviceId='" + deviceId + '\'' + + ", deviceName='" + deviceName + '\'' + + '}'; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java new file mode 100644 index 000000000..a42a605d3 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java @@ -0,0 +1,121 @@ +package com.azure.android.communication.ui.calling.redux.state; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents the state of audio devices in the system. + */ +public final class AudioDeviceState { + private final AudioDevice currentDevice; + private final List availableDevices; + private final boolean isWiredHeadsetConnected; + private final boolean isBluetoothConnected; + + /** + * Creates a new instance of AudioDeviceState. + * + * @param currentDevice The currently selected audio device + * @param availableDevices List of available audio devices + * @param isWiredHeadsetConnected Whether a wired headset is connected + * @param isBluetoothConnected Whether a bluetooth device is connected + */ + public AudioDeviceState( + AudioDevice currentDevice, + List availableDevices, + boolean isWiredHeadsetConnected, + boolean isBluetoothConnected) { + this.currentDevice = currentDevice; + this.availableDevices = new ArrayList<>(availableDevices); + this.isWiredHeadsetConnected = isWiredHeadsetConnected; + this.isBluetoothConnected = isBluetoothConnected; + } + + /** + * Creates initial state with speaker as default device. + * + * @return Initial AudioDeviceState + */ + public static AudioDeviceState getInitialState() { + List devices = new ArrayList<>(); + AudioDevice speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); + devices.add(speaker); + return new AudioDeviceState(speaker, devices, false, false); + } + + /** + * Gets the currently selected audio device. + * + * @return Current AudioDevice + */ + public AudioDevice getCurrentDevice() { + return currentDevice; + } + + /** + * Gets list of available audio devices. + * + * @return List of available AudioDevices + */ + public List getAvailableDevices() { + return new ArrayList<>(availableDevices); + } + + /** + * Checks if wired headset is connected. + * + * @return true if wired headset is connected + */ + public boolean isWiredHeadsetConnected() { + return isWiredHeadsetConnected; + } + + /** + * Checks if bluetooth device is connected. + * + * @return true if bluetooth device is connected + */ + public boolean isBluetoothConnected() { + return isBluetoothConnected; + } + + /** + * Creates a new state with updated current device. + * + * @param device New current device + * @return Updated AudioDeviceState + */ + public AudioDeviceState withCurrentDevice(AudioDevice device) { + return new AudioDeviceState(device, availableDevices, isWiredHeadsetConnected, isBluetoothConnected); + } + + /** + * Creates a new state with updated available devices. + * + * @param devices New list of available devices + * @return Updated AudioDeviceState + */ + public AudioDeviceState withAvailableDevices(List devices) { + return new AudioDeviceState(currentDevice, devices, isWiredHeadsetConnected, isBluetoothConnected); + } + + /** + * Creates a new state with updated wired headset connection status. + * + * @param connected New wired headset connection status + * @return Updated AudioDeviceState + */ + public AudioDeviceState withWiredHeadsetConnected(boolean connected) { + return new AudioDeviceState(currentDevice, availableDevices, connected, isBluetoothConnected); + } + + /** + * Creates a new state with updated bluetooth connection status. + * + * @param connected New bluetooth connection status + * @return Updated AudioDeviceState + */ + public AudioDeviceState withBluetoothConnected(boolean connected) { + return new AudioDeviceState(currentDevice, availableDevices, isWiredHeadsetConnected, connected); + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceType.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceType.java new file mode 100644 index 000000000..6174bcdb4 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceType.java @@ -0,0 +1,47 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Enum representing different types of audio devices. + */ +public enum AudioDeviceType { + /** + * Wired headset device. + */ + WIRED_HEADSET(0), + + /** + * Bluetooth audio device. + */ + BLUETOOTH(1), + + /** + * System speaker. + */ + SPEAKER(2); + + private final int priority; + + AudioDeviceType(int priority) { + this.priority = priority; + } + + /** + * Gets the priority of the device type. + * Lower number means higher priority. + * + * @return priority value + */ + public int getPriority() { + return priority; + } + + /** + * Checks if this device type has higher priority than another. + * + * @param other The other device type to compare with + * @return true if this device has higher priority + */ + public boolean hasHigherPriorityThan(AudioDeviceType other) { + return this.priority < other.priority; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java new file mode 100644 index 000000000..e36e3a9ed --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java @@ -0,0 +1,154 @@ +package com.azure.android.communication.ui.calling.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; + +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; +import com.azure.android.communication.ui.calling.redux.Store; + +import java.util.ArrayList; +import java.util.List; + +/** + * Service for managing audio device state and system audio routing. + */ +public final class AudioDeviceService { + private final Context context; + private final AudioManager audioManager; + private final Store store; + private final BroadcastReceiver audioDeviceReceiver; + + public AudioDeviceService(Context context, Store store) { + this.context = context; + this.store = store; + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + this.audioDeviceReceiver = createAudioDeviceReceiver(); + } + + /** + * Start monitoring audio device changes. + */ + public void start() { + IntentFilter filter = new IntentFilter(); + filter.addAction(AudioManager.ACTION_HEADSET_PLUG); + filter.addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED); + filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); + context.registerReceiver(audioDeviceReceiver, filter); + + // Initial device discovery + updateAvailableDevices(); + } + + /** + * Stop monitoring audio device changes. + */ + public void stop() { + context.unregisterReceiver(audioDeviceReceiver); + } + + /** + * Switch to a specific audio device. + * + * @param device The device to switch to + */ + public void selectDevice(AudioDevice device) { + switch (device.getType()) { + case SPEAKER: + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(true); + audioManager.setBluetoothScoOn(false); + break; + case BLUETOOTH: + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(false); + audioManager.setBluetoothScoOn(true); + audioManager.startBluetoothSco(); + break; + case WIRED_HEADSET: + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(false); + audioManager.setBluetoothScoOn(false); + break; + } + store.dispatch(new AudioDeviceAction.DeviceSelected(device)); + } + + private BroadcastReceiver createAudioDeviceReceiver() { + return new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action == null) return; + + switch (action) { + case AudioManager.ACTION_HEADSET_PLUG: + boolean connected = intent.getIntExtra("state", 0) == 1; + handleWiredHeadsetConnection(connected); + break; + case AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED: + int state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1); + handleBluetoothScoStateChange(state); + break; + case AudioManager.ACTION_AUDIO_BECOMING_NOISY: + handleAudioBecomingNoisy(); + break; + } + } + }; + } + + private void handleWiredHeadsetConnection(boolean connected) { + store.dispatch(new AudioDeviceAction.WiredHeadsetStateChanged(connected)); + if (connected) { + AudioDevice headset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); + store.dispatch(new AudioDeviceAction.DeviceConnected(headset)); + } else { + AudioDevice headset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); + store.dispatch(new AudioDeviceAction.DeviceDisconnected(headset)); + } + updateAvailableDevices(); + } + + private void handleBluetoothScoStateChange(int state) { + boolean connected = state == AudioManager.SCO_AUDIO_STATE_CONNECTED; + store.dispatch(new AudioDeviceAction.BluetoothStateChanged(connected)); + if (connected) { + AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); + store.dispatch(new AudioDeviceAction.DeviceConnected(bluetooth)); + } else { + AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); + store.dispatch(new AudioDeviceAction.DeviceDisconnected(bluetooth)); + } + updateAvailableDevices(); + } + + private void handleAudioBecomingNoisy() { + // Switch to speaker when audio becomes noisy (e.g. headphones unplugged) + AudioDevice speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); + selectDevice(speaker); + } + + private void updateAvailableDevices() { + List devices = new ArrayList<>(); + + // Speaker is always available + devices.add(new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker")); + + // Check for wired headset + if (audioManager.isWiredHeadsetOn()) { + devices.add(new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset")); + } + + // Check for bluetooth + if (audioManager.isBluetoothScoAvailableOffCall()) { + devices.add(new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth")); + } + + store.dispatch(new AudioDeviceAction.DevicesDiscovered(devices)); + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java new file mode 100644 index 000000000..a6f4b7d97 --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java @@ -0,0 +1,130 @@ +package com.azure.android.communication.ui.calling.redux.reducer; + +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; + +import org.junit.Test; +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AudioDeviceReducerTest { + private final AudioDevice speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); + private final AudioDevice wiredHeadset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); + private final AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); + + @Test + public void testInitialState() { + AudioDeviceState state = AudioDeviceState.getInitialState(); + assertEquals(AudioDeviceType.SPEAKER, state.getCurrentDevice().getType()); + assertEquals(1, state.getAvailableDevices().size()); + assertFalse(state.isWiredHeadsetConnected()); + assertFalse(state.isBluetoothConnected()); + } + + @Test + public void testDeviceConnected_HigherPriority_ShouldAutoSwitch() { + AudioDeviceState initialState = new AudioDeviceState(speaker, Arrays.asList(speaker), false, false); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceConnected(wiredHeadset)); + + assertEquals(wiredHeadset, newState.getCurrentDevice()); + assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); + assertTrue(newState.getAvailableDevices().contains(speaker)); + } + + @Test + public void testDeviceConnected_LowerPriority_ShouldNotAutoSwitch() { + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, Arrays.asList(wiredHeadset), true, false); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceConnected(bluetooth)); + + assertEquals(wiredHeadset, newState.getCurrentDevice()); + assertTrue(newState.getAvailableDevices().contains(bluetooth)); + assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); + } + + @Test + public void testDeviceDisconnected_CurrentDevice_ShouldSwitchToNextHighestPriority() { + List devices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, true); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceDisconnected(wiredHeadset)); + + assertEquals(bluetooth, newState.getCurrentDevice()); + assertFalse(newState.getAvailableDevices().contains(wiredHeadset)); + assertTrue(newState.getAvailableDevices().contains(bluetooth)); + assertTrue(newState.getAvailableDevices().contains(speaker)); + } + + @Test + public void testDeviceDisconnected_NotCurrentDevice_ShouldNotSwitch() { + List devices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, true); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceDisconnected(bluetooth)); + + assertEquals(wiredHeadset, newState.getCurrentDevice()); + assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); + assertFalse(newState.getAvailableDevices().contains(bluetooth)); + assertTrue(newState.getAvailableDevices().contains(speaker)); + } + + @Test + public void testDeviceSelected_ValidDevice_ShouldSwitch() { + List devices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, true); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceSelected(bluetooth)); + + assertEquals(bluetooth, newState.getCurrentDevice()); + } + + @Test + public void testDeviceSelected_InvalidDevice_ShouldNotSwitch() { + List devices = new ArrayList<>(Arrays.asList(wiredHeadset, speaker)); + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, false); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceSelected(bluetooth)); + + assertEquals(wiredHeadset, newState.getCurrentDevice()); + } + + @Test + public void testDevicesDiscovered_CurrentDeviceNotAvailable_ShouldSwitchToHighestPriority() { + List initialDevices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, initialDevices, true, true); + + List newDevices = new ArrayList<>(Arrays.asList(bluetooth, speaker)); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DevicesDiscovered(newDevices)); + + assertEquals(bluetooth, newState.getCurrentDevice()); + assertEquals(newDevices, newState.getAvailableDevices()); + } + + @Test + public void testDevicesDiscovered_CurrentDeviceAvailable_ShouldKeepCurrentDevice() { + List initialDevices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); + AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, initialDevices, true, true); + + List newDevices = new ArrayList<>(Arrays.asList(wiredHeadset, speaker)); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DevicesDiscovered(newDevices)); + + assertEquals(wiredHeadset, newState.getCurrentDevice()); + assertEquals(newDevices, newState.getAvailableDevices()); + } + + @Test + public void testWiredHeadsetStateChanged() { + AudioDeviceState initialState = AudioDeviceState.getInitialState(); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.WiredHeadsetStateChanged(true)); + + assertTrue(newState.isWiredHeadsetConnected()); + } + + @Test + public void testBluetoothStateChanged() { + AudioDeviceState initialState = AudioDeviceState.getInitialState(); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.BluetoothStateChanged(true)); + + assertTrue(newState.isBluetoothConnected()); + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/service/AudioDeviceServiceTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/service/AudioDeviceServiceTest.java new file mode 100644 index 000000000..086830f8d --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/service/AudioDeviceServiceTest.java @@ -0,0 +1,166 @@ +package com.azure.android.communication.ui.calling.service; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; + +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class AudioDeviceServiceTest { + @Mock + private Context mockContext; + @Mock + private AudioManager mockAudioManager; + @Mock + private Store mockStore; + + private AudioDeviceService audioDeviceService; + private ArgumentCaptor actionCaptor; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + when(mockContext.getSystemService(Context.AUDIO_SERVICE)).thenReturn(mockAudioManager); + audioDeviceService = new AudioDeviceService(mockContext, mockStore); + actionCaptor = ArgumentCaptor.forClass(Object.class); + } + + @Test + public void testStart_RegistersReceiver() { + audioDeviceService.start(); + verify(mockContext).registerReceiver(any(), any()); + } + + @Test + public void testStop_UnregistersReceiver() { + audioDeviceService.stop(); + verify(mockContext).unregisterReceiver(any()); + } + + @Test + public void testSelectDevice_Speaker() { + AudioDevice speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); + audioDeviceService.selectDevice(speaker); + + verify(mockAudioManager).setMode(AudioManager.MODE_NORMAL); + verify(mockAudioManager).setSpeakerphoneOn(true); + verify(mockAudioManager).setBluetoothScoOn(false); + verify(mockStore).dispatch(any(AudioDeviceAction.DeviceSelected.class)); + } + + @Test + public void testSelectDevice_Bluetooth() { + AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); + audioDeviceService.selectDevice(bluetooth); + + verify(mockAudioManager).setMode(AudioManager.MODE_NORMAL); + verify(mockAudioManager).setSpeakerphoneOn(false); + verify(mockAudioManager).setBluetoothScoOn(true); + verify(mockAudioManager).startBluetoothSco(); + verify(mockStore).dispatch(any(AudioDeviceAction.DeviceSelected.class)); + } + + @Test + public void testSelectDevice_WiredHeadset() { + AudioDevice wiredHeadset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); + audioDeviceService.selectDevice(wiredHeadset); + + verify(mockAudioManager).setMode(AudioManager.MODE_NORMAL); + verify(mockAudioManager).setSpeakerphoneOn(false); + verify(mockAudioManager).setBluetoothScoOn(false); + verify(mockStore).dispatch(any(AudioDeviceAction.DeviceSelected.class)); + } + + @Test + public void testHandleWiredHeadsetConnection_Connected() { + Intent intent = new Intent(AudioManager.ACTION_HEADSET_PLUG); + intent.putExtra("state", 1); + audioDeviceService.start(); + + // Simulate broadcast receiver + ArgumentCaptor receiverCaptor = + ArgumentCaptor.forClass(android.content.BroadcastReceiver.class); + verify(mockContext).registerReceiver(receiverCaptor.capture(), any()); + receiverCaptor.getValue().onReceive(mockContext, intent); + + // Verify actions dispatched + verify(mockStore, times(3)).dispatch(actionCaptor.capture()); + + // First action should be WiredHeadsetStateChanged + assertTrue(actionCaptor.getAllValues().get(0) instanceof AudioDeviceAction.WiredHeadsetStateChanged); + AudioDeviceAction.WiredHeadsetStateChanged stateAction = + (AudioDeviceAction.WiredHeadsetStateChanged) actionCaptor.getAllValues().get(0); + assertTrue(stateAction.isConnected()); + + // Second action should be DeviceConnected + assertTrue(actionCaptor.getAllValues().get(1) instanceof AudioDeviceAction.DeviceConnected); + + // Third action should be DevicesDiscovered + assertTrue(actionCaptor.getAllValues().get(2) instanceof AudioDeviceAction.DevicesDiscovered); + } + + @Test + public void testHandleBluetoothScoStateChange_Connected() { + Intent intent = new Intent(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED); + intent.putExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_CONNECTED); + audioDeviceService.start(); + + // Simulate broadcast receiver + ArgumentCaptor receiverCaptor = + ArgumentCaptor.forClass(android.content.BroadcastReceiver.class); + verify(mockContext).registerReceiver(receiverCaptor.capture(), any()); + receiverCaptor.getValue().onReceive(mockContext, intent); + + // Verify actions dispatched + verify(mockStore, times(3)).dispatch(actionCaptor.capture()); + + // First action should be BluetoothStateChanged + assertTrue(actionCaptor.getAllValues().get(0) instanceof AudioDeviceAction.BluetoothStateChanged); + AudioDeviceAction.BluetoothStateChanged stateAction = + (AudioDeviceAction.BluetoothStateChanged) actionCaptor.getAllValues().get(0); + assertTrue(stateAction.isConnected()); + + // Second action should be DeviceConnected + assertTrue(actionCaptor.getAllValues().get(1) instanceof AudioDeviceAction.DeviceConnected); + + // Third action should be DevicesDiscovered + assertTrue(actionCaptor.getAllValues().get(2) instanceof AudioDeviceAction.DevicesDiscovered); + } + + @Test + public void testHandleAudioBecomingNoisy() { + Intent intent = new Intent(AudioManager.ACTION_AUDIO_BECOMING_NOISY); + audioDeviceService.start(); + + // Simulate broadcast receiver + ArgumentCaptor receiverCaptor = + ArgumentCaptor.forClass(android.content.BroadcastReceiver.class); + verify(mockContext).registerReceiver(receiverCaptor.capture(), any()); + receiverCaptor.getValue().onReceive(mockContext, intent); + + // Verify speaker mode is set + verify(mockAudioManager).setMode(AudioManager.MODE_NORMAL); + verify(mockAudioManager).setSpeakerphoneOn(true); + verify(mockAudioManager).setBluetoothScoOn(false); + verify(mockStore).dispatch(any(AudioDeviceAction.DeviceSelected.class)); + } + + private static boolean assertTrue(boolean condition) { + org.junit.Assert.assertTrue(condition); + return condition; + } +} From c7331ea7a6cbaf34c76bc14b428d15c299fb379e Mon Sep 17 00:00:00 2001 From: Adam Hammer Date: Tue, 18 Mar 2025 12:09:02 -0700 Subject: [PATCH 2/3] bluetooth/audio device refactoring --- .../redux/action/AudioDeviceAction.java | 96 +++++++++- .../middleware/AudioDeviceMiddleware.java | 110 +++++++++++ .../redux/middleware/AudioDeviceMiddleware.kt | 108 +++++++++++ .../AudioDeviceServiceMiddleware.java | 81 ++++++++ .../redux/reducer/AudioDeviceReducer.java | 71 +++++-- .../calling/redux/state/AudioDeviceState.java | 99 +++++++++- .../state/AudioDeviceSwitchingState.java | 16 ++ .../calling/service/AudioDeviceService.java | 107 ++++++----- .../middleware/AudioDeviceMiddlewareTest.java | 174 ++++++++++++++++++ .../middleware/AudioDeviceMiddlewareTest.kt | 173 +++++++++++++++++ .../redux/reducer/AudioDeviceReducerTest.java | 162 +++++++++++----- 11 files changed, 1075 insertions(+), 122 deletions(-) create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.kt create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceServiceMiddleware.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceSwitchingState.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.kt diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java index b2f457fb5..24a8e22ba 100644 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioDeviceAction.java @@ -12,12 +12,12 @@ private AudioDeviceAction() { } /** - * Action dispatched when a new audio device is connected. + * Action dispatched when user requests to switch to a device. */ - public static final class DeviceConnected { + public static final class SelectDeviceRequested { private final AudioDevice device; - public DeviceConnected(AudioDevice device) { + public SelectDeviceRequested(AudioDevice device) { this.device = device; } @@ -27,12 +27,33 @@ public AudioDevice getDevice() { } /** - * Action dispatched when an audio device is disconnected. + * Action dispatched when device switching begins. */ - public static final class DeviceDisconnected { + public static final class DeviceSwitchStarted { + private final AudioDevice targetDevice; + private final AudioDevice previousDevice; + + public DeviceSwitchStarted(AudioDevice targetDevice, AudioDevice previousDevice) { + this.targetDevice = targetDevice; + this.previousDevice = previousDevice; + } + + public AudioDevice getTargetDevice() { + return targetDevice; + } + + public AudioDevice getPreviousDevice() { + return previousDevice; + } + } + + /** + * Action dispatched when device switch completes successfully. + */ + public static final class DeviceSwitchCompleted { private final AudioDevice device; - public DeviceDisconnected(AudioDevice device) { + public DeviceSwitchCompleted(AudioDevice device) { this.device = device; } @@ -42,12 +63,54 @@ public AudioDevice getDevice() { } /** - * Action dispatched when an audio device is manually selected. + * Action dispatched when device switch fails. */ - public static final class DeviceSelected { + public static final class DeviceSwitchFailed { + private final AudioDevice targetDevice; + private final AudioDevice fallbackDevice; + private final String error; + + public DeviceSwitchFailed(AudioDevice targetDevice, AudioDevice fallbackDevice, String error) { + this.targetDevice = targetDevice; + this.fallbackDevice = fallbackDevice; + this.error = error; + } + + public AudioDevice getTargetDevice() { + return targetDevice; + } + + public AudioDevice getFallbackDevice() { + return fallbackDevice; + } + + public String getError() { + return error; + } + } + + /** + * Action dispatched when a new audio device is connected. + */ + public static final class DeviceConnected { + private final AudioDevice device; + + public DeviceConnected(AudioDevice device) { + this.device = device; + } + + public AudioDevice getDevice() { + return device; + } + } + + /** + * Action dispatched when an audio device is disconnected. + */ + public static final class DeviceDisconnected { private final AudioDevice device; - public DeviceSelected(AudioDevice device) { + public DeviceDisconnected(AudioDevice device) { this.device = device; } @@ -100,4 +163,19 @@ public boolean isConnected() { return connected; } } + + /** + * Action dispatched when audio becomes noisy. + */ + public static final class AudioBecomingNoisy { + private final AudioDevice fallbackDevice; + + public AudioBecomingNoisy(AudioDevice fallbackDevice) { + this.fallbackDevice = fallbackDevice; + } + + public AudioDevice getFallbackDevice() { + return fallbackDevice; + } + } } diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.java new file mode 100644 index 000000000..a662e83a4 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.java @@ -0,0 +1,110 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.os.Handler; +import android.os.Looper; + +import com.azure.android.communication.ui.calling.redux.action.Action; +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.service.AudioDeviceService; +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState; +import com.azure.android.communication.ui.calling.redux.state.ReduxState; +import com.azure.android.communication.ui.calling.redux.Dispatch; +import com.azure.android.communication.ui.calling.redux.Middleware; + +/** + * Middleware for handling audio device switching operations. + */ +public final class AudioDeviceMiddleware implements Middleware { + private final AudioDeviceService audioDeviceService; + private final Handler mainHandler; + + public AudioDeviceMiddleware(AudioDeviceService audioDeviceService) { + this.audioDeviceService = audioDeviceService; + this.mainHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public Dispatch.Fn invoke(final Store store) { + return next -> action -> { + if (action instanceof AudioDeviceAction.SelectDeviceRequested) { + return handleDeviceSelection((AudioDeviceAction.SelectDeviceRequested) action, store, next); + } + + // Pass through all other actions + return next.invoke(action); + }; + } + + private Object handleDeviceSelection( + AudioDeviceAction.SelectDeviceRequested action, + Store store, + Dispatch next) { + AudioDevice targetDevice = action.getDevice(); + AudioDeviceState currentState = (AudioDeviceState) store.getCurrentState(); + + // Verify device is available + if (!currentState.getAvailableDevices().contains(targetDevice)) { + return next.invoke(new AudioDeviceAction.DeviceSwitchFailed( + targetDevice, + currentState.getCurrentDevice(), + "Selected device is not available" + )); + } + + // Don't switch if already switching + if (currentState.getSwitchingState() == com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState.SWITCHING) { + return null; + } + + // Don't switch if already using this device + if (currentState.getCurrentDevice().equals(targetDevice)) { + return null; + } + + // Start switching process + next.invoke(new AudioDeviceAction.DeviceSwitchStarted( + targetDevice, + currentState.getCurrentDevice() + )); + + // Attempt to configure the device asynchronously + configureDeviceAsync(targetDevice, currentState.getCurrentDevice(), next); + + return null; + } + + private void configureDeviceAsync( + final AudioDevice targetDevice, + final AudioDevice previousDevice, + final Dispatch next) { + // Run device configuration on main thread + mainHandler.post(() -> { + try { + boolean success = audioDeviceService.configureAudioRouting(targetDevice); + + if (success) { + next.invoke(new AudioDeviceAction.DeviceSwitchCompleted(targetDevice)); + } else { + next.invoke(new AudioDeviceAction.DeviceSwitchFailed( + targetDevice, + previousDevice, + "Failed to configure audio routing" + )); + // Attempt to restore previous device + audioDeviceService.configureAudioRouting(previousDevice); + } + } catch (Exception e) { + next.invoke(new AudioDeviceAction.DeviceSwitchFailed( + targetDevice, + previousDevice, + "Error configuring audio routing: " + e.getMessage() + )); + // Attempt to restore previous device + audioDeviceService.configureAudioRouting(previousDevice); + } + }); + } + +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.kt b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.kt new file mode 100644 index 000000000..649e0fa1f --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddleware.kt @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.android.communication.ui.calling.redux.middleware + +import android.os.Handler +import android.os.Looper +import com.azure.android.communication.ui.calling.logger.Logger +import com.azure.android.communication.ui.calling.redux.Dispatch +import com.azure.android.communication.ui.calling.redux.Middleware +import com.azure.android.communication.ui.calling.redux.Store +import com.azure.android.communication.ui.calling.redux.action.Action +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction +import com.azure.android.communication.ui.calling.redux.state.ReduxState +import com.azure.android.communication.ui.calling.service.AudioDeviceService + +internal interface AudioDeviceMiddleware + +internal class AudioDeviceMiddlewareImpl( + private val audioDeviceService: AudioDeviceService, + private val logger: Logger, +) : Middleware, + AudioDeviceMiddleware { + + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun invoke(store: Store) = { next: Dispatch -> + { action: Action -> + logger.info(action.toString()) + when (action) { + is AudioDeviceAction.SelectDeviceRequested -> { + handleDeviceSelection(action, store, next) + } + else -> next(action) + } + } + } + + private fun handleDeviceSelection( + action: AudioDeviceAction.SelectDeviceRequested, + store: Store, + next: Dispatch + ) { + val targetDevice = action.device + val state = store.getCurrentState() + + // Verify device is available + if (!state.audioState.availableDevices.contains(targetDevice)) { + next(AudioDeviceAction.DeviceSwitchFailed( + targetDevice, + state.audioState.currentDevice, + "Selected device is not available" + )) + return + } + + // Don't switch if already switching + if (state.audioState.switchingState == com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState.SWITCHING) { + return + } + + // Don't switch if already using this device + if (state.audioState.currentDevice == targetDevice) { + return + } + + // Start switching process + next(AudioDeviceAction.DeviceSwitchStarted( + targetDevice, + state.audioState.currentDevice + )) + + // Attempt to configure the device asynchronously + configureDeviceAsync(targetDevice, state.audioState.currentDevice, next) + } + + private fun configureDeviceAsync( + targetDevice: com.azure.android.communication.ui.calling.redux.state.AudioDevice, + previousDevice: com.azure.android.communication.ui.calling.redux.state.AudioDevice, + next: Dispatch + ) { + mainHandler.post { + try { + val success = audioDeviceService.configureAudioRouting(targetDevice) + + if (success) { + next(AudioDeviceAction.DeviceSwitchCompleted(targetDevice)) + } else { + next(AudioDeviceAction.DeviceSwitchFailed( + targetDevice, + previousDevice, + "Failed to configure audio routing" + )) + // Attempt to restore previous device + audioDeviceService.configureAudioRouting(previousDevice) + } + } catch (e: Exception) { + next(AudioDeviceAction.DeviceSwitchFailed( + targetDevice, + previousDevice, + "Error configuring audio routing: ${e.message}" + )) + // Attempt to restore previous device + audioDeviceService.configureAudioRouting(previousDevice) + } + } + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceServiceMiddleware.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceServiceMiddleware.java new file mode 100644 index 000000000..b244cbded --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceServiceMiddleware.java @@ -0,0 +1,81 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.content.Context; + +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.service.AudioDeviceService; +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; + +import java.util.List; + +/** + * Middleware that connects AudioDeviceService system events to Redux actions. + */ +public final class AudioDeviceServiceMiddleware implements AudioDeviceService.AudioDeviceCallback { + private final Store store; + private final AudioDeviceService audioDeviceService; + + public AudioDeviceServiceMiddleware(Context context, Store store) { + this.store = store; + this.audioDeviceService = new AudioDeviceService(context, this); + } + + /** + * Start monitoring audio device changes. + */ + public void start() { + audioDeviceService.start(); + } + + /** + * Stop monitoring audio device changes. + */ + public void stop() { + audioDeviceService.stop(); + } + + /** + * Get the audio device service instance. + * + * @return AudioDeviceService instance + */ + public AudioDeviceService getAudioDeviceService() { + return audioDeviceService; + } + + @Override + public void onDeviceConnected(AudioDevice device) { + store.dispatch(new AudioDeviceAction.DeviceConnected(device)); + } + + @Override + public void onDeviceDisconnected(AudioDevice device) { + store.dispatch(new AudioDeviceAction.DeviceDisconnected(device)); + } + + @Override + public void onAvailableDevicesChanged(List devices) { + store.dispatch(new AudioDeviceAction.DevicesDiscovered(devices)); + } + + @Override + public void onWiredHeadsetStateChanged(boolean connected) { + store.dispatch(new AudioDeviceAction.WiredHeadsetStateChanged(connected)); + } + + @Override + public void onBluetoothStateChanged(boolean connected) { + store.dispatch(new AudioDeviceAction.BluetoothStateChanged(connected)); + } + + @Override + public void onAudioBecomingNoisy() { + AudioDevice speaker = new AudioDevice( + com.azure.android.communication.ui.calling.redux.state.AudioDeviceType.SPEAKER, + "speaker", + "Speaker" + ); + store.dispatch(new AudioDeviceAction.AudioBecomingNoisy(speaker)); + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java index 62d9e362d..d8ff74770 100644 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducer.java @@ -4,6 +4,7 @@ import com.azure.android.communication.ui.calling.redux.state.AudioDevice; import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState; import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState; import java.util.ArrayList; import java.util.List; @@ -23,25 +24,54 @@ private AudioDeviceReducer() { * @return New state */ public static AudioDeviceState reduce(final AudioDeviceState state, final Object action) { - if (action instanceof AudioDeviceAction.DeviceConnected) { + if (action instanceof AudioDeviceAction.DeviceSwitchStarted) { + return handleDeviceSwitchStarted(state, (AudioDeviceAction.DeviceSwitchStarted) action); + } else if (action instanceof AudioDeviceAction.DeviceSwitchCompleted) { + return handleDeviceSwitchCompleted(state, (AudioDeviceAction.DeviceSwitchCompleted) action); + } else if (action instanceof AudioDeviceAction.DeviceSwitchFailed) { + return handleDeviceSwitchFailed(state, (AudioDeviceAction.DeviceSwitchFailed) action); + } else if (action instanceof AudioDeviceAction.DeviceConnected) { return handleDeviceConnected(state, (AudioDeviceAction.DeviceConnected) action); } else if (action instanceof AudioDeviceAction.DeviceDisconnected) { return handleDeviceDisconnected(state, (AudioDeviceAction.DeviceDisconnected) action); - } else if (action instanceof AudioDeviceAction.DeviceSelected) { - return handleDeviceSelected(state, (AudioDeviceAction.DeviceSelected) action); } else if (action instanceof AudioDeviceAction.DevicesDiscovered) { return handleDevicesDiscovered(state, (AudioDeviceAction.DevicesDiscovered) action); } else if (action instanceof AudioDeviceAction.WiredHeadsetStateChanged) { return handleWiredHeadsetStateChanged(state, (AudioDeviceAction.WiredHeadsetStateChanged) action); } else if (action instanceof AudioDeviceAction.BluetoothStateChanged) { return handleBluetoothStateChanged(state, (AudioDeviceAction.BluetoothStateChanged) action); + } else if (action instanceof AudioDeviceAction.AudioBecomingNoisy) { + return handleAudioBecomingNoisy(state, (AudioDeviceAction.AudioBecomingNoisy) action); } return state; } + private static AudioDeviceState handleDeviceSwitchStarted( + AudioDeviceState state, + AudioDeviceAction.DeviceSwitchStarted action) { + return state.withSwitchingToDevice(action.getTargetDevice()); + } + + private static AudioDeviceState handleDeviceSwitchCompleted( + AudioDeviceState state, + AudioDeviceAction.DeviceSwitchCompleted action) { + return state.withSwitchingCompleted(action.getDevice()); + } + + private static AudioDeviceState handleDeviceSwitchFailed( + AudioDeviceState state, + AudioDeviceAction.DeviceSwitchFailed action) { + return state.withSwitchingFailed(action.getFallbackDevice(), action.getError()); + } + private static AudioDeviceState handleDeviceConnected( AudioDeviceState state, AudioDeviceAction.DeviceConnected action) { + // Don't modify state during device switching + if (state.getSwitchingState() == AudioDeviceSwitchingState.SWITCHING) { + return state; + } + AudioDevice newDevice = action.getDevice(); List updatedDevices = new ArrayList<>(state.getAvailableDevices()); @@ -62,6 +92,11 @@ private static AudioDeviceState handleDeviceConnected( private static AudioDeviceState handleDeviceDisconnected( AudioDeviceState state, AudioDeviceAction.DeviceDisconnected action) { + // Don't modify state during device switching + if (state.getSwitchingState() == AudioDeviceSwitchingState.SWITCHING) { + return state; + } + AudioDevice disconnectedDevice = action.getDevice(); List updatedDevices = new ArrayList<>(state.getAvailableDevices()); updatedDevices.remove(disconnectedDevice); @@ -75,19 +110,14 @@ private static AudioDeviceState handleDeviceDisconnected( return state.withAvailableDevices(updatedDevices); } - private static AudioDeviceState handleDeviceSelected( - AudioDeviceState state, - AudioDeviceAction.DeviceSelected action) { - AudioDevice selectedDevice = action.getDevice(); - if (state.getAvailableDevices().contains(selectedDevice)) { - return state.withCurrentDevice(selectedDevice); - } - return state; - } - private static AudioDeviceState handleDevicesDiscovered( AudioDeviceState state, AudioDeviceAction.DevicesDiscovered action) { + // Don't modify state during device switching + if (state.getSwitchingState() == AudioDeviceSwitchingState.SWITCHING) { + return state; + } + List newDevices = action.getDevices(); // If current device is no longer available, switch to highest priority device @@ -112,6 +142,21 @@ private static AudioDeviceState handleBluetoothStateChanged( return state.withBluetoothConnected(action.isConnected()); } + private static AudioDeviceState handleAudioBecomingNoisy( + AudioDeviceState state, + AudioDeviceAction.AudioBecomingNoisy action) { + // Don't modify state during device switching + if (state.getSwitchingState() == AudioDeviceSwitchingState.SWITCHING) { + return state; + } + + AudioDevice fallbackDevice = action.getFallbackDevice(); + if (state.getAvailableDevices().contains(fallbackDevice)) { + return state.withCurrentDevice(fallbackDevice); + } + return state; + } + private static AudioDevice findHighestPriorityDevice(List devices) { AudioDevice highestPriority = null; for (AudioDevice device : devices) { diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java index a42a605d3..30151d082 100644 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceState.java @@ -11,6 +11,8 @@ public final class AudioDeviceState { private final List availableDevices; private final boolean isWiredHeadsetConnected; private final boolean isBluetoothConnected; + private final AudioDeviceSwitchingState switchingState; + private final String error; /** * Creates a new instance of AudioDeviceState. @@ -19,16 +21,22 @@ public final class AudioDeviceState { * @param availableDevices List of available audio devices * @param isWiredHeadsetConnected Whether a wired headset is connected * @param isBluetoothConnected Whether a bluetooth device is connected + * @param switchingState The current device switching state + * @param error Error message if device switching failed */ public AudioDeviceState( AudioDevice currentDevice, List availableDevices, boolean isWiredHeadsetConnected, - boolean isBluetoothConnected) { + boolean isBluetoothConnected, + AudioDeviceSwitchingState switchingState, + String error) { this.currentDevice = currentDevice; this.availableDevices = new ArrayList<>(availableDevices); this.isWiredHeadsetConnected = isWiredHeadsetConnected; this.isBluetoothConnected = isBluetoothConnected; + this.switchingState = switchingState; + this.error = error; } /** @@ -40,7 +48,8 @@ public static AudioDeviceState getInitialState() { List devices = new ArrayList<>(); AudioDevice speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); devices.add(speaker); - return new AudioDeviceState(speaker, devices, false, false); + return new AudioDeviceState(speaker, devices, false, false, + AudioDeviceSwitchingState.NONE, null); } /** @@ -79,6 +88,24 @@ public boolean isBluetoothConnected() { return isBluetoothConnected; } + /** + * Gets the current device switching state. + * + * @return AudioDeviceSwitchingState + */ + public AudioDeviceSwitchingState getSwitchingState() { + return switchingState; + } + + /** + * Gets the error message if device switching failed. + * + * @return Error message or null if no error + */ + public String getError() { + return error; + } + /** * Creates a new state with updated current device. * @@ -86,7 +113,8 @@ public boolean isBluetoothConnected() { * @return Updated AudioDeviceState */ public AudioDeviceState withCurrentDevice(AudioDevice device) { - return new AudioDeviceState(device, availableDevices, isWiredHeadsetConnected, isBluetoothConnected); + return new AudioDeviceState(device, availableDevices, isWiredHeadsetConnected, + isBluetoothConnected, AudioDeviceSwitchingState.NONE, null); } /** @@ -96,7 +124,8 @@ public AudioDeviceState withCurrentDevice(AudioDevice device) { * @return Updated AudioDeviceState */ public AudioDeviceState withAvailableDevices(List devices) { - return new AudioDeviceState(currentDevice, devices, isWiredHeadsetConnected, isBluetoothConnected); + return new AudioDeviceState(currentDevice, devices, isWiredHeadsetConnected, + isBluetoothConnected, switchingState, error); } /** @@ -106,7 +135,8 @@ public AudioDeviceState withAvailableDevices(List devices) { * @return Updated AudioDeviceState */ public AudioDeviceState withWiredHeadsetConnected(boolean connected) { - return new AudioDeviceState(currentDevice, availableDevices, connected, isBluetoothConnected); + return new AudioDeviceState(currentDevice, availableDevices, connected, + isBluetoothConnected, switchingState, error); } /** @@ -116,6 +146,63 @@ public AudioDeviceState withWiredHeadsetConnected(boolean connected) { * @return Updated AudioDeviceState */ public AudioDeviceState withBluetoothConnected(boolean connected) { - return new AudioDeviceState(currentDevice, availableDevices, isWiredHeadsetConnected, connected); + return new AudioDeviceState(currentDevice, availableDevices, isWiredHeadsetConnected, + connected, switchingState, error); + } + + /** + * Creates a new state with updated switching state. + * + * @param newSwitchingState New switching state + * @return Updated AudioDeviceState + */ + public AudioDeviceState withSwitchingState(AudioDeviceSwitchingState newSwitchingState) { + return new AudioDeviceState(currentDevice, availableDevices, isWiredHeadsetConnected, + isBluetoothConnected, newSwitchingState, error); + } + + /** + * Creates a new state with updated error message. + * + * @param newError New error message + * @return Updated AudioDeviceState + */ + public AudioDeviceState withError(String newError) { + return new AudioDeviceState(currentDevice, availableDevices, isWiredHeadsetConnected, + isBluetoothConnected, switchingState, newError); + } + + /** + * Creates a new state for device switching in progress. + * + * @param targetDevice Device being switched to + * @return Updated AudioDeviceState + */ + public AudioDeviceState withSwitchingToDevice(AudioDevice targetDevice) { + return new AudioDeviceState(currentDevice, availableDevices, isWiredHeadsetConnected, + isBluetoothConnected, AudioDeviceSwitchingState.SWITCHING, null); + } + + /** + * Creates a new state for device switching completion. + * + * @param newDevice Successfully switched device + * @return Updated AudioDeviceState + */ + public AudioDeviceState withSwitchingCompleted(AudioDevice newDevice) { + return new AudioDeviceState(newDevice, availableDevices, isWiredHeadsetConnected, + isBluetoothConnected, AudioDeviceSwitchingState.NONE, null); + } + + /** + * Creates a new state for device switching failure. + * + * @param fallbackDevice Device to fall back to + * @param errorMessage Error message + * @return Updated AudioDeviceState + */ + public AudioDeviceState withSwitchingFailed(AudioDevice fallbackDevice, String errorMessage) { + return new AudioDeviceState(fallbackDevice, availableDevices, isWiredHeadsetConnected, + isBluetoothConnected, AudioDeviceSwitchingState.NONE, errorMessage); } } diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceSwitchingState.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceSwitchingState.java new file mode 100644 index 000000000..f75d4d55b --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioDeviceSwitchingState.java @@ -0,0 +1,16 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Enum representing the state of audio device switching. + */ +public enum AudioDeviceSwitchingState { + /** + * No device switching in progress. + */ + NONE, + + /** + * Device switching is in progress. + */ + SWITCHING +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java index e36e3a9ed..a31853a41 100644 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/service/AudioDeviceService.java @@ -6,26 +6,30 @@ import android.content.IntentFilter; import android.media.AudioManager; -import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; import com.azure.android.communication.ui.calling.redux.state.AudioDevice; import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; -import com.azure.android.communication.ui.calling.redux.Store; import java.util.ArrayList; import java.util.List; /** - * Service for managing audio device state and system audio routing. + * Service for managing system audio routing and device detection. */ public final class AudioDeviceService { private final Context context; private final AudioManager audioManager; - private final Store store; + private final AudioDeviceCallback callback; private final BroadcastReceiver audioDeviceReceiver; - public AudioDeviceService(Context context, Store store) { + /** + * Creates a new instance of AudioDeviceService. + * + * @param context Android context + * @param callback Callback for audio device events + */ + public AudioDeviceService(Context context, AudioDeviceCallback callback) { this.context = context; - this.store = store; + this.callback = callback; this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); this.audioDeviceReceiver = createAudioDeviceReceiver(); } @@ -41,7 +45,7 @@ public void start() { context.registerReceiver(audioDeviceReceiver, filter); // Initial device discovery - updateAvailableDevices(); + notifyAvailableDevices(); } /** @@ -52,30 +56,35 @@ public void stop() { } /** - * Switch to a specific audio device. + * Configure system audio routing for a specific device. * - * @param device The device to switch to + * @param device The device to configure + * @return true if configuration was successful */ - public void selectDevice(AudioDevice device) { - switch (device.getType()) { - case SPEAKER: - audioManager.setMode(AudioManager.MODE_NORMAL); - audioManager.setSpeakerphoneOn(true); - audioManager.setBluetoothScoOn(false); - break; - case BLUETOOTH: - audioManager.setMode(AudioManager.MODE_NORMAL); - audioManager.setSpeakerphoneOn(false); - audioManager.setBluetoothScoOn(true); - audioManager.startBluetoothSco(); - break; - case WIRED_HEADSET: - audioManager.setMode(AudioManager.MODE_NORMAL); - audioManager.setSpeakerphoneOn(false); - audioManager.setBluetoothScoOn(false); - break; + public boolean configureAudioRouting(AudioDevice device) { + try { + switch (device.getType()) { + case SPEAKER: + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(true); + audioManager.setBluetoothScoOn(false); + break; + case BLUETOOTH: + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(false); + audioManager.setBluetoothScoOn(true); + audioManager.startBluetoothSco(); + break; + case WIRED_HEADSET: + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(false); + audioManager.setBluetoothScoOn(false); + break; + } + return true; + } catch (Exception e) { + return false; } - store.dispatch(new AudioDeviceAction.DeviceSelected(device)); } private BroadcastReceiver createAudioDeviceReceiver() { @@ -103,37 +112,35 @@ public void onReceive(Context context, Intent intent) { } private void handleWiredHeadsetConnection(boolean connected) { - store.dispatch(new AudioDeviceAction.WiredHeadsetStateChanged(connected)); + callback.onWiredHeadsetStateChanged(connected); + AudioDevice headset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); if (connected) { - AudioDevice headset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); - store.dispatch(new AudioDeviceAction.DeviceConnected(headset)); + callback.onDeviceConnected(headset); } else { - AudioDevice headset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); - store.dispatch(new AudioDeviceAction.DeviceDisconnected(headset)); + callback.onDeviceDisconnected(headset); } - updateAvailableDevices(); + notifyAvailableDevices(); } private void handleBluetoothScoStateChange(int state) { boolean connected = state == AudioManager.SCO_AUDIO_STATE_CONNECTED; - store.dispatch(new AudioDeviceAction.BluetoothStateChanged(connected)); + callback.onBluetoothStateChanged(connected); + AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); if (connected) { - AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); - store.dispatch(new AudioDeviceAction.DeviceConnected(bluetooth)); + callback.onDeviceConnected(bluetooth); } else { - AudioDevice bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); - store.dispatch(new AudioDeviceAction.DeviceDisconnected(bluetooth)); + callback.onDeviceDisconnected(bluetooth); } - updateAvailableDevices(); + notifyAvailableDevices(); } private void handleAudioBecomingNoisy() { - // Switch to speaker when audio becomes noisy (e.g. headphones unplugged) AudioDevice speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); - selectDevice(speaker); + configureAudioRouting(speaker); + callback.onAudioBecomingNoisy(); } - private void updateAvailableDevices() { + private void notifyAvailableDevices() { List devices = new ArrayList<>(); // Speaker is always available @@ -149,6 +156,18 @@ private void updateAvailableDevices() { devices.add(new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth")); } - store.dispatch(new AudioDeviceAction.DevicesDiscovered(devices)); + callback.onAvailableDevicesChanged(devices); + } + + /** + * Callback interface for audio device events. + */ + public interface AudioDeviceCallback { + void onDeviceConnected(AudioDevice device); + void onDeviceDisconnected(AudioDevice device); + void onAvailableDevicesChanged(List devices); + void onWiredHeadsetStateChanged(boolean connected); + void onBluetoothStateChanged(boolean connected); + void onAudioBecomingNoisy(); } } diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.java new file mode 100644 index 000000000..345c593af --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.java @@ -0,0 +1,174 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.os.Handler; +import android.os.Looper; + +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction; +import com.azure.android.communication.ui.calling.redux.state.AudioDevice; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; +import com.azure.android.communication.ui.calling.service.AudioDeviceService; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.annotation.LooperMode; + +import java.util.Arrays; + +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@LooperMode(LooperMode.Mode.PAUSED) +public class AudioDeviceMiddlewareTest { + @Mock + private Store mockStore; + @Mock + private AudioDeviceService mockAudioDeviceService; + @Mock + private AudioDeviceMiddleware.Dispatch mockNext; + + private AudioDeviceMiddleware middleware; + private AudioDeviceState initialState; + private AudioDevice speaker; + private AudioDevice wiredHeadset; + private AudioDevice bluetooth; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + middleware = new AudioDeviceMiddleware(mockAudioDeviceService); + + speaker = new AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker"); + wiredHeadset = new AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset"); + bluetooth = new AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth"); + + initialState = new AudioDeviceState( + speaker, + Arrays.asList(speaker, wiredHeadset, bluetooth), + true, + true, + com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState.NONE, + null + ); + + when(mockStore.getCurrentState()).thenReturn(initialState); + } + + @Test + public void testSelectDeviceRequested_Success() { + when(mockAudioDeviceService.configureAudioRouting(wiredHeadset)).thenReturn(true); + + middleware.process(mockStore, new AudioDeviceAction.SelectDeviceRequested(wiredHeadset), mockNext); + + // Verify DeviceSwitchStarted action is dispatched immediately + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockNext, times(1)).dispatch(actionCaptor.capture()); + assertTrue(actionCaptor.getValue() instanceof AudioDeviceAction.DeviceSwitchStarted); + + // Execute pending Looper tasks + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + // Verify DeviceSwitchCompleted action is dispatched after async operation + verify(mockNext, times(2)).dispatch(actionCaptor.capture()); + assertTrue(actionCaptor.getAllValues().get(1) instanceof AudioDeviceAction.DeviceSwitchCompleted); + } + + @Test + public void testSelectDeviceRequested_Failure() { + when(mockAudioDeviceService.configureAudioRouting(bluetooth)).thenReturn(false); + + middleware.process(mockStore, new AudioDeviceAction.SelectDeviceRequested(bluetooth), mockNext); + + // Verify DeviceSwitchStarted action is dispatched immediately + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockNext, times(1)).dispatch(actionCaptor.capture()); + assertTrue(actionCaptor.getValue() instanceof AudioDeviceAction.DeviceSwitchStarted); + + // Execute pending Looper tasks + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + // Verify DeviceSwitchFailed action is dispatched after async operation + verify(mockNext, times(2)).dispatch(actionCaptor.capture()); + assertTrue(actionCaptor.getAllValues().get(1) instanceof AudioDeviceAction.DeviceSwitchFailed); + + // Verify fallback to previous device is attempted + verify(mockAudioDeviceService).configureAudioRouting(speaker); + } + + @Test + public void testSelectDeviceRequested_UnavailableDevice() { + AudioDevice unavailableDevice = new AudioDevice(AudioDeviceType.BLUETOOTH, "unavailable", "Unavailable"); + + middleware.process(mockStore, new AudioDeviceAction.SelectDeviceRequested(unavailableDevice), mockNext); + + // Verify DeviceSwitchFailed action is dispatched immediately + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockNext, times(1)).dispatch(actionCaptor.capture()); + assertTrue(actionCaptor.getValue() instanceof AudioDeviceAction.DeviceSwitchFailed); + + // Verify no device configuration is attempted + verify(mockAudioDeviceService, never()).configureAudioRouting(any()); + } + + @Test + public void testSelectDeviceRequested_AlreadySelected() { + middleware.process(mockStore, new AudioDeviceAction.SelectDeviceRequested(speaker), mockNext); + + // Verify no actions are dispatched + verify(mockNext, never()).dispatch(any()); + verify(mockAudioDeviceService, never()).configureAudioRouting(any()); + } + + @Test + public void testSelectDeviceRequested_AlreadySwitching() { + AudioDeviceState switchingState = new AudioDeviceState( + speaker, + Arrays.asList(speaker, wiredHeadset, bluetooth), + true, + true, + com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState.SWITCHING, + null + ); + when(mockStore.getCurrentState()).thenReturn(switchingState); + + middleware.process(mockStore, new AudioDeviceAction.SelectDeviceRequested(bluetooth), mockNext); + + // Verify no actions are dispatched + verify(mockNext, never()).dispatch(any()); + verify(mockAudioDeviceService, never()).configureAudioRouting(any()); + } + + @Test + public void testSelectDeviceRequested_Exception() { + when(mockAudioDeviceService.configureAudioRouting(bluetooth)) + .thenThrow(new RuntimeException("Test error")); + + middleware.process(mockStore, new AudioDeviceAction.SelectDeviceRequested(bluetooth), mockNext); + + // Verify DeviceSwitchStarted action is dispatched immediately + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockNext, times(1)).dispatch(actionCaptor.capture()); + assertTrue(actionCaptor.getValue() instanceof AudioDeviceAction.DeviceSwitchStarted); + + // Execute pending Looper tasks + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + // Verify DeviceSwitchFailed action is dispatched after async operation + verify(mockNext, times(2)).dispatch(actionCaptor.capture()); + Object failedAction = actionCaptor.getAllValues().get(1); + assertTrue(failedAction instanceof AudioDeviceAction.DeviceSwitchFailed); + assertEquals("Error configuring audio routing: Test error", + ((AudioDeviceAction.DeviceSwitchFailed) failedAction).getError()); + + // Verify fallback to previous device is attempted + verify(mockAudioDeviceService).configureAudioRouting(speaker); + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.kt b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.kt new file mode 100644 index 000000000..9249f6ffd --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioDeviceMiddlewareTest.kt @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.android.communication.ui.calling.redux.middleware + +import android.os.Looper +import com.azure.android.communication.ui.calling.logger.Logger +import com.azure.android.communication.ui.calling.redux.Store +import com.azure.android.communication.ui.calling.redux.action.Action +import com.azure.android.communication.ui.calling.redux.action.AudioDeviceAction +import com.azure.android.communication.ui.calling.redux.state.AudioDevice +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType +import com.azure.android.communication.ui.calling.redux.state.ReduxState +import com.azure.android.communication.ui.calling.service.AudioDeviceService + +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.junit.MockitoJUnitRunner +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class AudioDeviceMiddlewareTest { + @Mock + private lateinit var mockStore: Store + @Mock + private lateinit var mockAudioDeviceService: AudioDeviceService + @Mock + private lateinit var mockLogger: Logger + @Mock + private lateinit var mockNext: (Action) -> Any + + private lateinit var middleware: AudioDeviceMiddlewareImpl + private lateinit var state: ReduxState + private val speaker = AudioDevice(AudioDeviceType.SPEAKER, "speaker", "Speaker") + private val wiredHeadset = AudioDevice(AudioDeviceType.WIRED_HEADSET, "wired_headset", "Wired Headset") + private val bluetooth = AudioDevice(AudioDeviceType.BLUETOOTH, "bluetooth", "Bluetooth") + + @Test + fun `test select device requested success`() { + setupTest() + `when`(mockAudioDeviceService.configureAudioRouting(wiredHeadset)).thenReturn(true) + + val action = AudioDeviceAction.SelectDeviceRequested(wiredHeadset) + middleware.invoke(mockStore)(mockNext)(action) + + // Verify DeviceSwitchStarted action is dispatched immediately + verify(mockNext).invoke(any(AudioDeviceAction.DeviceSwitchStarted::class.java)) + + // Execute pending Looper tasks + shadowOf(Looper.getMainLooper()).idle() + + // Verify DeviceSwitchCompleted action is dispatched after async operation + verify(mockNext).invoke(any(AudioDeviceAction.DeviceSwitchCompleted::class.java)) + } + + @Test + fun `test select device requested failure`() { + setupTest() + `when`(mockAudioDeviceService.configureAudioRouting(bluetooth)).thenReturn(false) + + val action = AudioDeviceAction.SelectDeviceRequested(bluetooth) + middleware.invoke(mockStore)(mockNext)(action) + + // Verify DeviceSwitchStarted action is dispatched immediately + verify(mockNext).invoke(any(AudioDeviceAction.DeviceSwitchStarted::class.java)) + + // Execute pending Looper tasks + shadowOf(Looper.getMainLooper()).idle() + + // Verify DeviceSwitchFailed action is dispatched after async operation + verify(mockNext).invoke(any(AudioDeviceAction.DeviceSwitchFailed::class.java)) + + // Verify fallback to previous device is attempted + verify(mockAudioDeviceService).configureAudioRouting(speaker) + } + + @Test + fun `test select device requested unavailable device`() { + setupTest() + val unavailableDevice = AudioDevice(AudioDeviceType.BLUETOOTH, "unavailable", "Unavailable") + + val action = AudioDeviceAction.SelectDeviceRequested(unavailableDevice) + middleware.invoke(mockStore)(mockNext)(action) + + // Verify DeviceSwitchFailed action is dispatched immediately + verify(mockNext).invoke(any(AudioDeviceAction.DeviceSwitchFailed::class.java)) + + // Verify no device configuration is attempted + verify(mockAudioDeviceService, never()).configureAudioRouting(any()) + } + + @Test + fun `test select device requested already selected`() { + setupTest() + + val action = AudioDeviceAction.SelectDeviceRequested(speaker) + middleware.invoke(mockStore)(mockNext)(action) + + // Verify no actions are dispatched + verify(mockNext, never()).invoke(any()) + verify(mockAudioDeviceService, never()).configureAudioRouting(any()) + } + + @Test + fun `test select device requested already switching`() { + setupTest(switching = true) + + val action = AudioDeviceAction.SelectDeviceRequested(bluetooth) + middleware.invoke(mockStore)(mockNext)(action) + + // Verify no actions are dispatched + verify(mockNext, never()).invoke(any()) + verify(mockAudioDeviceService, never()).configureAudioRouting(any()) + } + + @Test + fun `test select device requested exception`() { + setupTest() + `when`(mockAudioDeviceService.configureAudioRouting(bluetooth)) + .thenThrow(RuntimeException("Test error")) + + val action = AudioDeviceAction.SelectDeviceRequested(bluetooth) + middleware.invoke(mockStore)(mockNext)(action) + + // Verify DeviceSwitchStarted action is dispatched immediately + verify(mockNext).invoke(any(AudioDeviceAction.DeviceSwitchStarted::class.java)) + + // Execute pending Looper tasks + shadowOf(Looper.getMainLooper()).idle() + + // Verify DeviceSwitchFailed action is dispatched after async operation + verify(mockNext).invoke( + argThat { arg -> + arg is AudioDeviceAction.DeviceSwitchFailed && + arg.error == "Error configuring audio routing: Test error" + } + ) + + // Verify fallback to previous device is attempted + verify(mockAudioDeviceService).configureAudioRouting(speaker) + } + + private fun setupTest(switching: Boolean = false) { + mockStore = mock(Store::class.java) as Store + mockAudioDeviceService = mock(AudioDeviceService::class.java) + mockLogger = mock(Logger::class.java) + mockNext = mock((Action) -> Any::class.java) + + middleware = AudioDeviceMiddlewareImpl(mockAudioDeviceService, mockLogger) + + val audioState = AudioDeviceState( + speaker, + listOf(speaker, wiredHeadset, bluetooth), + true, + true, + if (switching) com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState.SWITCHING + else com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState.NONE, + null + ) + + state = mock(ReduxState::class.java) + `when`(state.audioState).thenReturn(audioState) + `when`(mockStore.getCurrentState()).thenReturn(state) + } + + private fun any(type: Class): T = any() +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java index a6f4b7d97..6f06877b6 100644 --- a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioDeviceReducerTest.java @@ -4,6 +4,7 @@ import com.azure.android.communication.ui.calling.redux.state.AudioDevice; import com.azure.android.communication.ui.calling.redux.state.AudioDeviceState; import com.azure.android.communication.ui.calling.redux.state.AudioDeviceType; +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceSwitchingState; import org.junit.Test; import static org.junit.Assert.*; @@ -24,98 +25,158 @@ public void testInitialState() { assertEquals(1, state.getAvailableDevices().size()); assertFalse(state.isWiredHeadsetConnected()); assertFalse(state.isBluetoothConnected()); + assertEquals(AudioDeviceSwitchingState.NONE, state.getSwitchingState()); + assertNull(state.getError()); } @Test - public void testDeviceConnected_HigherPriority_ShouldAutoSwitch() { - AudioDeviceState initialState = new AudioDeviceState(speaker, Arrays.asList(speaker), false, false); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceConnected(wiredHeadset)); + public void testDeviceSwitchStarted() { + AudioDeviceState initialState = createTestState(speaker, false, false); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceSwitchStarted(wiredHeadset, speaker)); + + assertEquals(AudioDeviceSwitchingState.SWITCHING, newState.getSwitchingState()); + assertEquals(speaker, newState.getCurrentDevice()); + assertNull(newState.getError()); + } + + @Test + public void testDeviceSwitchCompleted() { + AudioDeviceState initialState = createTestState(speaker, false, false) + .withSwitchingState(AudioDeviceSwitchingState.SWITCHING); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceSwitchCompleted(wiredHeadset)); + assertEquals(AudioDeviceSwitchingState.NONE, newState.getSwitchingState()); assertEquals(wiredHeadset, newState.getCurrentDevice()); - assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); - assertTrue(newState.getAvailableDevices().contains(speaker)); + assertNull(newState.getError()); } @Test - public void testDeviceConnected_LowerPriority_ShouldNotAutoSwitch() { - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, Arrays.asList(wiredHeadset), true, false); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceConnected(bluetooth)); + public void testDeviceSwitchFailed() { + AudioDeviceState initialState = createTestState(speaker, false, false) + .withSwitchingState(AudioDeviceSwitchingState.SWITCHING); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceSwitchFailed(wiredHeadset, speaker, "Test error")); + + assertEquals(AudioDeviceSwitchingState.NONE, newState.getSwitchingState()); + assertEquals(speaker, newState.getCurrentDevice()); + assertEquals("Test error", newState.getError()); + } - assertEquals(wiredHeadset, newState.getCurrentDevice()); - assertTrue(newState.getAvailableDevices().contains(bluetooth)); + @Test + public void testDeviceConnected_DuringSwitching_ShouldNotAutoSwitch() { + AudioDeviceState initialState = createTestState(speaker, false, false) + .withSwitchingState(AudioDeviceSwitchingState.SWITCHING); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceConnected(wiredHeadset)); + + assertEquals(AudioDeviceSwitchingState.SWITCHING, newState.getSwitchingState()); + assertEquals(speaker, newState.getCurrentDevice()); assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); } @Test - public void testDeviceDisconnected_CurrentDevice_ShouldSwitchToNextHighestPriority() { - List devices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, true); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceDisconnected(wiredHeadset)); + public void testDeviceDisconnected_DuringSwitching_ShouldNotSwitch() { + List devices = Arrays.asList(speaker, wiredHeadset, bluetooth); + AudioDeviceState initialState = new AudioDeviceState(speaker, devices, true, true, + AudioDeviceSwitchingState.SWITCHING, null); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceDisconnected(bluetooth)); + + assertEquals(AudioDeviceSwitchingState.SWITCHING, newState.getSwitchingState()); + assertEquals(speaker, newState.getCurrentDevice()); + assertFalse(newState.getAvailableDevices().contains(bluetooth)); + } - assertEquals(bluetooth, newState.getCurrentDevice()); - assertFalse(newState.getAvailableDevices().contains(wiredHeadset)); - assertTrue(newState.getAvailableDevices().contains(bluetooth)); - assertTrue(newState.getAvailableDevices().contains(speaker)); + @Test + public void testDevicesDiscovered_DuringSwitching_ShouldNotUpdateCurrent() { + List initialDevices = Arrays.asList(speaker, wiredHeadset, bluetooth); + AudioDeviceState initialState = new AudioDeviceState(speaker, initialDevices, true, true, + AudioDeviceSwitchingState.SWITCHING, null); + + List newDevices = Arrays.asList(bluetooth, speaker); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DevicesDiscovered(newDevices)); + + assertEquals(AudioDeviceSwitchingState.SWITCHING, newState.getSwitchingState()); + assertEquals(speaker, newState.getCurrentDevice()); + assertEquals(newDevices, newState.getAvailableDevices()); } @Test - public void testDeviceDisconnected_NotCurrentDevice_ShouldNotSwitch() { - List devices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, true); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceDisconnected(bluetooth)); + public void testAudioBecomingNoisy_DuringSwitching_ShouldNotSwitch() { + AudioDeviceState initialState = createTestState(wiredHeadset, true, false) + .withSwitchingState(AudioDeviceSwitchingState.SWITCHING); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.AudioBecomingNoisy(speaker)); + assertEquals(AudioDeviceSwitchingState.SWITCHING, newState.getSwitchingState()); assertEquals(wiredHeadset, newState.getCurrentDevice()); - assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); - assertFalse(newState.getAvailableDevices().contains(bluetooth)); - assertTrue(newState.getAvailableDevices().contains(speaker)); } + private AudioDeviceState createTestState(AudioDevice currentDevice, boolean wiredConnected, boolean bluetoothConnected) { + List devices = new ArrayList<>(); + devices.add(speaker); + if (wiredConnected) devices.add(wiredHeadset); + if (bluetoothConnected) devices.add(bluetooth); + + return new AudioDeviceState(currentDevice, devices, wiredConnected, bluetoothConnected, + AudioDeviceSwitchingState.NONE, null); + } + + // Original tests remain unchanged below @Test - public void testDeviceSelected_ValidDevice_ShouldSwitch() { - List devices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, true); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceSelected(bluetooth)); + public void testDeviceConnected_HigherPriority_ShouldAutoSwitch() { + AudioDeviceState initialState = createTestState(speaker, false, false); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceConnected(wiredHeadset)); - assertEquals(bluetooth, newState.getCurrentDevice()); + assertEquals(wiredHeadset, newState.getCurrentDevice()); + assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); + assertTrue(newState.getAvailableDevices().contains(speaker)); } @Test - public void testDeviceSelected_InvalidDevice_ShouldNotSwitch() { - List devices = new ArrayList<>(Arrays.asList(wiredHeadset, speaker)); - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, devices, true, false); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DeviceSelected(bluetooth)); + public void testDeviceConnected_LowerPriority_ShouldNotAutoSwitch() { + AudioDeviceState initialState = createTestState(wiredHeadset, true, false); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceConnected(bluetooth)); assertEquals(wiredHeadset, newState.getCurrentDevice()); + assertTrue(newState.getAvailableDevices().contains(bluetooth)); + assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); } @Test - public void testDevicesDiscovered_CurrentDeviceNotAvailable_ShouldSwitchToHighestPriority() { - List initialDevices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, initialDevices, true, true); - - List newDevices = new ArrayList<>(Arrays.asList(bluetooth, speaker)); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DevicesDiscovered(newDevices)); + public void testDeviceDisconnected_CurrentDevice_ShouldSwitchToNextHighestPriority() { + AudioDeviceState initialState = createTestState(wiredHeadset, true, true); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceDisconnected(wiredHeadset)); assertEquals(bluetooth, newState.getCurrentDevice()); - assertEquals(newDevices, newState.getAvailableDevices()); + assertFalse(newState.getAvailableDevices().contains(wiredHeadset)); + assertTrue(newState.getAvailableDevices().contains(bluetooth)); + assertTrue(newState.getAvailableDevices().contains(speaker)); } @Test - public void testDevicesDiscovered_CurrentDeviceAvailable_ShouldKeepCurrentDevice() { - List initialDevices = new ArrayList<>(Arrays.asList(wiredHeadset, bluetooth, speaker)); - AudioDeviceState initialState = new AudioDeviceState(wiredHeadset, initialDevices, true, true); - - List newDevices = new ArrayList<>(Arrays.asList(wiredHeadset, speaker)); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.DevicesDiscovered(newDevices)); + public void testDeviceDisconnected_NotCurrentDevice_ShouldNotSwitch() { + AudioDeviceState initialState = createTestState(wiredHeadset, true, true); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.DeviceDisconnected(bluetooth)); assertEquals(wiredHeadset, newState.getCurrentDevice()); - assertEquals(newDevices, newState.getAvailableDevices()); + assertTrue(newState.getAvailableDevices().contains(wiredHeadset)); + assertFalse(newState.getAvailableDevices().contains(bluetooth)); + assertTrue(newState.getAvailableDevices().contains(speaker)); } @Test public void testWiredHeadsetStateChanged() { AudioDeviceState initialState = AudioDeviceState.getInitialState(); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.WiredHeadsetStateChanged(true)); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.WiredHeadsetStateChanged(true)); assertTrue(newState.isWiredHeadsetConnected()); } @@ -123,7 +184,8 @@ public void testWiredHeadsetStateChanged() { @Test public void testBluetoothStateChanged() { AudioDeviceState initialState = AudioDeviceState.getInitialState(); - AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, new AudioDeviceAction.BluetoothStateChanged(true)); + AudioDeviceState newState = AudioDeviceReducer.reduce(initialState, + new AudioDeviceAction.BluetoothStateChanged(true)); assertTrue(newState.isBluetoothConnected()); } From c0153bc81ec1b72d6621f304ce5e27673301dea4 Mon Sep 17 00:00:00 2001 From: Adam Hammer Date: Tue, 18 Mar 2025 12:33:42 -0700 Subject: [PATCH 3/3] refactoring towards redux more --- .../presentation/manager/AudioFocusManager.kt | 149 -------- .../presentation/manager/AudioModeManager.kt | 39 -- .../manager/AudioSessionManager.kt | 346 ------------------ .../redux/action/AudioFocusAction.java | 86 +++++ .../calling/redux/action/AudioModeAction.java | 91 +++++ .../middleware/AudioFocusMiddleware.java | 114 ++++++ .../redux/middleware/AudioModeMiddleware.java | 96 +++++ .../redux/reducer/AudioFocusReducer.java | 57 +++ .../redux/reducer/AudioModeReducer.java | 47 +++ .../calling/redux/state/AudioFocusState.java | 51 +++ .../calling/redux/state/AudioFocusStatus.java | 12 + .../ui/calling/redux/state/AudioMode.java | 12 + .../calling/redux/state/AudioModeState.java | 63 ++++ .../ui/calling/redux/state/AudioState.java | 71 ++++ .../middleware/AudioFocusMiddlewareTest.java | 163 +++++++++ .../middleware/AudioModeMiddlewareTest.java | 157 ++++++++ .../redux/reducer/AudioFocusReducerTest.java | 141 +++++++ .../redux/reducer/AudioModeReducerTest.java | 118 ++++++ 18 files changed, 1279 insertions(+), 534 deletions(-) delete mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioFocusManager.kt delete mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioModeManager.kt delete mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioSessionManager.kt create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioFocusAction.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioModeAction.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddleware.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddleware.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducer.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducer.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusState.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusStatus.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioMode.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioModeState.java create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioState.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddlewareTest.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddlewareTest.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducerTest.java create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducerTest.java diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioFocusManager.kt b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioFocusManager.kt deleted file mode 100644 index 166be9a05..000000000 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioFocusManager.kt +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.android.communication.ui.calling.presentation.manager - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.media.AudioManager.MODE_NORMAL -import android.os.Build -import androidx.annotation.RequiresApi -import com.azure.android.communication.ui.calling.models.CallCompositeTelecomManagerOptions -import com.azure.android.communication.ui.calling.redux.Store -import com.azure.android.communication.ui.calling.redux.action.AudioSessionAction -import com.azure.android.communication.ui.calling.redux.state.AudioFocusStatus -import com.azure.android.communication.ui.calling.redux.state.CallingStatus -import com.azure.android.communication.ui.calling.redux.state.ReduxState -import kotlinx.coroutines.flow.collect - -internal abstract class AudioFocusHandler : AudioManager.OnAudioFocusChangeListener { - var onFocusChange: ((Int) -> Unit)? = null - - override fun onAudioFocusChange(focusChange: Int) { - onFocusChange?.let { it(focusChange) } - } - - abstract fun getAudioFocus(): Boolean - abstract fun releaseAudioFocus(): Boolean - abstract fun getMode(): Int -} - -// Newer API Version of AudioFocusHandler -@RequiresApi(Build.VERSION_CODES.O) -internal class AudioFocusHandler26(val context: Context) : AudioFocusHandler() { - private fun audioManager() = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - private val audioFocusRequest26 = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setOnAudioFocusChangeListener(this) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .build() - - override fun getAudioFocus() = - audioManager().requestAudioFocus(audioFocusRequest26) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED - - override fun getMode() = audioManager().mode - - override fun releaseAudioFocus() = - audioManager().abandonAudioFocusRequest(audioFocusRequest26) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED -} - -// Legacy AudioFocus API -@Suppress("DEPRECATION") -internal class AudioFocusHandlerLegacy(val context: Context) : AudioFocusHandler() { - private fun audioManager() = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - override fun getAudioFocus() = audioManager().requestAudioFocus( - this, - AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT - ) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED - - override fun getMode() = audioManager().mode - - override fun releaseAudioFocus(): Boolean = - audioManager().abandonAudioFocus(this) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED -} - -internal class AudioFocusManager( - private val store: Store, - applicationContext: Context, - private val telecomManagerOptions: CallCompositeTelecomManagerOptions?, -) { - private var audioFocusHandler: AudioFocusHandler? = null - private var isAudioFocused = false - private var previousCallState: CallingStatus? = null - private var previousAudioFocusStatus: AudioFocusStatus? = null - - init { - audioFocusHandler = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AudioFocusHandler26(applicationContext) - } else { - AudioFocusHandlerLegacy(applicationContext) - } - } - - suspend fun start() { - if (telecomManagerOptions != null) { - // audio focus is not needed for telecom manager - return - } - - if (audioFocusHandler?.getAudioFocus() == false) { - store.dispatch(AudioSessionAction.AudioFocusRejected()) - } - - audioFocusHandler?.onFocusChange = { - // Todo: AudioFocus can be resumed as well (e.g. transient is temporary, we will get back. - // I.e. like how spotify can continue playing after a call is done. - if (it == AudioManager.AUDIOFOCUS_LOSS || - it == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || - it == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - ) { - store.dispatch(AudioSessionAction.AudioFocusInterrupted()) - } - } - store.getStateFlow().collect { - if (previousAudioFocusStatus != it.audioSessionState.audioFocusStatus) { - previousAudioFocusStatus = it.audioSessionState.audioFocusStatus - if (it.audioSessionState.audioFocusStatus == AudioFocusStatus.REQUESTING) { - val mode = audioFocusHandler?.getMode() - if (mode != MODE_NORMAL && it.audioSessionState.audioFocusStatus == AudioFocusStatus.REQUESTING) { - store.dispatch(AudioSessionAction.AudioFocusRejected()) - } else { - isAudioFocused = audioFocusHandler?.getAudioFocus() == true - if (!isAudioFocused) { - store.dispatch(AudioSessionAction.AudioFocusRejected()) - } else { - store.dispatch(AudioSessionAction.AudioFocusApproved()) - } - } - } else if (it.audioSessionState.audioFocusStatus == AudioFocusStatus.APPROVED) { - store.dispatch(AudioSessionAction.AudioFocusApproved()) - } - } else if (previousCallState != it.callState.callingStatus) { - previousCallState = it.callState.callingStatus - if (it.callState.callingStatus == CallingStatus.CONNECTED) { - isAudioFocused = audioFocusHandler?.getAudioFocus() == true - if (!isAudioFocused) { - store.dispatch(AudioSessionAction.AudioFocusRejected()) - } else { - store.dispatch(AudioSessionAction.AudioFocusApproved()) - } - } else if (it.callState.callingStatus == CallingStatus.DISCONNECTING) { - if (isAudioFocused) { - isAudioFocused = audioFocusHandler?.releaseAudioFocus() == false - } - } - } - } - } - - fun stop() { - audioFocusHandler?.onFocusChange = null - } -} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioModeManager.kt b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioModeManager.kt deleted file mode 100644 index 78d8b7edf..000000000 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioModeManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.android.communication.ui.calling.presentation.manager - -import android.content.Context -import android.media.AudioManager -import android.media.AudioManager.MODE_IN_COMMUNICATION -import android.media.AudioManager.MODE_NORMAL -import com.azure.android.communication.ui.calling.redux.Store -import com.azure.android.communication.ui.calling.redux.state.CallingStatus -import com.azure.android.communication.ui.calling.redux.state.ReduxState -import kotlinx.coroutines.flow.collect - -internal class AudioModeManager( - private val store: Store, - context: Context, -) { - private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } - - suspend fun start() { - store.getStateFlow().collect { - if (audioManager.mode != MODE_IN_COMMUNICATION && it.callState.callingStatus == CallingStatus.CONNECTED) { - // to fix samsung device audio issue - // MODE_IN_COMMUNICATION is used to let the system know that the app is in a VOIP call - audioManager?.mode = MODE_IN_COMMUNICATION - } - if (it.callState.callingStatus == CallingStatus.LOCAL_HOLD) { - // To fix audio focus retrieval after returning from other call, we need to - // assign ourselves as mode_normal when we go to hold - audioManager?.mode = MODE_NORMAL - } - } - } - - fun onDestroy() { - audioManager.mode = MODE_NORMAL - } -} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioSessionManager.kt b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioSessionManager.kt deleted file mode 100644 index 97f6f5e74..000000000 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/presentation/manager/AudioSessionManager.kt +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.android.communication.ui.calling.presentation.manager - -import android.app.Activity -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothHeadset -import android.bluetooth.BluetoothManager -import android.bluetooth.BluetoothProfile -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.media.AudioManager -import com.azure.android.communication.ui.calling.implementation.R -import com.azure.android.communication.ui.calling.redux.Store -import com.azure.android.communication.ui.calling.redux.action.LocalParticipantAction -import com.azure.android.communication.ui.calling.redux.state.AudioDeviceSelectionStatus -import com.azure.android.communication.ui.calling.redux.state.ReduxState -import kotlinx.coroutines.flow.collect -import android.media.AudioDeviceInfo -import android.os.Build -import android.os.Bundle -import androidx.annotation.RequiresApi - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.coroutineScope -import com.azure.android.communication.ui.calling.CallCompositeException -/* -import com.azure.android.communication.ui.calling.models.CallCompositeAudioSelectionMode - */ -import kotlinx.coroutines.launch -import java.lang.IllegalArgumentException -import com.azure.android.communication.ui.calling.redux.state.PermissionStatus - -internal class AudioSessionManager( - private val store: Store, - private val context: Context, - /* - private val audioSelectionMode: CallCompositeAudioSelectionMode? = null, - */ - -) : BluetoothProfile.ServiceListener, BroadcastReceiver() { - - private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } - private var bluetoothAudioProxy: BluetoothHeadset? = null - private var initialized = false - private var isProxyOpen = false - - private val isBluetoothScoAvailable - get() = try { - (bluetoothAudioProxy?.connectedDevices?.size ?: 0) > 0 - } catch (exception: SecurityException) { - false - } - - private val bluetoothDeviceName: String - get() = try { - bluetoothAudioProxy?.connectedDevices?.firstOrNull()?.name - ?: context.getString(R.string.azure_communication_ui_calling_audio_device_drawer_bluetooth) - } catch (exception: SecurityException) { - context.getString(R.string.azure_communication_ui_calling_audio_device_drawer_bluetooth) - } - - private var previousPermissionState: PermissionStatus = PermissionStatus.UNKNOWN - - private var previousAudioDeviceSelectionStatus: AudioDeviceSelectionStatus? = null - private var priorToBluetoothAudioSelectionStatus: AudioDeviceSelectionStatus? = null - - private val btAdapter: BluetoothAdapter? get() { - val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - return manager.adapter - } - - fun onCreate(savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - initializeAudioDeviceState() - - // Listeners we need to rebind with Activity (Bluetooth, Headset, State Updates) - openProfileProxy() - - val filter = IntentFilter(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) - filter.addAction(AudioManager.ACTION_HEADSET_PLUG) - filter.addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED) - context.registerReceiver(this@AudioSessionManager, filter) - } - } - - fun onStart(activity: Activity) { - if (activity !is LifecycleOwner) { - throw CallCompositeException("Activity must be a LifecycleOwner", IllegalArgumentException()) - } - (activity as LifecycleOwner).lifecycle.coroutineScope.launch { - // On first launch we need to init the redux-state, check Bluetooth and Headset status - store.getStateFlow().collect { - if (previousAudioDeviceSelectionStatus == null || - previousAudioDeviceSelectionStatus != it.localParticipantState.audioState.device - ) { - onAudioDeviceStateChange(it.localParticipantState.audioState.device) - } - - // After permission is granted, double check bluetooth status - if (it.permissionState.audioPermissionState == PermissionStatus.GRANTED && - previousPermissionState != PermissionStatus.GRANTED - ) { - updateBluetoothStatus() - } - - previousAudioDeviceSelectionStatus = it.localParticipantState.audioState.device - previousPermissionState = it.permissionState.audioPermissionState - } - } - } - - // Call when the Activity is finishing (i.e. call is done) - fun onDestroy(activity: Activity) { - if (activity.isFinishing) { - btAdapter?.run { - closeProfileProxy(BluetoothProfile.HEADSET, bluetoothAudioProxy) - } - - if (audioManager.isBluetoothScoOn) { - audioManager.stopBluetoothSco() - } - audioManager.isBluetoothScoOn = false - audioManager.isSpeakerphoneOn = false - bluetoothAudioProxy = null - context.unregisterReceiver(this@AudioSessionManager) - } - } - - override fun onReceive(context: Context?, intent: Intent?) { - intent?.apply { - when (action) { - BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED -> openProfileProxy() - BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> updateBluetoothStatus() - AudioManager.ACTION_HEADSET_PLUG -> updateHeadphoneStatus() - } - } - } - - private fun updateHeadphoneStatus() { - store.dispatch(LocalParticipantAction.AudioDeviceHeadsetAvailable(isHeadsetActive())) - } - - // Update the status of bluetooth - // Connect a headset automatically if bluetooth is connected - // When disconnected revert to "Speaker" - // When disconnected (and not selected), just update availability - private fun updateBluetoothStatus() { - val audioState = store.getCurrentState().localParticipantState.audioState - - // Bluetooth is no longer available - // Fallback to previous device selection - if (!isBluetoothScoAvailable && - audioState.bluetoothState.available && - audioState.device == AudioDeviceSelectionStatus.BLUETOOTH_SCO_SELECTED - ) { - // Request the Previous Device - revertToPreviousAudioDevice() - } - - // Auto-Connect to Bluetooth if it wasn't available but now is - if (isBluetoothScoAvailable && !audioState.bluetoothState.available) { - - store.dispatch( - LocalParticipantAction.AudioDeviceBluetoothSCOAvailable( - isBluetoothScoAvailable, - bluetoothDeviceName - ) - ) - - priorToBluetoothAudioSelectionStatus = store.getCurrentState().localParticipantState.audioState.device - store.dispatch( - LocalParticipantAction.AudioDeviceChangeRequested( - AudioDeviceSelectionStatus.BLUETOOTH_SCO_REQUESTED - ) - ) - } - - // Update the Bluetooth Status in the store - store.dispatch( - LocalParticipantAction.AudioDeviceBluetoothSCOAvailable( - isBluetoothScoAvailable, - bluetoothDeviceName - ) - ) - } - - private fun revertToPreviousAudioDevice() { - store.dispatch( - LocalParticipantAction.AudioDeviceChangeRequested( - when (priorToBluetoothAudioSelectionStatus) { - AudioDeviceSelectionStatus.RECEIVER_SELECTED -> AudioDeviceSelectionStatus.RECEIVER_REQUESTED - else -> AudioDeviceSelectionStatus.SPEAKER_REQUESTED - } - ) - ) - } - - private fun isHeadsetActive(): Boolean { - // We support 21+. audioManager.getDevices API was added in 23. - // audioManager.isWiredHeadsetOn call is for pre-23 devices. - // M=23, O=26. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return audioManager.isWiredHeadsetOn - } - val headsetTypes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - headsetTypesPost25() - } else { - headsetTypesPost22() - } - return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).find { - it.type in headsetTypes - } != null - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun headsetTypesPost22(): List { - return listOf(AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun headsetTypesPost25(): List { - return headsetTypesPost22() + listOf(AudioDeviceInfo.TYPE_USB_HEADSET) - } - - private fun initializeAudioDeviceState() { - if (initialized) return - initialized = true - - /* - if (audioSelectionMode == CallCompositeAudioSelectionMode.RECEIVER) { - enableEarpiece() - store.dispatch( - LocalParticipantAction.AudioDeviceChangeSucceeded(AudioDeviceSelectionStatus.RECEIVER_SELECTED) - ) - } else if (audioSelectionMode == CallCompositeAudioSelectionMode.BLUETOOTH) { - enableBluetooth() - store.dispatch( - LocalParticipantAction.AudioDeviceChangeSucceeded(AudioDeviceSelectionStatus.BLUETOOTH_SCO_SELECTED) - ) - } else { */ - enableSpeakerPhone() - store.dispatch( - LocalParticipantAction.AudioDeviceChangeSucceeded(AudioDeviceSelectionStatus.SPEAKER_SELECTED) - ) - /* - } */ - - updateHeadphoneStatus() - } - - private fun onAudioDeviceStateChange(audioDeviceSelectionStatus: AudioDeviceSelectionStatus) { - when (audioDeviceSelectionStatus) { - AudioDeviceSelectionStatus.SPEAKER_REQUESTED, AudioDeviceSelectionStatus.RECEIVER_REQUESTED, AudioDeviceSelectionStatus.BLUETOOTH_SCO_REQUESTED -> - switchAudioDevice(audioDeviceSelectionStatus) - else -> {} - } - } - - private fun switchAudioDevice(audioDeviceSelectionStatus: AudioDeviceSelectionStatus) { - when (audioDeviceSelectionStatus) { - AudioDeviceSelectionStatus.SPEAKER_REQUESTED -> { - enableSpeakerPhone() - store.dispatch( - LocalParticipantAction.AudioDeviceChangeSucceeded( - AudioDeviceSelectionStatus.SPEAKER_SELECTED - ) - ) - } - AudioDeviceSelectionStatus.RECEIVER_REQUESTED -> { - enableEarpiece() - store.dispatch( - LocalParticipantAction.AudioDeviceChangeSucceeded( - AudioDeviceSelectionStatus.RECEIVER_SELECTED - ) - ) - } - AudioDeviceSelectionStatus.BLUETOOTH_SCO_REQUESTED -> { - enableBluetooth() - store.dispatch( - LocalParticipantAction.AudioDeviceChangeSucceeded( - AudioDeviceSelectionStatus.BLUETOOTH_SCO_SELECTED - ) - ) - } - else -> {} - } - } - - private fun enableSpeakerPhone() { - audioManager.stopBluetoothSco() - audioManager.isBluetoothScoOn = false - audioManager.isSpeakerphoneOn = true - } - - private fun enableEarpiece() { - audioManager.stopBluetoothSco() - audioManager.isBluetoothScoOn = false - audioManager.isSpeakerphoneOn = false - } - - private fun enableBluetooth() { - try { - if (!audioManager.isBluetoothScoOn) { - audioManager.startBluetoothSco() - audioManager.isBluetoothScoOn = true - audioManager.isSpeakerphoneOn = false - } - } catch (exception: Exception) { - revertToPreviousAudioDevice() - } - } - - private fun openProfileProxy() { - if (isProxyOpen) return - if (btAdapter?.isEnabled == true && bluetoothAudioProxy == null) { - btAdapter?.run { - getProfileProxy(context, this@AudioSessionManager, BluetoothProfile.HEADSET) - isProxyOpen = true - } - } - } - - private fun closeProfileProxy() { - if (!isProxyOpen) return - isProxyOpen = false - bluetoothAudioProxy?.let { - btAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothAudioProxy) - bluetoothAudioProxy = null - } - } - - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { - bluetoothAudioProxy = proxy as BluetoothHeadset - updateBluetoothStatus() - } - - override fun onServiceDisconnected(profile: Int) { - if (isProxyOpen) { - closeProfileProxy() - } - } -} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioFocusAction.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioFocusAction.java new file mode 100644 index 000000000..7d74990ac --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioFocusAction.java @@ -0,0 +1,86 @@ +package com.azure.android.communication.ui.calling.redux.action; + +/** + * Actions related to audio focus management. + */ +public abstract class AudioFocusAction { + private AudioFocusAction() { + } + + /** + * Action dispatched when requesting audio focus. + */ + public static final class RequestFocus { + public RequestFocus() { + } + } + + /** + * Action dispatched when audio focus is granted. + */ + public static final class FocusGranted { + public FocusGranted() { + } + } + + /** + * Action dispatched when audio focus is denied. + */ + public static final class FocusDenied { + private final String reason; + + public FocusDenied(String reason) { + this.reason = reason; + } + + public String getReason() { + return reason; + } + } + + /** + * Action dispatched when audio focus is lost. + */ + public static final class FocusLost { + private final boolean isTransient; + + public FocusLost(boolean isTransient) { + this.isTransient = isTransient; + } + + public boolean isTransient() { + return isTransient; + } + } + + /** + * Action dispatched when requesting to release audio focus. + */ + public static final class ReleaseFocus { + public ReleaseFocus() { + } + } + + /** + * Action dispatched when audio focus is interrupted. + */ + public static final class FocusInterrupted { + private final String reason; + + public FocusInterrupted(String reason) { + this.reason = reason; + } + + public String getReason() { + return reason; + } + } + + /** + * Action dispatched when audio focus is restored after interruption. + */ + public static final class FocusRestored { + public FocusRestored() { + } + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioModeAction.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioModeAction.java new file mode 100644 index 000000000..95726cdf4 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/action/AudioModeAction.java @@ -0,0 +1,91 @@ +package com.azure.android.communication.ui.calling.redux.action; + +import com.azure.android.communication.ui.calling.redux.state.AudioMode; + +/** + * Actions related to audio mode management. + */ +public abstract class AudioModeAction { + private AudioModeAction() { + } + + /** + * Action dispatched when requesting to set a specific audio mode. + */ + public static final class SetModeRequested { + private final AudioMode mode; + + public SetModeRequested(AudioMode mode) { + this.mode = mode; + } + + public AudioMode getMode() { + return mode; + } + } + + /** + * Action dispatched when mode change is successful. + */ + public static final class ModeChangeSucceeded { + private final AudioMode mode; + + public ModeChangeSucceeded(AudioMode mode) { + this.mode = mode; + } + + public AudioMode getMode() { + return mode; + } + } + + /** + * Action dispatched when mode change fails. + */ + public static final class ModeChangeFailed { + private final AudioMode targetMode; + private final AudioMode fallbackMode; + private final String error; + + public ModeChangeFailed(AudioMode targetMode, AudioMode fallbackMode, String error) { + this.targetMode = targetMode; + this.fallbackMode = fallbackMode; + this.error = error; + } + + public AudioMode getTargetMode() { + return targetMode; + } + + public AudioMode getFallbackMode() { + return fallbackMode; + } + + public String getError() { + return error; + } + } + + /** + * Action dispatched when requesting to restore previous audio mode. + */ + public static final class RestorePreviousModeRequested { + public RestorePreviousModeRequested() { + } + } + + /** + * Action dispatched when audio mode is forcibly changed by the system. + */ + public static final class SystemModeChanged { + private final AudioMode mode; + + public SystemModeChanged(AudioMode mode) { + this.mode = mode; + } + + public AudioMode getMode() { + return mode; + } + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddleware.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddleware.java new file mode 100644 index 000000000..2323c82b9 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddleware.java @@ -0,0 +1,114 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Build; + +import com.azure.android.communication.ui.calling.redux.action.AudioFocusAction; +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.state.ReduxState; +import com.azure.android.communication.ui.calling.redux.Dispatch; +import com.azure.android.communication.ui.calling.redux.Middleware; + +/** + * Middleware for handling audio focus operations. + */ +public final class AudioFocusMiddleware implements Middleware { + private final AudioManager audioManager; + private final AudioFocusHandler audioFocusHandler; + private AudioFocusRequest audioFocusRequest; + + public AudioFocusMiddleware(Context context) { + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + this.audioFocusHandler = new AudioFocusHandler(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + + this.audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(audioAttributes) + .setOnAudioFocusChangeListener(audioFocusHandler) + .build(); + } + } + + @Override + public Dispatch.Fn invoke(final Store store) { + audioFocusHandler.setDispatch(store::dispatch); + return next -> action -> { + if (action instanceof AudioFocusAction.RequestFocus) { + return handleRequestFocus(next); + } + + if (action instanceof AudioFocusAction.ReleaseFocus) { + return handleReleaseFocus(next); + } + + // Pass through all other actions + return next.invoke(action); + }; + } + + private Object handleRequestFocus(Dispatch next) { + int result; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + result = audioManager.requestAudioFocus(audioFocusRequest); + } else { + result = audioManager.requestAudioFocus(audioFocusHandler, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN); + } + + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + return next.invoke(new AudioFocusAction.FocusGranted()); + } else { + return next.invoke(new AudioFocusAction.FocusDenied("Audio focus request denied")); + } + } + + private Object handleReleaseFocus(Dispatch next) { + int result; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + result = audioManager.abandonAudioFocusRequest(audioFocusRequest); + } else { + result = audioManager.abandonAudioFocus(audioFocusHandler); + } + + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + return next.invoke(new AudioFocusAction.ReleaseFocus()); + } + return null; + } + + private static class AudioFocusHandler implements AudioManager.OnAudioFocusChangeListener { + private Dispatch dispatch; + + void setDispatch(Dispatch dispatch) { + this.dispatch = dispatch; + } + + @Override + public void onAudioFocusChange(int focusChange) { + if (dispatch == null) return; + + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + dispatch.invoke(new AudioFocusAction.FocusLost(false)); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + dispatch.invoke(new AudioFocusAction.FocusLost(true)); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + dispatch.invoke(new AudioFocusAction.FocusInterrupted("Ducking required")); + break; + case AudioManager.AUDIOFOCUS_GAIN: + dispatch.invoke(new AudioFocusAction.FocusRestored()); + break; + } + } + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddleware.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddleware.java new file mode 100644 index 000000000..cf4e5a988 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddleware.java @@ -0,0 +1,96 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.content.Context; +import android.media.AudioManager; + +import com.azure.android.communication.ui.calling.redux.action.AudioModeAction; +import com.azure.android.communication.ui.calling.redux.state.AudioMode; +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.state.ReduxState; +import com.azure.android.communication.ui.calling.redux.Dispatch; +import com.azure.android.communication.ui.calling.redux.Middleware; + +/** + * Middleware for handling audio mode operations. + */ +public final class AudioModeMiddleware implements Middleware { + private final AudioManager audioManager; + + public AudioModeMiddleware(Context context) { + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + + @Override + public Dispatch.Fn invoke(final Store store) { + return next -> action -> { + if (action instanceof AudioModeAction.SetModeRequested) { + return handleSetMode((AudioModeAction.SetModeRequested) action, store, next); + } + + if (action instanceof AudioModeAction.RestorePreviousModeRequested) { + return handleRestoreMode(store, next); + } + + // Pass through all other actions + return next.invoke(action); + }; + } + + private Object handleSetMode( + AudioModeAction.SetModeRequested action, + Store store, + Dispatch next) { + AudioMode targetMode = action.getMode(); + AudioMode currentMode = store.getCurrentState().getAudioState().getModeState().getCurrentMode(); + + if (currentMode == targetMode) { + return null; + } + + try { + int androidAudioMode = convertToAndroidAudioMode(targetMode); + audioManager.setMode(androidAudioMode); + return next.invoke(new AudioModeAction.ModeChangeSucceeded(targetMode)); + } catch (Exception e) { + return next.invoke(new AudioModeAction.ModeChangeFailed( + targetMode, + currentMode, + "Failed to set audio mode: " + e.getMessage() + )); + } + } + + private Object handleRestoreMode(Store store, Dispatch next) { + AudioMode previousMode = store.getCurrentState().getAudioState().getModeState().getPreviousMode(); + if (previousMode == null) { + return null; + } + + try { + int androidAudioMode = convertToAndroidAudioMode(previousMode); + audioManager.setMode(androidAudioMode); + return next.invoke(new AudioModeAction.ModeChangeSucceeded(previousMode)); + } catch (Exception e) { + return next.invoke(new AudioModeAction.ModeChangeFailed( + previousMode, + store.getCurrentState().getAudioState().getModeState().getCurrentMode(), + "Failed to restore audio mode: " + e.getMessage() + )); + } + } + + private int convertToAndroidAudioMode(AudioMode mode) { + switch (mode) { + case NORMAL: + return AudioManager.MODE_NORMAL; + case IN_CALL: + return AudioManager.MODE_IN_CALL; + case IN_COMMUNICATION: + return AudioManager.MODE_IN_COMMUNICATION; + case RINGTONE: + return AudioManager.MODE_RINGTONE; + default: + return AudioManager.MODE_NORMAL; + } + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducer.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducer.java new file mode 100644 index 000000000..0625db56f --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducer.java @@ -0,0 +1,57 @@ +package com.azure.android.communication.ui.calling.redux.reducer; + +import com.azure.android.communication.ui.calling.redux.action.AudioFocusAction; +import com.azure.android.communication.ui.calling.redux.state.AudioFocusState; +import com.azure.android.communication.ui.calling.redux.state.AudioFocusStatus; + +/** + * Reducer for handling audio focus state changes. + */ +public final class AudioFocusReducer { + private AudioFocusReducer() { + } + + /** + * Handles state updates based on audio focus actions. + * + * @param state Current audio focus state + * @param action Action to process + * @return Updated audio focus state + */ + public static AudioFocusState reduce(final AudioFocusState state, final Object action) { + if (action instanceof AudioFocusAction.RequestFocus) { + return state.copy(AudioFocusStatus.REQUESTING); + } + + if (action instanceof AudioFocusAction.FocusGranted) { + return state.copy(AudioFocusStatus.APPROVED); + } + + if (action instanceof AudioFocusAction.FocusDenied) { + return state.copy(AudioFocusStatus.REJECTED, + ((AudioFocusAction.FocusDenied) action).getReason()); + } + + if (action instanceof AudioFocusAction.FocusLost) { + AudioFocusAction.FocusLost lostAction = (AudioFocusAction.FocusLost) action; + return state.copy( + lostAction.isTransient() ? AudioFocusStatus.INTERRUPTED : AudioFocusStatus.NONE + ); + } + + if (action instanceof AudioFocusAction.ReleaseFocus) { + return state.copy(AudioFocusStatus.NONE); + } + + if (action instanceof AudioFocusAction.FocusInterrupted) { + return state.copy(AudioFocusStatus.INTERRUPTED, + ((AudioFocusAction.FocusInterrupted) action).getReason()); + } + + if (action instanceof AudioFocusAction.FocusRestored) { + return state.copy(AudioFocusStatus.APPROVED); + } + + return state; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducer.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducer.java new file mode 100644 index 000000000..805acf736 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducer.java @@ -0,0 +1,47 @@ +package com.azure.android.communication.ui.calling.redux.reducer; + +import com.azure.android.communication.ui.calling.redux.action.AudioModeAction; +import com.azure.android.communication.ui.calling.redux.state.AudioModeState; + +/** + * Reducer for handling audio mode state changes. + */ +public final class AudioModeReducer { + private AudioModeReducer() { + } + + /** + * Handles state updates based on audio mode actions. + * + * @param state Current audio mode state + * @param action Action to process + * @return Updated audio mode state + */ + public static AudioModeState reduce(final AudioModeState state, final Object action) { + if (action instanceof AudioModeAction.SetModeRequested) { + return state.copy(((AudioModeAction.SetModeRequested) action).getMode()); + } + + if (action instanceof AudioModeAction.ModeChangeSucceeded) { + return state.copy(((AudioModeAction.ModeChangeSucceeded) action).getMode()); + } + + if (action instanceof AudioModeAction.ModeChangeFailed) { + AudioModeAction.ModeChangeFailed failedAction = (AudioModeAction.ModeChangeFailed) action; + return state.copy(failedAction.getFallbackMode(), failedAction.getError()); + } + + if (action instanceof AudioModeAction.RestorePreviousModeRequested) { + if (state.getPreviousMode() != null) { + return state.copy(state.getPreviousMode()); + } + return state; + } + + if (action instanceof AudioModeAction.SystemModeChanged) { + return state.copy(((AudioModeAction.SystemModeChanged) action).getMode()); + } + + return state; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusState.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusState.java new file mode 100644 index 000000000..b723f7767 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusState.java @@ -0,0 +1,51 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Represents the state of audio focus in the application. + */ +public final class AudioFocusState { + private final AudioFocusStatus status; + private final String lastError; + + public AudioFocusState() { + this(AudioFocusStatus.NONE, null); + } + + public AudioFocusState(AudioFocusStatus status, String lastError) { + this.status = status; + this.lastError = lastError; + } + + public AudioFocusStatus getStatus() { + return status; + } + + public String getLastError() { + return lastError; + } + + public AudioFocusState copy(AudioFocusStatus newStatus, String newError) { + return new AudioFocusState(newStatus, newError); + } + + public AudioFocusState copy(AudioFocusStatus newStatus) { + return new AudioFocusState(newStatus, this.lastError); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + AudioFocusState other = (AudioFocusState) obj; + return status == other.status && + (lastError == null ? other.lastError == null : lastError.equals(other.lastError)); + } + + @Override + public int hashCode() { + int result = status != null ? status.hashCode() : 0; + result = 31 * result + (lastError != null ? lastError.hashCode() : 0); + return result; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusStatus.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusStatus.java new file mode 100644 index 000000000..f7491e7e8 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioFocusStatus.java @@ -0,0 +1,12 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Represents the status of audio focus request. + */ +public enum AudioFocusStatus { + NONE, + REQUESTING, + APPROVED, + REJECTED, + INTERRUPTED +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioMode.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioMode.java new file mode 100644 index 000000000..fc1e7f1ea --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioMode.java @@ -0,0 +1,12 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Represents different audio modes for the device. + */ +public enum AudioMode { + NORMAL, + IN_CALL, + IN_COMMUNICATION, + RINGTONE, + UNKNOWN +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioModeState.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioModeState.java new file mode 100644 index 000000000..3a4ce85a7 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioModeState.java @@ -0,0 +1,63 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Represents the state of audio mode in the application. + */ +public final class AudioModeState { + private final AudioMode currentMode; + private final AudioMode previousMode; + private final String lastError; + + public AudioModeState() { + this(AudioMode.NORMAL, null, null); + } + + public AudioModeState(AudioMode currentMode, AudioMode previousMode, String lastError) { + this.currentMode = currentMode; + this.previousMode = previousMode; + this.lastError = lastError; + } + + public AudioMode getCurrentMode() { + return currentMode; + } + + public AudioMode getPreviousMode() { + return previousMode; + } + + public String getLastError() { + return lastError; + } + + public AudioModeState copy(AudioMode newMode) { + return new AudioModeState(newMode, this.currentMode, null); + } + + public AudioModeState copy(AudioMode newMode, String error) { + return new AudioModeState(newMode, this.currentMode, error); + } + + public AudioModeState copyWithError(String error) { + return new AudioModeState(this.currentMode, this.previousMode, error); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + AudioModeState other = (AudioModeState) obj; + return currentMode == other.currentMode && + previousMode == other.previousMode && + (lastError == null ? other.lastError == null : lastError.equals(other.lastError)); + } + + @Override + public int hashCode() { + int result = currentMode != null ? currentMode.hashCode() : 0; + result = 31 * result + (previousMode != null ? previousMode.hashCode() : 0); + result = 31 * result + (lastError != null ? lastError.hashCode() : 0); + return result; + } +} diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioState.java b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioState.java new file mode 100644 index 000000000..06be89835 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/state/AudioState.java @@ -0,0 +1,71 @@ +package com.azure.android.communication.ui.calling.redux.state; + +/** + * Represents the complete audio state for the application. + */ +public final class AudioState { + private final AudioFocusState focusState; + private final AudioModeState modeState; + private final AudioDeviceState deviceState; + + public AudioState() { + this(new AudioFocusState(), new AudioModeState(), new AudioDeviceState()); + } + + public AudioState(AudioFocusState focusState, AudioModeState modeState, AudioDeviceState deviceState) { + this.focusState = focusState; + this.modeState = modeState; + this.deviceState = deviceState; + } + + public AudioFocusState getFocusState() { + return focusState; + } + + public AudioModeState getModeState() { + return modeState; + } + + public AudioDeviceState getDeviceState() { + return deviceState; + } + + public AudioState copy(AudioFocusState newFocusState, AudioModeState newModeState, AudioDeviceState newDeviceState) { + return new AudioState( + newFocusState != null ? newFocusState : this.focusState, + newModeState != null ? newModeState : this.modeState, + newDeviceState != null ? newDeviceState : this.deviceState + ); + } + + public AudioState copyWithFocusState(AudioFocusState newFocusState) { + return copy(newFocusState, null, null); + } + + public AudioState copyWithModeState(AudioModeState newModeState) { + return copy(null, newModeState, null); + } + + public AudioState copyWithDeviceState(AudioDeviceState newDeviceState) { + return copy(null, null, newDeviceState); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + AudioState other = (AudioState) obj; + return focusState.equals(other.focusState) && + modeState.equals(other.modeState) && + deviceState.equals(other.deviceState); + } + + @Override + public int hashCode() { + int result = focusState != null ? focusState.hashCode() : 0; + result = 31 * result + (modeState != null ? modeState.hashCode() : 0); + result = 31 * result + (deviceState != null ? deviceState.hashCode() : 0); + return result; + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddlewareTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddlewareTest.java new file mode 100644 index 000000000..355f0adbb --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioFocusMiddlewareTest.java @@ -0,0 +1,163 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.content.Context; +import android.media.AudioManager; +import android.os.Build; + +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.action.AudioFocusAction; +import com.azure.android.communication.ui.calling.redux.state.ReduxState; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class AudioFocusMiddlewareTest { + @Mock + private Context mockContext; + + @Mock + private AudioManager mockAudioManager; + + @Mock + private Store mockStore; + + private AudioFocusMiddleware middleware; + private ArgumentCaptor listenerCaptor; + + @Before + public void setup() { + when(mockContext.getSystemService(Context.AUDIO_SERVICE)).thenReturn(mockAudioManager); + listenerCaptor = ArgumentCaptor.forClass(AudioManager.OnAudioFocusChangeListener.class); + middleware = new AudioFocusMiddleware(mockContext); + } + + @Test + public void requestFocus_whenGranted_shouldDispatchGrantedAction() { + // Arrange + when(mockAudioManager.requestAudioFocus(any(), anyInt(), anyInt())) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED); + AudioFocusAction.RequestFocus action = new AudioFocusAction.RequestFocus(); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + verify(mockAudioManager).requestAudioFocus(any()); + } else { + verify(mockAudioManager).requestAudioFocus(any(), eq(AudioManager.STREAM_VOICE_CALL), + eq(AudioManager.AUDIOFOCUS_GAIN)); + } + verify(next).invoke(any(AudioFocusAction.FocusGranted.class)); + } + + @Test + public void requestFocus_whenDenied_shouldDispatchDeniedAction() { + // Arrange + when(mockAudioManager.requestAudioFocus(any(), anyInt(), anyInt())) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_FAILED); + AudioFocusAction.RequestFocus action = new AudioFocusAction.RequestFocus(); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(next).invoke(any(AudioFocusAction.FocusDenied.class)); + } + + @Test + public void releaseFocus_whenSuccessful_shouldDispatchReleaseAction() { + // Arrange + when(mockAudioManager.abandonAudioFocus(any())) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED); + AudioFocusAction.ReleaseFocus action = new AudioFocusAction.ReleaseFocus(); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + verify(mockAudioManager).abandonAudioFocusRequest(any()); + } else { + verify(mockAudioManager).abandonAudioFocus(any()); + } + verify(next).invoke(any(AudioFocusAction.ReleaseFocus.class)); + } + + @Test + public void onAudioFocusChange_whenLossFocusPermanent_shouldDispatchLostAction() { + // Arrange + captureAudioFocusListener(); + + // Act + listenerCaptor.getValue().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS); + + // Assert + verify(mockStore).dispatch(any(AudioFocusAction.FocusLost.class)); + } + + @Test + public void onAudioFocusChange_whenLossFocusTransient_shouldDispatchLostTransientAction() { + // Arrange + captureAudioFocusListener(); + + // Act + listenerCaptor.getValue().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT); + + // Assert + verify(mockStore).dispatch(any(AudioFocusAction.FocusLost.class)); + } + + @Test + public void onAudioFocusChange_whenLossFocusCanDuck_shouldDispatchInterruptedAction() { + // Arrange + captureAudioFocusListener(); + + // Act + listenerCaptor.getValue().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + + // Assert + verify(mockStore).dispatch(any(AudioFocusAction.FocusInterrupted.class)); + } + + @Test + public void onAudioFocusChange_whenGainFocus_shouldDispatchRestoredAction() { + // Arrange + captureAudioFocusListener(); + + // Act + listenerCaptor.getValue().onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN); + + // Assert + verify(mockStore).dispatch(any(AudioFocusAction.FocusRestored.class)); + } + + @Test + public void unknownAction_shouldPassThrough() { + // Arrange + Object unknownAction = new Object(); + + // Act + middleware.invoke(mockStore).apply(next -> unknownAction).invoke(unknownAction); + + // Assert + verify(next).invoke(unknownAction); + } + + private void captureAudioFocusListener() { + AudioFocusAction.RequestFocus action = new AudioFocusAction.RequestFocus(); + middleware.invoke(mockStore).apply(next -> action).invoke(action); + verify(mockAudioManager).requestAudioFocus(listenerCaptor.capture(), anyInt(), anyInt()); + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddlewareTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddlewareTest.java new file mode 100644 index 000000000..7ebebaa1b --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/AudioModeMiddlewareTest.java @@ -0,0 +1,157 @@ +package com.azure.android.communication.ui.calling.redux.middleware; + +import android.content.Context; +import android.media.AudioManager; + +import com.azure.android.communication.ui.calling.redux.Store; +import com.azure.android.communication.ui.calling.redux.action.AudioModeAction; +import com.azure.android.communication.ui.calling.redux.state.AudioMode; +import com.azure.android.communication.ui.calling.redux.state.AudioModeState; +import com.azure.android.communication.ui.calling.redux.state.AudioState; +import com.azure.android.communication.ui.calling.redux.state.ReduxState; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class AudioModeMiddlewareTest { + @Mock + private Context mockContext; + + @Mock + private AudioManager mockAudioManager; + + @Mock + private Store mockStore; + + @Mock + private ReduxState mockState; + + @Mock + private AudioState mockAudioState; + + @Mock + private AudioModeState mockAudioModeState; + + private AudioModeMiddleware middleware; + + @Before + public void setup() { + when(mockContext.getSystemService(Context.AUDIO_SERVICE)).thenReturn(mockAudioManager); + when(mockStore.getCurrentState()).thenReturn(mockState); + when(mockState.getAudioState()).thenReturn(mockAudioState); + when(mockAudioState.getModeState()).thenReturn(mockAudioModeState); + middleware = new AudioModeMiddleware(mockContext); + } + + @Test + public void setMode_whenModeChangeSucceeds_shouldDispatchSuccessAction() { + // Arrange + AudioMode targetMode = AudioMode.IN_COMMUNICATION; + when(mockAudioModeState.getCurrentMode()).thenReturn(AudioMode.NORMAL); + AudioModeAction.SetModeRequested action = new AudioModeAction.SetModeRequested(targetMode); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(mockAudioManager).setMode(AudioManager.MODE_IN_COMMUNICATION); + verify(next).invoke(any(AudioModeAction.ModeChangeSucceeded.class)); + } + + @Test + public void setMode_whenModeChangeFails_shouldDispatchFailureAction() { + // Arrange + AudioMode targetMode = AudioMode.IN_COMMUNICATION; + when(mockAudioModeState.getCurrentMode()).thenReturn(AudioMode.NORMAL); + doThrow(new RuntimeException("Test error")).when(mockAudioManager).setMode(anyInt()); + AudioModeAction.SetModeRequested action = new AudioModeAction.SetModeRequested(targetMode); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(mockAudioManager).setMode(AudioManager.MODE_IN_COMMUNICATION); + verify(next).invoke(any(AudioModeAction.ModeChangeFailed.class)); + } + + @Test + public void setMode_whenSameMode_shouldNotDispatchAction() { + // Arrange + AudioMode currentMode = AudioMode.IN_COMMUNICATION; + when(mockAudioModeState.getCurrentMode()).thenReturn(currentMode); + AudioModeAction.SetModeRequested action = new AudioModeAction.SetModeRequested(currentMode); + + // Act + Object result = middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(mockAudioManager, never()).setMode(anyInt()); + verifyNoInteractions(next); + assertNull(result); + } + + @Test + public void restoreMode_whenPreviousModeExists_shouldDispatchSuccessAction() { + // Arrange + AudioMode previousMode = AudioMode.IN_CALL; + when(mockAudioModeState.getPreviousMode()).thenReturn(previousMode); + AudioModeAction.RestorePreviousModeRequested action = new AudioModeAction.RestorePreviousModeRequested(); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(mockAudioManager).setMode(AudioManager.MODE_IN_CALL); + verify(next).invoke(any(AudioModeAction.ModeChangeSucceeded.class)); + } + + @Test + public void restoreMode_whenNoPreviousMode_shouldNotDispatchAction() { + // Arrange + when(mockAudioModeState.getPreviousMode()).thenReturn(null); + AudioModeAction.RestorePreviousModeRequested action = new AudioModeAction.RestorePreviousModeRequested(); + + // Act + Object result = middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(mockAudioManager, never()).setMode(anyInt()); + verifyNoInteractions(next); + assertNull(result); + } + + @Test + public void restoreMode_whenModeChangeFails_shouldDispatchFailureAction() { + // Arrange + AudioMode previousMode = AudioMode.IN_CALL; + when(mockAudioModeState.getPreviousMode()).thenReturn(previousMode); + doThrow(new RuntimeException("Test error")).when(mockAudioManager).setMode(anyInt()); + AudioModeAction.RestorePreviousModeRequested action = new AudioModeAction.RestorePreviousModeRequested(); + + // Act + middleware.invoke(mockStore).apply(next -> action).invoke(action); + + // Assert + verify(mockAudioManager).setMode(AudioManager.MODE_IN_CALL); + verify(next).invoke(any(AudioModeAction.ModeChangeFailed.class)); + } + + @Test + public void unknownAction_shouldPassThrough() { + // Arrange + Object unknownAction = new Object(); + + // Act + middleware.invoke(mockStore).apply(next -> unknownAction).invoke(unknownAction); + + // Assert + verify(next).invoke(unknownAction); + verifyNoInteractions(mockAudioManager); + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducerTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducerTest.java new file mode 100644 index 000000000..2c17ce7e1 --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioFocusReducerTest.java @@ -0,0 +1,141 @@ +package com.azure.android.communication.ui.calling.redux.reducer; + +import com.azure.android.communication.ui.calling.redux.action.AudioFocusAction; +import com.azure.android.communication.ui.calling.redux.state.AudioFocusState; +import com.azure.android.communication.ui.calling.redux.state.AudioFocusStatus; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class AudioFocusReducerTest { + + @Test + public void audioFocusReducer_whenRequestFocus_shouldSetStatusToRequesting() { + // Arrange + AudioFocusState initialState = new AudioFocusState(); + AudioFocusAction.RequestFocus action = new AudioFocusAction.RequestFocus(); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.REQUESTING, newState.getStatus()); + } + + @Test + public void audioFocusReducer_whenFocusGranted_shouldSetStatusToApproved() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.REQUESTING, null); + AudioFocusAction.FocusGranted action = new AudioFocusAction.FocusGranted(); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.APPROVED, newState.getStatus()); + } + + @Test + public void audioFocusReducer_whenFocusDenied_shouldSetStatusToRejected() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.REQUESTING, null); + String reason = "Test denial reason"; + AudioFocusAction.FocusDenied action = new AudioFocusAction.FocusDenied(reason); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.REJECTED, newState.getStatus()); + assertEquals(reason, newState.getLastError()); + } + + @Test + public void audioFocusReducer_whenFocusLostPermanently_shouldSetStatusToNone() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.APPROVED, null); + AudioFocusAction.FocusLost action = new AudioFocusAction.FocusLost(false); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.NONE, newState.getStatus()); + } + + @Test + public void audioFocusReducer_whenFocusLostTransient_shouldSetStatusToInterrupted() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.APPROVED, null); + AudioFocusAction.FocusLost action = new AudioFocusAction.FocusLost(true); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.INTERRUPTED, newState.getStatus()); + } + + @Test + public void audioFocusReducer_whenReleaseFocus_shouldSetStatusToNone() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.APPROVED, null); + AudioFocusAction.ReleaseFocus action = new AudioFocusAction.ReleaseFocus(); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.NONE, newState.getStatus()); + } + + @Test + public void audioFocusReducer_whenFocusInterrupted_shouldSetStatusToInterrupted() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.APPROVED, null); + String reason = "Test interruption reason"; + AudioFocusAction.FocusInterrupted action = new AudioFocusAction.FocusInterrupted(reason); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.INTERRUPTED, newState.getStatus()); + assertEquals(reason, newState.getLastError()); + } + + @Test + public void audioFocusReducer_whenFocusRestored_shouldSetStatusToApproved() { + // Arrange + AudioFocusState initialState = new AudioFocusState(AudioFocusStatus.INTERRUPTED, null); + AudioFocusAction.FocusRestored action = new AudioFocusAction.FocusRestored(); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(AudioFocusStatus.APPROVED, newState.getStatus()); + } + + @Test + public void audioFocusReducer_whenUnknownAction_shouldReturnSameState() { + // Arrange + AudioFocusState initialState = new AudioFocusState(); + Object unknownAction = new Object(); + + // Act + AudioFocusState newState = AudioFocusReducer.reduce(initialState, unknownAction); + + // Assert + assertEquals(initialState, newState); + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducerTest.java b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducerTest.java new file mode 100644 index 000000000..8a8aac5de --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/reducer/AudioModeReducerTest.java @@ -0,0 +1,118 @@ +package com.azure.android.communication.ui.calling.redux.reducer; + +import com.azure.android.communication.ui.calling.redux.action.AudioModeAction; +import com.azure.android.communication.ui.calling.redux.state.AudioMode; +import com.azure.android.communication.ui.calling.redux.state.AudioModeState; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class AudioModeReducerTest { + + @Test + public void audioModeReducer_whenSetModeRequested_shouldUpdateMode() { + // Arrange + AudioModeState initialState = new AudioModeState(); + AudioMode targetMode = AudioMode.IN_COMMUNICATION; + AudioModeAction.SetModeRequested action = new AudioModeAction.SetModeRequested(targetMode); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(targetMode, newState.getCurrentMode()); + assertEquals(AudioMode.NORMAL, newState.getPreviousMode()); + } + + @Test + public void audioModeReducer_whenModeChangeSucceeded_shouldUpdateMode() { + // Arrange + AudioModeState initialState = new AudioModeState(); + AudioMode targetMode = AudioMode.IN_CALL; + AudioModeAction.ModeChangeSucceeded action = new AudioModeAction.ModeChangeSucceeded(targetMode); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(targetMode, newState.getCurrentMode()); + } + + @Test + public void audioModeReducer_whenModeChangeFailed_shouldRevertToFallbackMode() { + // Arrange + AudioModeState initialState = new AudioModeState(AudioMode.IN_COMMUNICATION, AudioMode.NORMAL, null); + AudioMode targetMode = AudioMode.IN_CALL; + AudioMode fallbackMode = AudioMode.NORMAL; + String error = "Test error"; + AudioModeAction.ModeChangeFailed action = new AudioModeAction.ModeChangeFailed(targetMode, fallbackMode, error); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(fallbackMode, newState.getCurrentMode()); + assertEquals(error, newState.getLastError()); + } + + @Test + public void audioModeReducer_whenRestorePreviousModeRequested_shouldRestorePreviousMode() { + // Arrange + AudioMode previousMode = AudioMode.IN_CALL; + AudioModeState initialState = new AudioModeState(AudioMode.IN_COMMUNICATION, previousMode, null); + AudioModeAction.RestorePreviousModeRequested action = new AudioModeAction.RestorePreviousModeRequested(); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(previousMode, newState.getCurrentMode()); + } + + @Test + public void audioModeReducer_whenRestorePreviousModeRequestedWithNoPreviousMode_shouldKeepCurrentMode() { + // Arrange + AudioModeState initialState = new AudioModeState(); + AudioModeAction.RestorePreviousModeRequested action = new AudioModeAction.RestorePreviousModeRequested(); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(initialState.getCurrentMode(), newState.getCurrentMode()); + } + + @Test + public void audioModeReducer_whenSystemModeChanged_shouldUpdateMode() { + // Arrange + AudioModeState initialState = new AudioModeState(); + AudioMode systemMode = AudioMode.RINGTONE; + AudioModeAction.SystemModeChanged action = new AudioModeAction.SystemModeChanged(systemMode); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, action); + + // Assert + assertNotNull(newState); + assertEquals(systemMode, newState.getCurrentMode()); + } + + @Test + public void audioModeReducer_whenUnknownAction_shouldReturnSameState() { + // Arrange + AudioModeState initialState = new AudioModeState(); + Object unknownAction = new Object(); + + // Act + AudioModeState newState = AudioModeReducer.reduce(initialState, unknownAction); + + // Assert + assertEquals(initialState, newState); + } +}