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/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..e54fcfd88 --- /dev/null +++ b/azure-communication-ui/calling/src/test/java/com/azure/android/communication/ui/calling/redux/middleware/handler/CameraSwitchingAfterBackgroundTest.kt @@ -0,0 +1,302 @@ +// 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 io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.concurrent.CompletableFuture + +@RunWith(JUnit4::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 = mockk { + every { turnCameraOff() } returns cameraOffCompletableFuture + every { turnCameraOn() } returns cameraOnCompletableFuture + every { switchCamera() } returns cameraSwitchCompletableFuture + } + + val handler = CallingMiddlewareActionHandlerImpl( + mockCallingService, + UnconfinedTestContextProvider(), + CallCompositeConfiguration(), + CapabilitiesManager(CallType.GROUP_CALL) + ) + + val mockAppStore = mockk> { + every { getCurrentState() } returns appState + every { dispatch(any()) } returns Unit + } + + // 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(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LifecycleAction.EnterBackgroundSucceeded + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LocalParticipantAction.CameraPauseSucceeded + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LifecycleAction.EnterForegroundSucceeded + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LocalParticipantAction.CameraOnSucceeded && + action.videoStreamID == "videoStreamId" + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { 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 = mockk { + every { turnCameraOff() } returns cameraOffCompletableFuture + every { turnCameraOn() } returns cameraOnCompletableFuture + every { switchCamera() } returns cameraSwitchCompletableFuture + } + + val handler = CallingMiddlewareActionHandlerImpl( + mockCallingService, + UnconfinedTestContextProvider(), + CallCompositeConfiguration(), + CapabilitiesManager(CallType.GROUP_CALL) + ) + + val mockAppStore = mockk> { + every { getCurrentState() } returns appState + every { dispatch(any()) } returns Unit + } + + // 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(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LifecycleAction.EnterBackgroundSucceeded + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LocalParticipantAction.CameraPauseSucceeded + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LifecycleAction.EnterForegroundSucceeded + }) + } + + verify(exactly = 1) { + mockAppStore.dispatch(match { action -> + action is LocalParticipantAction.CameraOnSucceeded && + action.videoStreamID == "videoStreamId" + }) + } + + 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/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" + } + ) + } +}