From 4c7537b4cef37e334fa403231baabb139e535b3e Mon Sep 17 00:00:00 2001 From: Adam Hammer Date: Thu, 3 Apr 2025 12:40:15 -0700 Subject: [PATCH 1/3] test scaffolding --- .../handler/CallingMiddlewareActionHandler.kt | 17 +- .../middleware/handler/CameraSwitchingFix.kt | 109 ++++++ .../CameraSwitchingAfterBackgroundTest.kt | 306 ++++++++++++++++ .../handler/CameraSwitchingHandlerTest.kt | 339 ++++++++++++++++++ 4 files changed, 757 insertions(+), 14 deletions(-) create mode 100644 azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingFix.kt create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt create mode 100644 azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingHandlerTest.kt diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CallingMiddlewareActionHandler.kt b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CallingMiddlewareActionHandler.kt index 2879f604a..30f66642b 100644 --- a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CallingMiddlewareActionHandler.kt +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CallingMiddlewareActionHandler.kt @@ -387,20 +387,9 @@ internal class CallingMiddlewareActionHandlerImpl( } override fun switchCamera(store: Store) { - val currentCamera = store.getCurrentState().localParticipantState.cameraState.device - - callingService.switchCamera().handle { cameraDevice, error: Throwable? -> - if (error != null) { - store.dispatch( - LocalParticipantAction.CameraSwitchFailed( - currentCamera, - CallCompositeError(ErrorCode.SWITCH_CAMERA_FAILED, error) - ) - ) - } else { - store.dispatch(LocalParticipantAction.CameraSwitchSucceeded(cameraDevice)) - } - } + // Use the enhanced camera switching handler that properly handles + // camera switching after background/foreground transitions + CameraSwitchingHandler(callingService).switchCamera(store) } override fun turnMicOn(store: Store) { diff --git a/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingFix.kt b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingFix.kt new file mode 100644 index 000000000..aca84fdf2 --- /dev/null +++ b/azure-communication-ui/calling/src/main/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingFix.kt @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.android.communication.ui.calling.redux.middleware.handler + +import com.azure.android.communication.ui.calling.error.CallCompositeError +import com.azure.android.communication.ui.calling.error.ErrorCode +import com.azure.android.communication.ui.calling.redux.action.LocalParticipantAction +import com.azure.android.communication.ui.calling.redux.state.CameraDeviceSelectionStatus +import com.azure.android.communication.ui.calling.redux.state.CameraOperationalStatus +import com.azure.android.communication.ui.calling.redux.state.ReduxState +import com.azure.android.communication.ui.calling.redux.Store +import com.azure.android.communication.ui.calling.service.CallingService + +/** + * This class provides an enhanced implementation of the camera switching functionality + * that properly handles camera switching after background/foreground transitions. + */ +internal class CameraSwitchingHandler(private val callingService: CallingService) { + + /** + * Switches the camera with improved handling for background/foreground transitions. + * This method ensures that camera switching works properly even after the app + * has gone through a background/foreground cycle. + * + * @param store The Redux store + */ + fun switchCamera(store: Store) { + val state = store.getCurrentState() + val currentCamera = state.localParticipantState.cameraState.device + + // Only attempt to switch camera if it's currently ON + if (state.localParticipantState.cameraState.operation != CameraOperationalStatus.ON) { + store.dispatch( + LocalParticipantAction.CameraSwitchFailed( + currentCamera, + CallCompositeError( + ErrorCode.SWITCH_CAMERA_FAILED, + Exception("Cannot switch camera when camera is not on") + ) + ) + ) + return + } + + // Call the service to switch camera + callingService.switchCamera().handle { cameraDevice, error: Throwable? -> + if (error != null) { + // If switching fails, try to reinitialize the camera and try again + if (error.message?.contains("Camera not initialized") == true || + error.message?.contains("Camera error") == true) { + + // First turn camera off + callingService.turnCameraOff().handle { _, offError -> + if (offError != null) { + // If turning off fails, report the original error + store.dispatch( + LocalParticipantAction.CameraSwitchFailed( + currentCamera, + CallCompositeError(ErrorCode.SWITCH_CAMERA_FAILED, error) + ) + ) + } else { + // Then turn camera back on + callingService.turnCameraOn().handle { _, onError -> + if (onError != null) { + // If turning on fails, report the error + store.dispatch( + LocalParticipantAction.CameraSwitchFailed( + currentCamera, + CallCompositeError(ErrorCode.SWITCH_CAMERA_FAILED, onError) + ) + ) + } else { + // Now try switching again + callingService.switchCamera().handle { newCameraDevice, switchError -> + if (switchError != null) { + store.dispatch( + LocalParticipantAction.CameraSwitchFailed( + currentCamera, + CallCompositeError(ErrorCode.SWITCH_CAMERA_FAILED, switchError) + ) + ) + } else { + store.dispatch( + LocalParticipantAction.CameraSwitchSucceeded(newCameraDevice) + ) + } + } + } + } + } + } + } else { + // For other errors, just report the failure + store.dispatch( + LocalParticipantAction.CameraSwitchFailed( + currentCamera, + CallCompositeError(ErrorCode.SWITCH_CAMERA_FAILED, error) + ) + ) + } + } else { + // Success case + store.dispatch(LocalParticipantAction.CameraSwitchSucceeded(cameraDevice)) + } + } + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt new file mode 100644 index 000000000..b67c5827f --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.android.communication.ui.calling.redux.middleware.handler + +import com.azure.android.communication.ui.calling.ACSBaseTestCoroutine +import com.azure.android.communication.ui.calling.configuration.CallCompositeConfiguration +import com.azure.android.communication.ui.calling.configuration.CallType +import com.azure.android.communication.ui.calling.error.CallCompositeError +import com.azure.android.communication.ui.calling.error.ErrorCode +import com.azure.android.communication.ui.calling.helper.UnconfinedTestContextProvider +import com.azure.android.communication.ui.calling.presentation.manager.CapabilitiesManager +import com.azure.android.communication.ui.calling.redux.AppStore +import com.azure.android.communication.ui.calling.redux.action.LifecycleAction +import com.azure.android.communication.ui.calling.redux.action.LocalParticipantAction +import com.azure.android.communication.ui.calling.redux.state.AppReduxState +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceSelectionStatus +import com.azure.android.communication.ui.calling.redux.state.AudioOperationalStatus +import com.azure.android.communication.ui.calling.redux.state.AudioState +import com.azure.android.communication.ui.calling.redux.state.BluetoothState +import com.azure.android.communication.ui.calling.redux.state.CallingState +import com.azure.android.communication.ui.calling.redux.state.CallingStatus +import com.azure.android.communication.ui.calling.redux.state.CameraDeviceSelectionStatus +import com.azure.android.communication.ui.calling.redux.state.CameraOperationalStatus +import com.azure.android.communication.ui.calling.redux.state.CameraState +import com.azure.android.communication.ui.calling.redux.state.CameraTransmissionStatus +import com.azure.android.communication.ui.calling.redux.state.LocalUserState +import com.azure.android.communication.ui.calling.redux.state.NavigationState +import com.azure.android.communication.ui.calling.redux.state.NavigationStatus +import com.azure.android.communication.ui.calling.redux.state.ReduxState +import com.azure.android.communication.ui.calling.service.CallingService +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import java.util.concurrent.CompletableFuture + +@RunWith(MockitoJUnitRunner::class) +internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { + + @Test + fun callingMiddlewareActionHandler_switchCamera_after_background_foreground_transition_then_dispatch_CameraSwitchSucceeded() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + // Mock camera off future for enterBackground + val cameraOffCompletableFuture = CompletableFuture() + + // Mock camera on future for enterForeground + val cameraOnCompletableFuture = CompletableFuture() + + // Mock camera switch future + val cameraSwitchCompletableFuture = CompletableFuture() + + val mockCallingService: CallingService = mock { + on { turnCameraOff() } doReturn cameraOffCompletableFuture + on { turnCameraOn() } doReturn cameraOnCompletableFuture + on { switchCamera() } doReturn cameraSwitchCompletableFuture + } + + val handler = CallingMiddlewareActionHandlerImpl( + mockCallingService, + UnconfinedTestContextProvider(), + CallCompositeConfiguration(), + CapabilitiesManager(CallType.GROUP_CALL) + ) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act - simulate background transition + handler.enterBackground(mockAppStore) + cameraOffCompletableFuture.complete(null) + + // Update app state to reflect camera paused + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.PAUSED, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + + // act - simulate foreground transition + handler.enterForeground(mockAppStore) + cameraOnCompletableFuture.complete("videoStreamId") + + // Update app state to reflect camera on + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + + // act - try to switch camera + handler.switchCamera(mockAppStore) + cameraSwitchCompletableFuture.complete(CameraDeviceSelectionStatus.BACK) + + // assert + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LifecycleAction.EnterBackgroundSucceeded + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraPauseSucceeded + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LifecycleAction.EnterForegroundSucceeded + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraOnSucceeded && + action.videoStreamID == "videoStreamId" + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchSucceeded && + action.cameraDeviceSelectionStatus == CameraDeviceSelectionStatus.BACK + } + ) + } + + @Test + fun callingMiddlewareActionHandler_switchCamera_after_background_foreground_transition_then_dispatch_CameraSwitchFailed() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + // Mock camera off future for enterBackground + val cameraOffCompletableFuture = CompletableFuture() + + // Mock camera on future for enterForeground + val cameraOnCompletableFuture = CompletableFuture() + + // Mock camera switch future + val cameraSwitchCompletableFuture = CompletableFuture() + val error = Exception("Camera switch failed") + + val mockCallingService: CallingService = mock { + on { turnCameraOff() } doReturn cameraOffCompletableFuture + on { turnCameraOn() } doReturn cameraOnCompletableFuture + on { switchCamera() } doReturn cameraSwitchCompletableFuture + } + + val handler = CallingMiddlewareActionHandlerImpl( + mockCallingService, + UnconfinedTestContextProvider(), + CallCompositeConfiguration(), + CapabilitiesManager(CallType.GROUP_CALL) + ) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act - simulate background transition + handler.enterBackground(mockAppStore) + cameraOffCompletableFuture.complete(null) + + // Update app state to reflect camera paused + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.PAUSED, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + + // act - simulate foreground transition + handler.enterForeground(mockAppStore) + cameraOnCompletableFuture.complete("videoStreamId") + + // Update app state to reflect camera on + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + + // act - try to switch camera + handler.switchCamera(mockAppStore) + cameraSwitchCompletableFuture.completeExceptionally(error) + + // assert + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LifecycleAction.EnterBackgroundSucceeded + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraPauseSucceeded + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LifecycleAction.EnterForegroundSucceeded + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraOnSucceeded && + action.videoStreamID == "videoStreamId" + } + ) + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchFailed && + action.error.cause == error && + action.error.errorCode == ErrorCode.SWITCH_CAMERA_FAILED + } + ) + } +} diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingHandlerTest.kt b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingHandlerTest.kt new file mode 100644 index 000000000..b1fb811b0 --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingHandlerTest.kt @@ -0,0 +1,339 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.android.communication.ui.calling.redux.middleware.handler + +import com.azure.android.communication.ui.calling.ACSBaseTestCoroutine +import com.azure.android.communication.ui.calling.error.CallCompositeError +import com.azure.android.communication.ui.calling.error.ErrorCode +import com.azure.android.communication.ui.calling.redux.AppStore +import com.azure.android.communication.ui.calling.redux.action.LocalParticipantAction +import com.azure.android.communication.ui.calling.redux.state.AppReduxState +import com.azure.android.communication.ui.calling.redux.state.AudioDeviceSelectionStatus +import com.azure.android.communication.ui.calling.redux.state.AudioOperationalStatus +import com.azure.android.communication.ui.calling.redux.state.AudioState +import com.azure.android.communication.ui.calling.redux.state.BluetoothState +import com.azure.android.communication.ui.calling.redux.state.CallingState +import com.azure.android.communication.ui.calling.redux.state.CallingStatus +import com.azure.android.communication.ui.calling.redux.state.CameraDeviceSelectionStatus +import com.azure.android.communication.ui.calling.redux.state.CameraOperationalStatus +import com.azure.android.communication.ui.calling.redux.state.CameraState +import com.azure.android.communication.ui.calling.redux.state.CameraTransmissionStatus +import com.azure.android.communication.ui.calling.redux.state.LocalUserState +import com.azure.android.communication.ui.calling.redux.state.NavigationState +import com.azure.android.communication.ui.calling.redux.state.NavigationStatus +import com.azure.android.communication.ui.calling.redux.state.ReduxState +import com.azure.android.communication.ui.calling.service.CallingService +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import java.util.concurrent.CompletableFuture + +@RunWith(MockitoJUnitRunner::class) +internal class CameraSwitchingHandlerTest : ACSBaseTestCoroutine() { + + @Test + fun cameraSwitchingHandler_switchCamera_success_then_dispatch_CameraSwitchSucceeded() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + // Mock camera switch future + val cameraSwitchCompletableFuture = CompletableFuture() + + val mockCallingService: CallingService = mock { + on { switchCamera() } doReturn cameraSwitchCompletableFuture + } + + val handler = CameraSwitchingHandler(mockCallingService) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act + handler.switchCamera(mockAppStore) + cameraSwitchCompletableFuture.complete(CameraDeviceSelectionStatus.BACK) + + // assert + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchSucceeded && + action.cameraDeviceSelectionStatus == CameraDeviceSelectionStatus.BACK + } + ) + } + + @Test + fun cameraSwitchingHandler_switchCamera_fails_with_camera_not_initialized_then_retry_and_succeed() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + // Mock camera switch future that fails with "Camera not initialized" error + val cameraSwitchCompletableFuture = CompletableFuture() + val error = Exception("Camera not initialized") + + // Mock camera off future + val cameraOffCompletableFuture = CompletableFuture() + + // Mock camera on future + val cameraOnCompletableFuture = CompletableFuture() + + // Mock second camera switch future that succeeds + val cameraSwitchRetryCompletableFuture = CompletableFuture() + + val mockCallingService: CallingService = mock { + on { switchCamera() } doReturn cameraSwitchCompletableFuture doReturn cameraSwitchRetryCompletableFuture + on { turnCameraOff() } doReturn cameraOffCompletableFuture + on { turnCameraOn() } doReturn cameraOnCompletableFuture + } + + val handler = CameraSwitchingHandler(mockCallingService) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act - first attempt fails + handler.switchCamera(mockAppStore) + cameraSwitchCompletableFuture.completeExceptionally(error) + + // camera off succeeds + cameraOffCompletableFuture.complete(null) + + // camera on succeeds + cameraOnCompletableFuture.complete("videoStreamId") + + // second attempt succeeds + cameraSwitchRetryCompletableFuture.complete(CameraDeviceSelectionStatus.BACK) + + // assert + verify(mockCallingService, times(1)).turnCameraOff() + verify(mockCallingService, times(1)).turnCameraOn() + verify(mockCallingService, times(2)).switchCamera() + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchSucceeded && + action.cameraDeviceSelectionStatus == CameraDeviceSelectionStatus.BACK + } + ) + } + + @Test + fun cameraSwitchingHandler_switchCamera_fails_with_camera_error_then_retry_and_succeed() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + // Mock camera switch future that fails with "Camera error" error + val cameraSwitchCompletableFuture = CompletableFuture() + val error = Exception("Camera error") + + // Mock camera off future + val cameraOffCompletableFuture = CompletableFuture() + + // Mock camera on future + val cameraOnCompletableFuture = CompletableFuture() + + // Mock second camera switch future that succeeds + val cameraSwitchRetryCompletableFuture = CompletableFuture() + + val mockCallingService: CallingService = mock { + on { switchCamera() } doReturn cameraSwitchCompletableFuture doReturn cameraSwitchRetryCompletableFuture + on { turnCameraOff() } doReturn cameraOffCompletableFuture + on { turnCameraOn() } doReturn cameraOnCompletableFuture + } + + val handler = CameraSwitchingHandler(mockCallingService) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act - first attempt fails + handler.switchCamera(mockAppStore) + cameraSwitchCompletableFuture.completeExceptionally(error) + + // camera off succeeds + cameraOffCompletableFuture.complete(null) + + // camera on succeeds + cameraOnCompletableFuture.complete("videoStreamId") + + // second attempt succeeds + cameraSwitchRetryCompletableFuture.complete(CameraDeviceSelectionStatus.BACK) + + // assert + verify(mockCallingService, times(1)).turnCameraOff() + verify(mockCallingService, times(1)).turnCameraOn() + verify(mockCallingService, times(2)).switchCamera() + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchSucceeded && + action.cameraDeviceSelectionStatus == CameraDeviceSelectionStatus.BACK + } + ) + } + + @Test + fun cameraSwitchingHandler_switchCamera_fails_with_other_error_then_dispatch_CameraSwitchFailed() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.ON, + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + // Mock camera switch future that fails with a different error + val cameraSwitchCompletableFuture = CompletableFuture() + val error = Exception("Some other error") + + val mockCallingService: CallingService = mock { + on { switchCamera() } doReturn cameraSwitchCompletableFuture + } + + val handler = CameraSwitchingHandler(mockCallingService) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act + handler.switchCamera(mockAppStore) + cameraSwitchCompletableFuture.completeExceptionally(error) + + // assert + verify(mockCallingService, times(0)).turnCameraOff() + verify(mockCallingService, times(0)).turnCameraOn() + verify(mockCallingService, times(1)).switchCamera() + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchFailed && + action.error.cause == error && + action.error.errorCode == ErrorCode.SWITCH_CAMERA_FAILED + } + ) + } + + @Test + fun cameraSwitchingHandler_switchCamera_when_camera_is_not_on_then_dispatch_CameraSwitchFailed() { + // arrange + val appState = AppReduxState("", false, false) + appState.localParticipantState = LocalUserState( + CameraState( + CameraOperationalStatus.OFF, // Camera is OFF + CameraDeviceSelectionStatus.FRONT, + CameraTransmissionStatus.REMOTE + ), + AudioState( + AudioOperationalStatus.OFF, + AudioDeviceSelectionStatus.SPEAKER_SELECTED, + BluetoothState(available = false, deviceName = "bluetooth") + ), + videoStreamID = "videoStreamId", + displayName = "username", + localParticipantRole = null + ) + appState.navigationState = NavigationState(NavigationStatus.IN_CALL) + appState.callState = CallingState(CallingStatus.CONNECTED) + + val mockCallingService: CallingService = mock() + + val handler = CameraSwitchingHandler(mockCallingService) + + val mockAppStore = mock> { + on { getCurrentState() } doReturn appState + on { dispatch(any()) } doAnswer { } + } + + // act + handler.switchCamera(mockAppStore) + + // assert + verify(mockCallingService, times(0)).switchCamera() + + verify(mockAppStore, times(1)).dispatch( + argThat { action -> + action is LocalParticipantAction.CameraSwitchFailed && + action.error.errorCode == ErrorCode.SWITCH_CAMERA_FAILED && + action.error.cause?.message == "Cannot switch camera when camera is not on" + } + ) + } +} From 7c55b5339ee6feb0d0840c806601cdab4d21d230 Mon Sep 17 00:00:00 2001 From: Adam Hammer Date: Thu, 3 Apr 2025 13:09:13 -0700 Subject: [PATCH 2/3] adding mockk --- azure-communication-ui/build.gradle | 1 + azure-communication-ui/calling/build.gradle | 1 + .../CameraSwitchingAfterBackgroundTest.kt | 122 +++++++++--------- azure-communication-ui/chat/build.gradle | 1 + 4 files changed, 62 insertions(+), 63 deletions(-) diff --git a/azure-communication-ui/build.gradle b/azure-communication-ui/build.gradle index 1d61bb666..920870315 100644 --- a/azure-communication-ui/build.gradle +++ b/azure-communication-ui/build.gradle @@ -81,6 +81,7 @@ buildscript { mockito_inline_version = '4.3.1' mockito_kotlin_version = '4.0.0' + mockk_version = '1.13.17' shouldNotCheckTaskRoot = { return rootProject.hasProperty("disableTaskRootCheck") && rootProject.getProperty("disableTaskRootCheck") diff --git a/azure-communication-ui/calling/build.gradle b/azure-communication-ui/calling/build.gradle index 6c2911dd3..be4a8035d 100644 --- a/azure-communication-ui/calling/build.gradle +++ b/azure-communication-ui/calling/build.gradle @@ -109,6 +109,7 @@ dependencies { testImplementation "junit:junit:$junit_version" testImplementation "org.mockito:mockito-inline:$mockito_inline_version" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockito_kotlin_version" + testImplementation "io.mockk:mockk:$mockk_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$jetbrains_kotlinx_coroutines_test_version" testImplementation('org.threeten:threetenbp:1.6.5') { exclude group: 'com.jakewharton.threetenabp', module: 'threetenabp' diff --git a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt index b67c5827f..e54fcfd88 100644 --- a/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt @@ -29,19 +29,15 @@ import com.azure.android.communication.ui.calling.redux.state.NavigationState import com.azure.android.communication.ui.calling.redux.state.NavigationStatus import com.azure.android.communication.ui.calling.redux.state.ReduxState import com.azure.android.communication.ui.calling.service.CallingService +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import org.junit.Test import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any -import org.mockito.kotlin.argThat -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify +import org.junit.runners.JUnit4 import java.util.concurrent.CompletableFuture -@RunWith(MockitoJUnitRunner::class) +@RunWith(JUnit4::class) internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { @Test @@ -75,10 +71,10 @@ internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { // Mock camera switch future val cameraSwitchCompletableFuture = CompletableFuture() - val mockCallingService: CallingService = mock { - on { turnCameraOff() } doReturn cameraOffCompletableFuture - on { turnCameraOn() } doReturn cameraOnCompletableFuture - on { switchCamera() } doReturn cameraSwitchCompletableFuture + val mockCallingService = mockk { + every { turnCameraOff() } returns cameraOffCompletableFuture + every { turnCameraOn() } returns cameraOnCompletableFuture + every { switchCamera() } returns cameraSwitchCompletableFuture } val handler = CallingMiddlewareActionHandlerImpl( @@ -88,9 +84,9 @@ internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { CapabilitiesManager(CallType.GROUP_CALL) ) - val mockAppStore = mock> { - on { getCurrentState() } doReturn appState - on { dispatch(any()) } doAnswer { } + val mockAppStore = mockk> { + every { getCurrentState() } returns appState + every { dispatch(any()) } returns Unit } // act - simulate background transition @@ -140,37 +136,37 @@ internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { cameraSwitchCompletableFuture.complete(CameraDeviceSelectionStatus.BACK) // assert - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LifecycleAction.EnterBackgroundSucceeded - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LocalParticipantAction.CameraPauseSucceeded - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LifecycleAction.EnterForegroundSucceeded - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LocalParticipantAction.CameraOnSucceeded && action.videoStreamID == "videoStreamId" - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LocalParticipantAction.CameraSwitchSucceeded && action.cameraDeviceSelectionStatus == CameraDeviceSelectionStatus.BACK - } - ) + }) + } } @Test @@ -205,10 +201,10 @@ internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { val cameraSwitchCompletableFuture = CompletableFuture() val error = Exception("Camera switch failed") - val mockCallingService: CallingService = mock { - on { turnCameraOff() } doReturn cameraOffCompletableFuture - on { turnCameraOn() } doReturn cameraOnCompletableFuture - on { switchCamera() } doReturn cameraSwitchCompletableFuture + val mockCallingService = mockk { + every { turnCameraOff() } returns cameraOffCompletableFuture + every { turnCameraOn() } returns cameraOnCompletableFuture + every { switchCamera() } returns cameraSwitchCompletableFuture } val handler = CallingMiddlewareActionHandlerImpl( @@ -218,9 +214,9 @@ internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { CapabilitiesManager(CallType.GROUP_CALL) ) - val mockAppStore = mock> { - on { getCurrentState() } doReturn appState - on { dispatch(any()) } doAnswer { } + val mockAppStore = mockk> { + every { getCurrentState() } returns appState + every { dispatch(any()) } returns Unit } // act - simulate background transition @@ -270,37 +266,37 @@ internal class CameraSwitchingAfterBackgroundTest : ACSBaseTestCoroutine() { cameraSwitchCompletableFuture.completeExceptionally(error) // assert - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LifecycleAction.EnterBackgroundSucceeded - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LocalParticipantAction.CameraPauseSucceeded - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LifecycleAction.EnterForegroundSucceeded - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LocalParticipantAction.CameraOnSucceeded && action.videoStreamID == "videoStreamId" - } - ) + }) + } - verify(mockAppStore, times(1)).dispatch( - argThat { action -> + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> action is LocalParticipantAction.CameraSwitchFailed && action.error.cause == error && action.error.errorCode == ErrorCode.SWITCH_CAMERA_FAILED - } - ) + }) + } } } diff --git a/azure-communication-ui/chat/build.gradle b/azure-communication-ui/chat/build.gradle index 9c96d1c73..ddb568ce5 100644 --- a/azure-communication-ui/chat/build.gradle +++ b/azure-communication-ui/chat/build.gradle @@ -113,6 +113,7 @@ dependencies { testImplementation "junit:junit:$junit_version" testImplementation "org.mockito:mockito-inline:$mockito_inline_version" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockito_kotlin_version" + testImplementation "io.mockk:mockk:$mockk_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$jetbrains_kotlinx_coroutines_test_version" testImplementation group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-test', version: '1.6.4', ext: 'pom' From 229391d86377cba4b655eaebfd175bf7c63a3e29 Mon Sep 17 00:00:00 2001 From: Adam Hammer Date: Thu, 3 Apr 2025 13:10:38 -0700 Subject: [PATCH 3/3] Remove mockk dependency from build.gradle --- azure-communication-ui/chat/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/azure-communication-ui/chat/build.gradle b/azure-communication-ui/chat/build.gradle index ddb568ce5..9c96d1c73 100644 --- a/azure-communication-ui/chat/build.gradle +++ b/azure-communication-ui/chat/build.gradle @@ -113,7 +113,6 @@ dependencies { testImplementation "junit:junit:$junit_version" testImplementation "org.mockito:mockito-inline:$mockito_inline_version" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockito_kotlin_version" - testImplementation "io.mockk:mockk:$mockk_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$jetbrains_kotlinx_coroutines_test_version" testImplementation group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-test', version: '1.6.4', ext: 'pom'