From 1779ed727453b4f78b8d315bf583d7d33f784284 Mon Sep 17 00:00:00 2001 From: Patrick Latter <73612854+palatter@users.noreply.github.com> Date: Fri, 10 Dec 2021 10:38:28 -0800 Subject: [PATCH] [Release] 1.0.0-beta.1 (#4) * Initial Public Preview Features * GitHub folder and podspec (#5) * Initial Public Preview Features * Github folder and podspec * Podspec --- .github/CODEOWNERS | 1 + .github/CODE_OF_CONDUCT.md | 9 + .github/ISSUE_TEMPLATE.md | 33 + .github/PULL_REQUEST_TEMPLATE.md | 45 + .gitignore | 8 + AzureCommunicationUI.podspec | 25 + AzureCommunicationUI/.swiftlint.yml | 201 ++ .../project.pbxproj | 1726 +++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcschemes/AzureCommunicationUI.xcscheme | 106 + .../contents.xcworkspacedata | 13 + .../xcshareddata/IDETemplateMacros.plist | 11 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../ACSPrimaryColor.colorset/Contents.json | 38 + .../Assets.xcassets/Color/Contents.json | 9 + .../backgroundColor.colorset/Contents.json | 38 + .../disabledColor.colorset/Contents.json | 38 + .../Color/drawerColor.colorset/Contents.json | 38 + .../Color/errorColor.colorset/Contents.json | 38 + .../gradientColor.colorset/Contents.json | 38 + .../Contents.json | 38 + .../Color/hangupColor.colorset/Contents.json | 38 + .../Color/mute.colorset/Contents.json | 56 + .../onBackgroundColor.colorset/Contents.json | 38 + .../onDisabledColor.colorset/Contents.json | 38 + .../Color/onErrorColor.colorset/Contents.json | 38 + .../onPrimaryColor.colorset/Contents.json | 38 + .../onSuccessColor.colorset/Contents.json | 38 + .../onSurfaceColor.colorset/Contents.json | 38 + .../onWarningColor.colorset/Contents.json | 38 + .../Color/overlayColor.colorset/Contents.json | 38 + .../Color/popoverColor.colorset/Contents.json | 38 + .../Color/successColor.colorset/Contents.json | 38 + .../Color/surfaceColor.colorset/Contents.json | 38 + .../surfaceDarkColor.colorset/Contents.json | 38 + .../surfaceLightColor.colorset/Contents.json | 38 + .../Color/warningColor.colorset/Contents.json | 38 + .../Assets.xcassets/Contents.json | 6 + .../Assets.xcassets/Icon/Contents.json | 9 + .../Contents.json | 16 + .../ic_fluent_call_end_24_filled.pdf | Bin 0 -> 1486 bytes .../Contents.json | 16 + .../ic_fluent_camera_switch_24_regular.pdf | Bin 0 -> 4842 bytes .../Contents.json | 16 + .../ic_fluent_clock_24_filled.pdf | Bin 0 -> 1430 bytes .../Contents.json | 16 + .../ic_fluent_dismiss_16_regular.pdf | Bin 0 -> 1554 bytes .../Contents.json | 16 + .../ic_fluent_meet_now_20_regular.pdf | Bin 0 -> 5752 bytes .../Contents.json | 16 + .../ic_fluent_mic_off_24_filled.pdf | Bin 0 -> 2715 bytes .../Contents.json | 16 + .../ic_fluent_mic_on_24_filled.pdf | Bin 0 -> 1941 bytes .../Contents.json | 16 + .../ic_fluent_people_24_regular.pdf | Bin 0 -> 3534 bytes .../Contents.json | 16 + .../ic_fluent_speaker_2_24_filled.pdf | Bin 0 -> 2280 bytes .../Contents.json | 16 + .../ic_fluent_speaker_2_24_regular.pdf | Bin 0 -> 2853 bytes .../Contents.json | 16 + .../ic_fluent_video_24_filled.pdf | Bin 0 -> 1460 bytes .../Contents.json | 16 + .../ic_fluent_video_off_24_filled.pdf | Bin 0 -> 1932 bytes .../Contents.json | 16 + .../ic_fluent_warning_24_filled.pdf | Bin 0 -> 1906 bytes .../Contents.json | 16 + .../ic_ios_arrow_left_24_outlined.pdf | Bin 0 -> 4019 bytes .../AzureCommunicationUI/CallComposite.swift | 139 ++ .../CallCompositeEventsHandler.swift | 11 + .../CallCompositeOptions.swift | 17 + .../CallConfiguration.swift | 43 + .../DiagnosticConfig.swift | 22 + .../CallCompositeOptions/ErrorEvent.swift | 36 + .../GroupCallOptions.swift | 28 + .../TeamsMeetingOptions.swift | 28 + .../ThemeConfiguration.swift | 17 + .../DI/DependancyContainer.swift | 68 + .../AzureCommunicationUI/Info.plist | 26 + .../Logger/DefaultLogger.swift | 46 + .../AzureCommunicationUI/Logger/Logger.swift | 27 + .../Model/CallInfoModel.swift | 9 + .../Model/ParticipantInfoModel.swift | 21 + .../Model/VideoStreamInfoModel.swift | 15 + .../Factories/CompositeViewFactory.swift | 34 + .../Factories/CompositeViewModelFactory.swift | 180 ++ .../Presentation/FluentUI/Icon.swift | 17 + .../FluentUI/Wrapper/CompositeAvatar.swift | 21 + .../FluentUI/Wrapper/CompositeButton.swift | 35 + .../Wrapper/CompositeParticipantsList.swift | 56 + .../CompositeParticipantsListCell.swift | 30 + .../FluentUI/Wrapper/CompositePopupMenu.swift | 46 + .../DrawerContainerViewController.swift | 75 + .../ParticipantsListViewController.swift | 107 + .../Wrapper/PopupMenuViewController.swift | 52 + .../Manager/AppLifeCycleManager.swift | 46 + .../Manager/AudioDeviceType.swift | 25 + .../Manager/AudioSessionManager.swift | 97 + .../Manager/CompositeErrorManager.swift | 64 + .../Manager/PermissionsManager.swift | 152 ++ .../Navigation/NavigationRouter.swift | 60 + .../Style/ColorThemeProvider.swift | 71 + .../Presentation/Style/IconProvider.swift | 38 + .../Presentation/Style/StyleProvider.swift | 12 + .../SwiftUI/Calling/CallingView.swift | 148 ++ .../Banner/BannerInfoType.swift | 86 + .../Banner/BannerTextView.swift | 25 + .../Banner/BannerTextViewModel.swift | 29 + .../Banner/BannerView.swift | 30 + .../Banner/BannerViewModel.swift | 102 + .../ConfirmLeaveOverlayView.swift | 47 + .../CallingViewComponent/ControlBarView.swift | 68 + .../ControlBarViewModel.swift | 138 ++ .../CallingViewComponent/InfoHeaderView.swift | 57 + .../InfoHeaderViewModel.swift | 93 + .../LobbyOverlayView.swift | 28 + .../ParticipantsListCellViewModel.swift | 30 + .../ParticipantsListViewModel.swift | 41 + .../SwiftUI/Calling/CallingViewModel.swift | 128 ++ .../Cell/ParticipantGridCellVideoView.swift | 42 + .../Grid/Cell/ParticipantGridCellView.swift | 99 + .../Cell/ParticipantGridCellViewModel.swift | 53 + .../Grid/ParticipantGridLayoutView.swift | 65 + .../Calling/Grid/ParticipantGridView.swift | 47 + .../Grid/ParticipantGridViewModel.swift | 136 ++ .../ContainerUIHostingController.swift | 100 + .../SwiftUI/Container/ContainerView.swift | 36 + .../SwiftUI/Setup/SetupView.swift | 82 + .../SetupViewComponent/PreviewAreaView.swift | 71 + .../PreviewAreaViewModel.swift | 67 + .../SetupControlBarView.swift | 75 + .../SetupControlBarViewModel.swift | 147 ++ .../SwiftUI/Setup/SetupViewModel.swift | 80 + .../EnvironmentValuesExtension.swift | 28 + .../SwiftUI/Utilities/PreferenceKey.swift | 27 + .../SwiftUI/Utilities/UIFontExtension.swift | 13 + .../SwiftUI/Utilities/ViewExtension.swift | 26 + .../ViewComponents/Button/IconButton.swift | 146 ++ .../Button/IconButtonViewModel.swift | 45 + .../Button/IconWithLabelButton.swift | 42 + .../Button/IconWithLabelButtonViewModel.swift | 53 + .../ViewComponents/Button/PrimaryButton.swift | 21 + .../Button/PrimaryButtonViewModel.swift | 34 + .../Drawer/AudioDeviceListViewModel.swift | 67 + .../Drawer/PopupMenuViewModel.swift | 23 + .../Drawer/SourceViewSpace.swift | 19 + .../ViewComponents/Error/ErrorInfoView.swift | 37 + .../Error/ErrorInfoViewModel.swift | 32 + .../ViewComponents/LockPhoneOrientation.swift | 26 + .../ViewComponents/PopupModalView.swift | 33 + .../VideoView/LocalVideoView.swift | 119 ++ .../VideoView/LocalVideoViewModel.swift | 65 + .../VideoView/VideoRenderView.swift | 20 + .../Presentation/VideoViewManager.swift | 140 ++ .../Redux/Action/Action.swift | 9 + .../Redux/Action/CallingAction.swift | 49 + .../Redux/Action/LifecycleAction.swift | 13 + .../Redux/Action/LocalUserAction.swift | 64 + .../Redux/Action/PermissionAction.swift | 46 + .../Redux/Middleware/CallingMiddleware.swift | 52 + .../CallingMiddlewareErrorHandler.swift | 13 + .../Middleware/CallingMiddlewareHandler.swift | 304 +++ .../Redux/Middleware/Middleware.swift | 13 + .../Redux/Reducer/AppStateReducer.swift | 81 + .../Redux/Reducer/CallingReducer.swift | 31 + .../Redux/Reducer/ErrorReducer.swift | 35 + .../Redux/Reducer/LifeCycleReducer.swift | 24 + .../Redux/Reducer/LocalUserReducer.swift | 82 + .../Redux/Reducer/NavigationReducer.swift | 27 + .../Redux/Reducer/PermissionReducer.swift | 38 + .../Redux/Reducer/Reducer.swift | 11 + .../Redux/State/AppLifeCycleState.swift | 20 + .../Redux/State/AppState.swift | 33 + .../Redux/State/CallingState.swift | 39 + .../Redux/State/ErrorState.swift | 30 + .../Redux/State/LocalUserState.swift | 137 ++ .../Redux/State/NavigationState.swift | 25 + .../Redux/State/PermissionState.swift | 31 + .../Redux/State/ReduxState.swift | 9 + .../Redux/State/RemoteParticipantsState.swift | 17 + .../AzureCommunicationUI/Redux/Store.swift | 45 + .../Calling/CallingSDKEventsHandler.swift | 194 ++ .../Service/Calling/CallingSDKWrapper.swift | 378 ++++ .../Service/Calling/CallingService.swift | 90 + .../Extension/ACSCallingStateExtension.swift | 36 + .../Extension/ACSCameraFacingExtension.swift | 20 + .../CommunicationIdentifierExtension.swift | 24 + .../RemoteParticipantExtension.swift | 42 + .../RemoteParticipantsEventsAdapter.swift | 27 + .../Utilities/ArrayExtension.swift | 23 + .../Utilities/CancelBag.swift | 32 + .../Utilities/ColorExtension.swift | 51 + .../Utilities/CompsiteError.swift | 14 + .../Utilities/DeviceExtension.swift | 25 + .../Utilities/MappedSequence.swift | 134 ++ .../Utilities/UIViewControllerExtension.swift | 17 + .../Utilities/UIWindowExtension.swift | 57 + .../project.pbxproj | 502 +++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../AzureCommunicationUIDemoApp.xcscheme | 78 + .../AppConfig.xcconfig | 7 + .../AppDelegate.swift | 30 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 98 + .../Assets.xcassets/Contents.json | 6 + .../AuthenticationHelper.swift | 32 + .../DemoInputTypes.swift | 28 + .../EnvConfig.swift | 23 + .../AzureCommunicationUIDemoApp/Info.plist | 43 + .../AzureCommunicationUIDemoApp/README.md | 38 + .../SceneDelegate.swift | 47 + .../ThemeConfig.swift | 19 + .../Views/Base.lproj/LaunchScreen.storyboard | 32 + .../Views/Base.lproj/Main.storyboard | 31 + .../Views/CustomControls.swift | 25 + .../Views/EntryViewController.swift | 82 + .../Views/SwiftUIDemoView.swift | 209 ++ .../Views/UIKitDemoViewController.swift | 421 ++++ .../ActionMocking.swift | 10 + .../BannerTextViewModelMocking.swift | 22 + .../Builder/ParticipantInfoModelBuilder.swift | 46 + .../CallCompositeMocking.swift | 11 + .../DiagnosticConfigTests.swift | 42 + .../GroupCallOptionsTests.swift | 24 + .../TeamsMeetingOptionsTests.swift | 24 + .../CallingMiddlewareHandlerMocking.swift | 75 + .../CompositeViewModelFactoryMocking.swift | 121 ++ .../DI/DependencyContainerTests.swift | 37 + .../ErrorMocking.swift | 10 + .../AzureCommunicationUITests/Info.plist | 22 + .../Manager/CompositeErrorManagerTests.swift | 110 ++ .../Mocking/CallingSDKWrapperMocking.swift | 127 ++ .../Mocking/CallingServiceMocking.swift | 124 ++ .../Mocking/LoggerMocking.swift | 37 + .../Mocking/MiddlewareMocking.swift | 24 + .../Mocking/PermissionsManagerMocking.swift | 32 + .../Mocking/ReducerMocking.swift | 25 + .../Mocking/StateMocking.swift | 10 + .../Mocking/StoreFactoryMocking.swift | 39 + .../Mocking/VideoViewManagerMocking.swift | 12 + .../Calling/BannerInfoTypeTests.swift | 114 ++ .../Calling/BannerTextViewModelTests.swift | 55 + .../Calling/BannerViewModelTests.swift | 324 ++++ .../Calling/CallingViewModelTests.swift | 138 ++ .../Calling/ControlBarViewModelTests.swift | 386 ++++ .../Calling/InfoHeaderViewModelTests.swift | 178 ++ .../ParticipantCellViewModelTests.swift | 282 +++ .../ParticipantGridsViewModelTests.swift | 303 +++ .../ParticipantsListViewModelTests.swift | 207 ++ .../ContainerViewModelTests.swift | 33 + .../CompositeViewModelFactoryTests.swift | 45 + .../Setup/PreviewAreaViewModelTests.swift | 123 ++ .../Setup/SetupControlBarViewModelTests.swift | 164 ++ .../Setup/SetupViewModelTests.swift | 92 + .../AudioDeviceListViewModelTests.swift | 79 + .../LocalVideoViewModelTests.swift | 58 + .../CallingMiddlewareHandlerTests.swift | 458 +++++ .../Middleware/CallingMiddlewareTests.swift | 141 ++ .../Redux/Reducer/AppStateReducerTests.swift | 211 ++ .../Redux/Reducer/CallingReducerTests.swift | 124 ++ .../Redux/Reducer/ErrorReducerTests.swift | 47 + .../Redux/Reducer/LifeCycleReducerTests.swift | 65 + .../Redux/Reducer/LocalUserReducerTests.swift | 203 ++ .../Reducer/NavigationReducerTests.swift | 76 + .../Reducer/PermissionReducerTests.swift | 98 + .../Service/CallingServiceTests.swift | 95 + .../Service/NavigationRouterTests.swift | 64 + AzureCommunicationUI/Podfile | 42 + AzureCommunicationUI/Podfile.lock | 61 + CHANGELOG.md | 6 + CONTRIBUTING.md | 50 + README.md | 97 +- docs/api/CallComposite/Reference.md | 20 + .../CallComposite/classes/CallComposite.md | 54 + .../protocols/ThemeConfiguration.md | 21 + .../structs/CallCompositeErrorCode.md | 35 + .../structs/CallCompositeOptions.md | 29 + docs/api/CallComposite/structs/ErrorEvent.md | 26 + .../CallComposite/structs/GroupCallOptions.md | 61 + .../structs/TeamsMeetingOptions.md | 61 + docs/contributing-guide.md | 61 + docs/images/EnvConfig.png | Bin 0 -> 73472 bytes docs/images/SelectSimulator.png | Bin 0 -> 23558 bytes docs/images/mobile-ui-library-hero-image.png | Bin 0 -> 176397 bytes docs/manual-installation.md | 25 + 286 files changed, 18269 insertions(+), 24 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 AzureCommunicationUI.podspec create mode 100644 AzureCommunicationUI/.swiftlint.yml create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.pbxproj create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUI.xcscheme create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcworkspace/contents.xcworkspacedata create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDETemplateMacros.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/ACSPrimaryColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/backgroundColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/disabledColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/drawerColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/errorColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gradientColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gridLayoutBackgroundColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/hangupColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/mute.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onBackgroundColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onDisabledColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onErrorColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onPrimaryColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSuccessColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSurfaceColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onWarningColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/overlayColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/popoverColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/successColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceDarkColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceLightColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/warningColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/ic_fluent_call_end_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_camera_switch_24_regular.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_camera_switch_24_regular.imageset/ic_fluent_camera_switch_24_regular.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/ic_fluent_clock_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/ic_fluent_dismiss_16_regular.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_meet_now_20_regular.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_meet_now_20_regular.imageset/ic_fluent_meet_now_20_regular.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/ic_fluent_mic_off_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/ic_fluent_mic_on_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_people_24_regular.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_people_24_regular.imageset/ic_fluent_people_24_regular.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/ic_fluent_speaker_2_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/ic_fluent_speaker_2_24_regular.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_24_filled.imageset/ic_fluent_video_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/ic_fluent_video_off_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/ic_fluent_warning_24_filled.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/ic_ios_arrow_left_24_outlined.pdf create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallComposite.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeEventsHandler.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeOptions.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallConfiguration.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/DiagnosticConfig.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ErrorEvent.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/GroupCallOptions.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/TeamsMeetingOptions.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ThemeConfiguration.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/DI/DependancyContainer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Info.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Logger/DefaultLogger.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Logger/Logger.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Model/CallInfoModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Model/ParticipantInfoModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Model/VideoStreamInfoModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewFactory.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewModelFactory.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Icon.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeAvatar.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeButton.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsList.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsListCell.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositePopupMenu.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/DrawerContainerViewController.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/ParticipantsListViewController.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/PopupMenuViewController.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AppLifeCycleManager.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioDeviceType.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioSessionManager.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/CompositeErrorManager.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/PermissionsManager.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Navigation/NavigationRouter.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/ColorThemeProvider.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/IconProvider.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/StyleProvider.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerInfoType.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ConfirmLeaveOverlayView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/LobbyOverlayView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListCellViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellVideoView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridLayoutView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerUIHostingController.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/EnvironmentValuesExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/PreferenceKey.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/UIFontExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/ViewExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButton.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButtonViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButton.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButtonViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButton.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButtonViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/AudioDeviceListViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/PopupMenuViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/SourceViewSpace.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/LockPhoneOrientation.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/PopupModalView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoViewModel.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/VideoRenderView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Presentation/VideoViewManager.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Action/Action.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Action/CallingAction.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LifecycleAction.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LocalUserAction.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Action/PermissionAction.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddleware.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareErrorHandler.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareHandler.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/Middleware.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/AppStateReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/CallingReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/ErrorReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LifeCycleReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LocalUserReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/NavigationReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/PermissionReducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/Reducer.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppLifeCycleState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/CallingState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/ErrorState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/LocalUserState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/NavigationState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/PermissionState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/ReduxState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/State/RemoteParticipantsState.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Redux/Store.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKEventsHandler.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKWrapper.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingService.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCallingStateExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCameraFacingExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/CommunicationIdentifierExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/RemoteParticipantExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Service/Calling/RemoteParticipantsEventsAdapter.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/ArrayExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/CancelBag.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/ColorExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/CompsiteError.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/DeviceExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/MappedSequence.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/UIViewControllerExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUI/Utilities/UIWindowExtension.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.pbxproj create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUIDemoApp.xcscheme create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/AppConfig.xcconfig create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/AppDelegate.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/Contents.json create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/AuthenticationHelper.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/DemoInputTypes.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/EnvConfig.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Info.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/README.md create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/SceneDelegate.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/ThemeConfig.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/LaunchScreen.storyboard create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/Main.storyboard create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/CustomControls.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/EntryViewController.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/SwiftUIDemoView.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/UIKitDemoViewController.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/ActionMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/BannerTextViewModelMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Builder/ParticipantInfoModelBuilder.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/CallCompositeMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/DiagnosticConfigTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/GroupCallOptionsTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/TeamsMeetingOptionsTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/CallingMiddlewareHandlerMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/CompositeViewModelFactoryMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/DI/DependencyContainerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/ErrorMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Info.plist create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Manager/CompositeErrorManagerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingSDKWrapperMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingServiceMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/LoggerMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/MiddlewareMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/PermissionsManagerMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/ReducerMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/StateMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/StoreFactoryMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Mocking/VideoViewManagerMocking.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerInfoTypeTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerTextViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/CallingViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ControlBarViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/InfoHeaderViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantCellViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantGridsViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantsListViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/ContainerViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Factories/CompositeViewModelFactoryTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/PreviewAreaViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupControlBarViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/AudioDeviceListViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/LocalVideoViewModelTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareHandlerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/AppStateReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/CallingReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/ErrorReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LifeCycleReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LocalUserReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/NavigationReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/PermissionReducerTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Service/CallingServiceTests.swift create mode 100644 AzureCommunicationUI/AzureCommunicationUITests/Service/NavigationRouterTests.swift create mode 100644 AzureCommunicationUI/Podfile create mode 100644 AzureCommunicationUI/Podfile.lock create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/api/CallComposite/Reference.md create mode 100644 docs/api/CallComposite/classes/CallComposite.md create mode 100644 docs/api/CallComposite/protocols/ThemeConfiguration.md create mode 100644 docs/api/CallComposite/structs/CallCompositeErrorCode.md create mode 100644 docs/api/CallComposite/structs/CallCompositeOptions.md create mode 100644 docs/api/CallComposite/structs/ErrorEvent.md create mode 100644 docs/api/CallComposite/structs/GroupCallOptions.md create mode 100644 docs/api/CallComposite/structs/TeamsMeetingOptions.md create mode 100644 docs/contributing-guide.md create mode 100644 docs/images/EnvConfig.png create mode 100644 docs/images/SelectSimulator.png create mode 100644 docs/images/mobile-ui-library-hero-image.png create mode 100644 docs/manual-installation.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..a629b0edf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Azure/acs-ui-ios-reviewer-1 @Azure/acs-ui-ios-reviewer-2 @Azure/acs-ui-ios-reviewer-3 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..f9ba8cf65 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..15c7f6022 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,33 @@ + +> Please provide us with the following information: +> --------------------------------------------------------------- + +### This issue is for a: (mark with an `x`) +``` +- [ ] bug report -> please search issues before submitting +- [ ] feature request +- [ ] documentation issue or request +- [ ] regression (a behavior that used to work and stopped in a new release) +``` + +### Minimal steps to reproduce +> + +### Any log messages given by the failure +> + +### Expected/desired behavior +> + +### OS and Version? +> Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) + +### Versions +> + +### Mention any other details that might be useful + +> --------------------------------------------------------------- +> Thanks! We'll be in touch soon. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..ab05e292b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,45 @@ +## Purpose + +* ... + +## Does this introduce a breaking change? + +``` +[ ] Yes +[ ] No +``` + +## Pull Request Type +What kind of change does this Pull Request introduce? + + +``` +[ ] Bugfix +[ ] Feature +[ ] Code style update (formatting, local variables) +[ ] Refactoring (no functional changes, no api changes) +[ ] Documentation content changes +[ ] Other... Please describe: +``` + +## How to Test +* Get the code + +``` +git clone [repo-address] +cd [repo-name] +git checkout [branch-name] +npm install +``` + +* Test the code + +``` +``` + +## What to Check +Verify that the following are valid +* ... + +## Other Information + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 330d1674f..156c883a1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ playground.xcworkspace # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # # Pods/ +AzureCommunicationUI/Pods/ # # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace @@ -88,3 +89,10 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +# Mac +.DS_Store + +#AppCode +AzureCommunicationUI/.idea +AzureCommunicationUI/AzureCommunicationUIDemoApp/EnvConfig.xcconfig \ No newline at end of file diff --git a/AzureCommunicationUI.podspec b/AzureCommunicationUI.podspec new file mode 100644 index 000000000..cf992b199 --- /dev/null +++ b/AzureCommunicationUI.podspec @@ -0,0 +1,25 @@ +Pod::Spec.new do |spec| + spec.name = "AzureCommunicationUI" + spec.version = "1.0.0-beta.1" + spec.summary = "UI Library to quickly integrate Azure Communication Services experiences into your applications." + spec.homepage = "https://github.com/Azure/communication-ui-library-ios" + spec.license = { :type => 'MIT' } + spec.author = 'Microsoft' + spec.source = { :git => 'https://github.com/Azure/communication-ui-library-ios', :tag => 'v1.0.0-beta.1' } + spec.module_name = 'AzureCommunicationUI' + spec.swift_version = '5.0' + + spec.platform = :ios, '13.0' + + spec.source_files = 'AzureCommunicationUI/AzureCommunicationUI/**/*.swift' + spec.resources = 'AzureCommunicationUI/AzureCommunicationUI/*.xcassets' + + spec.pod_target_xcconfig = { "EXCLUDED_ARCHS[sdk=iphonesimulator*]": "arm64", "ENABLE_BITCODE": "NO"} + + spec.frameworks = 'UIKit', 'SwiftUI' + spec.dependency 'AzureCommunicationCalling', '2.2.0-beta.1' + spec.dependency 'MicrosoftFluentUI/Avatar_ios', '0.3.5' + spec.dependency 'MicrosoftFluentUI/BottomSheet_ios', '0.3.5' + spec.dependency 'MicrosoftFluentUI/Button_ios', '0.3.5' + spec.dependency 'MicrosoftFluentUI/PopupMenu_ios', '0.3.5' +end \ No newline at end of file diff --git a/AzureCommunicationUI/.swiftlint.yml b/AzureCommunicationUI/.swiftlint.yml new file mode 100644 index 000000000..091123493 --- /dev/null +++ b/AzureCommunicationUI/.swiftlint.yml @@ -0,0 +1,201 @@ +opt_in_rules: + # Delegate protocols should be class-only so they can be weakly referenced. + - class_delegate_protocol + + # Closing brace with closing parenthesis should not have any whitespaces in the middle. + - closing_brace + + # Closure end should have the same indentation as the line that started it. + - closure_end_indentation + + # Colons should be next to the identifier when specifying a type and next to the key in dictionary literals. + - colon + + # There should be no space before and one after any comma. + - comma + + # Conditional statements should always return on the next line. + - conditional_returns_on_newline + + # Prefer contains over first(where:) != nil + - contains_over_first_not_nil + + # if, for, guard, switch, while, and catch statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses. + - control_statement + + # Discouraged direct initialization of types that can be harmful (UIDevice, Bundle, etc) + - discouraged_direct_init + + # Prefer () -> over Void ->. + - empty_parameters + + # When using trailing closures, empty parentheses should be avoided after the method call. + - empty_parentheses_with_trailing_closure + + # Properties should have a type interface + - explicit_type_interface + + # Prefer to use extension access modifiers. + - extension_access_modifier + + # A fatalError call should have a message. + - fatal_error_message + + # Header comments should be consistent with project patterns. + - file_header + + # Identifier names should only contain alphanumeric characters and start with a lowercase character or should only contain capital letters. + - identifier_name + + # Computed read-only properties and subscripts should avoid using the get keyword. + - implicit_getter + + # Files should not contain leading whitespace. + - leading_whitespace + + # Struct-scoped constants are preferred over legacy global constants. + - legacy_constant + + # Swift constructors are preferred over legacy convenience functions. + - legacy_constructor + + # MARK comment should be in valid format. e.g. '// MARK: ...' or '// MARK: - ...' + - mark + + # Modifier order should be consistent. + - modifier_order + + # Functions and methods parameters should be either on the same line, or one per line. + - multiline_parameters + + # Trailing closure syntax should not be used when passing more than one closure argument. + - multiple_closures_with_trailing_closure + + # Opening braces should be preceded by a single space and on the same line as the declaration. + - opening_brace + + # Operators should be surrounded by a single whitespace when they are being used. + - operator_usage_whitespace + + # Some overridden methods should always call super + - overridden_super_call + + # Prefer private over fileprivate declarations. + - private_over_fileprivate + + # When declaring properties in protocols, the order of accessors should be get set. + - protocol_property_accessors_order + + # Prefer _ = foo() over let _ = foo() when discarding a result from a function. + - redundant_discardable_let + + # Initializing an optional variable with nil is redundant. + - redundant_optional_initialization + + # Property setter access level shouldn't be explicit if it's the same as the variable access level. + - redundant_set_access_control + + # String enum values can be omitted when they are equal to the enumcase name. + - redundant_string_enum_value + + # Properties, variables, and constants should not have redundant type annotation + - redundant_type_annotation + + # Returning Void in a function declaration is redundant. + - redundant_void_return + + # Return arrow and return type should be separated by a single space or on a separate line. + - return_arrow_whitespace + + # Prefer shorthand operators (+=, -=, *=, /=) over doing the operation and assigning. + - shorthand_operator + + # Else and catch should be on the same line, one space after the previous declaration. + - statement_position + + # SwiftLint 'disable' commands are superfluous when the disabled rule would not have triggered a violation in the disabled region. + - superfluous_disable_command + + # Case statements should vertically align with their enclosing switch statement, or indented if configured otherwise. + - switch_case_alignment + + # Cases inside a switch should always be on a newline. + - switch_case_on_newline + + # Shorthand syntactic sugar should be used, i.e. [Int] instead of Array. + - syntactic_sugar + + # Trailing commas in arrays and dictionaries should be avoided/enforced. + - trailing_comma + + # Files should have a single trailing newline. + - trailing_newline + + # Lines should not have trailing semicolons. + - trailing_semicolon + + # Lines should not have trailing whitespace. + - trailing_whitespace + + # Type name should only contain alphanumeric characters, start with an uppercase character, and span between 3 and 50 characters in length. + - type_name + + # Avoid using unneeded break statements. + - unneeded_break_in_switch + + # Unused parameter in a closure should be replaced with _. + - unused_closure_parameter + + # When the index or the item is not used, .enumerated() can be removed. + - unused_enumerated + + # Prefer != nil over let _ =. + - unused_optional_binding + + # Function parameters should be aligned vertically if they're in multiple lines in a declaration. + - vertical_parameter_alignment + + # Limit vertical whitespace to a single empty line. + - vertical_whitespace + + # Prefer -> Void over -> (). + - void_return + + # Delegates should be weak to avoid reference cycles. + - weak_delegate + +disabled_rules: # rule identifiers turned on by default to exclude from running + +included: # paths to include during linting. `--path` is ignored if present. + - AzureCommunicationUI + +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Pods +explicit_type_interface: # requires type annotation on everything but local variables and constants + excluded: + - local + allow_redundancy: true # Ignores rule if it would result in redundancy +file_header: + required_pattern: | + \/\/ + \/\/ Copyright \(c\) Microsoft Corporation\. All rights reserved\. + \/\/ Licensed under the MIT License\. + \/\/ +identifier_name: + min_length: 1 + max_length: 60 +modifier_order: + preferred_modifier_order: + - acl + - setterACL + - override + - typeMethods +type_name: + min_length: 3 + max_length: 50 +function_body_length: + - 75 # Warning + - 100 # Error +cyclomatic_complexity: + - 25 # Warning + - 50 # Error diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.pbxproj b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.pbxproj new file mode 100644 index 000000000..14975d2ac --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.pbxproj @@ -0,0 +1,1726 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 0F521429FD06F4F9B397730A /* Pods_AzureCommunicationUI_AzureCommunicationUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D02090EE8283A8254134AA10 /* Pods_AzureCommunicationUI_AzureCommunicationUITests.framework */; }; + 1B70D52827191073007CC933 /* DeviceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B70D52727191073007CC933 /* DeviceExtension.swift */; }; + 1B79BD50273F2D87002D5A36 /* UIViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B79BD4F273F2D87002D5A36 /* UIViewControllerExtension.swift */; }; + 1F03D3CE2638A6AE0055C456 /* CallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F03D3CD2638A6AE0055C456 /* CallingView.swift */; }; + 1F03D3D22638A6B70055C456 /* CallingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F03D3D12638A6B70055C456 /* CallingViewModel.swift */; }; + 1F09A10426BA472A00BACED7 /* ParticipantInfoModelBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A10326BA472A00BACED7 /* ParticipantInfoModelBuilder.swift */; }; + 1F09A10A26BA47EC00BACED7 /* SetupViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A10926BA47EC00BACED7 /* SetupViewModelTests.swift */; }; + 1F09A11126BA484000BACED7 /* ControlBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A10C26BA484000BACED7 /* ControlBarViewModelTests.swift */; }; + 1F09A11326BA484000BACED7 /* ParticipantCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A10E26BA484000BACED7 /* ParticipantCellViewModelTests.swift */; }; + 1F09A11426BA484000BACED7 /* CallingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A10F26BA484000BACED7 /* CallingViewModelTests.swift */; }; + 1F09A11526BA484000BACED7 /* ParticipantGridsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A11026BA484000BACED7 /* ParticipantGridsViewModelTests.swift */; }; + 1F09A11D26BB5B0D00BACED7 /* ArrayExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A11C26BB5B0D00BACED7 /* ArrayExtension.swift */; }; + 1F11D2B2263CA10000D33838 /* CommunicationIdentifierExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F87A7BA263A7EE900F49C7E /* CommunicationIdentifierExtension.swift */; }; + 1F11D2BD2640BD9800D33838 /* CallingMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F11D2BC2640BD9700D33838 /* CallingMiddleware.swift */; }; + 1F13D36C26A8B83300E31666 /* RemoteParticipantsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13D36B26A8B83300E31666 /* RemoteParticipantsState.swift */; }; + 1F13D36E26AA82D400E31666 /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13D36D26AA82D400E31666 /* NavigationState.swift */; }; + 1F13D37026AB293700E31666 /* NavigationReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13D36F26AB293700E31666 /* NavigationReducer.swift */; }; + 1F13D37326AB3F1200E31666 /* ACSCallingStateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13D37226AB3F1200E31666 /* ACSCallingStateExtension.swift */; }; + 1F2BED64262F86E600D98266 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED63262F86E600D98266 /* SetupView.swift */; }; + 1F2BED68262F86F400D98266 /* SetupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED67262F86F400D98266 /* SetupViewModel.swift */; }; + 1F2BED6C262F8B7000D98266 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED6B262F8B7000D98266 /* Store.swift */; }; + 1F2BED72262F8F4D00D98266 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED71262F8F4D00D98266 /* Reducer.swift */; }; + 1F2BED76262F944500D98266 /* ReduxState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED75262F944500D98266 /* ReduxState.swift */; }; + 1F2BED882631EB7900D98266 /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED872631EB7900D98266 /* PermissionsManager.swift */; }; + 1F2BED992631FA6C00D98266 /* PermissionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BED982631FA6C00D98266 /* PermissionState.swift */; }; + 1F2BEDA5263252E100D98266 /* Middleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BEDA4263252E100D98266 /* Middleware.swift */; }; + 1F2BEDA92632530D00D98266 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BEDA82632530D00D98266 /* Action.swift */; }; + 1F2BEDBB26328EBA00D98266 /* PermissionReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BEDBA26328EBA00D98266 /* PermissionReducer.swift */; }; + 1F2BEDC026328F2700D98266 /* CallingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BEDBF26328F2700D98266 /* CallingState.swift */; }; + 1F2BEDCD26329E5600D98266 /* PermissionAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BEDCC26329E5600D98266 /* PermissionAction.swift */; }; + 1F2BEDD126329E7C00D98266 /* CallingAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BEDD026329E7C00D98266 /* CallingAction.swift */; }; + 1F47F78726E9E18E000AE4B7 /* DrawerContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F47F78626E9E18E000AE4B7 /* DrawerContainerViewController.swift */; }; + 1F48B401274879F000B6E5F9 /* DiagnosticConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F48B400274879F000B6E5F9 /* DiagnosticConfig.swift */; }; + 1F48B40327496E2800B6E5F9 /* DiagnosticConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F48B40227496E2800B6E5F9 /* DiagnosticConfigTests.swift */; }; + 1F4A24BC26D6F5E600C11083 /* CompositeViewModelFactoryMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4A24BB26D6F5E600C11083 /* CompositeViewModelFactoryMocking.swift */; }; + 1F4B0EF8269BD17600E87014 /* CompositeErrorManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4B0EF7269BD17600E87014 /* CompositeErrorManagerTests.swift */; }; + 1F4B0EFA269BD3D000E87014 /* CallCompositeMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4B0EF9269BD3D000E87014 /* CallCompositeMocking.swift */; }; + 1F4B0EFE26A1466D00E87014 /* ParticipantGridCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA46012681409D001844AC /* ParticipantGridCellView.swift */; }; + 1F4B0F0126A2025900E87014 /* ParticipantGridLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4B0F0026A2025900E87014 /* ParticipantGridLayoutView.swift */; }; + 1F4B0F0526A6469F00E87014 /* RemoteParticipantsEventsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4B0F0426A6469F00E87014 /* RemoteParticipantsEventsAdapter.swift */; }; + 1F6470A92639DCFA0008B9E9 /* CallingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6470A82639DCFA0008B9E9 /* CallingService.swift */; }; + 1F6470AC2639FE670008B9E9 /* CancelBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6470AB2639FE670008B9E9 /* CancelBag.swift */; }; + 1F7BF43E2707D86000974139 /* BannerTextViewModelMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7BF43D2707D86000974139 /* BannerTextViewModelMocking.swift */; }; + 1F83813F26803ADF0096A454 /* CallingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F83813E26803ADF0096A454 /* CallingReducer.swift */; }; + 1F87A7B9263A7E6E00F49C7E /* MappedSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F87A7B8263A7E6E00F49C7E /* MappedSequence.swift */; }; + 1F8E0B722684EBC100DDD18E /* CallingMiddlewareHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8E0B712684EBC100DDD18E /* CallingMiddlewareHandler.swift */; }; + 1F8E0B74268697B700DDD18E /* CallingMiddlewareHandlerMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8E0B73268697B700DDD18E /* CallingMiddlewareHandlerMocking.swift */; }; + 1F8E0B78268D0EF000DDD18E /* CompositeErrorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8E0B77268D0EF000DDD18E /* CompositeErrorManager.swift */; }; + 1F91849926B31B640049EF5B /* ActionMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F91849826B31B640049EF5B /* ActionMocking.swift */; }; + 1F9184AD26B491840049EF5B /* LifeCycleReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9184AC26B491840049EF5B /* LifeCycleReducerTests.swift */; }; + 1F9184B426B4A2EF0049EF5B /* CallingReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9184B326B4A2EF0049EF5B /* CallingReducerTests.swift */; }; + 1F9184B626B4A3340049EF5B /* NavigationReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9184B526B4A3340049EF5B /* NavigationReducerTests.swift */; }; + 1F94DADC2673E87700691D1E /* ThemeConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DADB2673E87700691D1E /* ThemeConfiguration.swift */; }; + 1F94DADF2673E94F00691D1E /* CallComposite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DADE2673E94F00691D1E /* CallComposite.swift */; }; + 1F94DAE12673EA9500691D1E /* CallCompositeEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DAE02673EA9500691D1E /* CallCompositeEventsHandler.swift */; }; + 1F94DAE426740BCB00691D1E /* LocalUserAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DAE326740BCB00691D1E /* LocalUserAction.swift */; }; + 1F94DAE626740D4400691D1E /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DAE526740D4400691D1E /* AppState.swift */; }; + 1F94DAE82676885700691D1E /* AppStateReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DAE72676885700691D1E /* AppStateReducer.swift */; }; + 1F94DAF326772B3C00691D1E /* UIWindowExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DAF226772B3C00691D1E /* UIWindowExtension.swift */; }; + 1F94DAF52677D48400691D1E /* ErrorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F94DAF42677D48400691D1E /* ErrorEvent.swift */; }; + 1FA23F9E265DA21400B4A080 /* VideoStreamInfoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA23F9D265DA21400B4A080 /* VideoStreamInfoModel.swift */; }; + 1FA24BAD2666F0CC00B4A080 /* ColorThemeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA24BAC2666F0CC00B4A080 /* ColorThemeProvider.swift */; }; + 1FBCB666264519DA00F57EEA /* VideoViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB665264519DA00F57EEA /* VideoViewManager.swift */; }; + 1FBCB69F2645285100F57EEA /* AzureCommunicationUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A8659FC52602A22000C807DE /* AzureCommunicationUI.framework */; platformFilter = ios; }; + 1FBCB6AA2645AC2F00F57EEA /* VideoRenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6A92645AC2F00F57EEA /* VideoRenderView.swift */; }; + 1FBCB6AC2649993500F57EEA /* CallingSDKWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6AB2649993500F57EEA /* CallingSDKWrapper.swift */; }; + 1FBCB6AF2649DC7900F57EEA /* ParticipantInfoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6AE2649DC7900F57EEA /* ParticipantInfoModel.swift */; }; + 1FBCB6B12649EB1200F57EEA /* RemoteParticipantExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6B02649EB1200F57EEA /* RemoteParticipantExtension.swift */; }; + 1FBCB6B6264BA8A200F57EEA /* ParticipantGridCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6B5264BA8A200F57EEA /* ParticipantGridCellViewModel.swift */; }; + 1FBCB6B9264BA90000F57EEA /* ParticipantGridCellVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6B8264BA90000F57EEA /* ParticipantGridCellVideoView.swift */; }; + 1FBCB6D72654765D00F57EEA /* LocalUserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6D62654765D00F57EEA /* LocalUserState.swift */; }; + 1FBCB6D92655825200F57EEA /* LocalUserReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBCB6D82655825200F57EEA /* LocalUserReducer.swift */; }; + 1FC308442668166C007EC537 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1FC308432668166C007EC537 /* Assets.xcassets */; }; + 1FC30846266949E8007EC537 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC30845266949E8007EC537 /* ColorExtension.swift */; }; + 1FCB056B2671D8BE00126A4E /* StyleProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCB056A2671D8BE00126A4E /* StyleProvider.swift */; }; + 1FD299642723345B0084B9ED /* DefaultLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD299632723345B0084B9ED /* DefaultLogger.swift */; }; + 1FD299662728B72B0084B9ED /* CallingSDKEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD299652728B72B0084B9ED /* CallingSDKEventsHandler.swift */; }; + 1FE1BE7F26FE38C9000A8D18 /* CompsiteError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FE1BE7E26FE38C9000A8D18 /* CompsiteError.swift */; }; + 1FFEA01426A68D0D00E90816 /* UIFontExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFEA01326A68D0D00E90816 /* UIFontExtension.swift */; }; + 4EFDD23E634871B55C951638 /* Pods_AzureCommunicationUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AEF91CC2CFA3B351A6C90172 /* Pods_AzureCommunicationUI.framework */; }; + 50137D0426A245E100AB843E /* ConfirmLeaveOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50137D0326A245E100AB843E /* ConfirmLeaveOverlayView.swift */; }; + 50137D0626A7479C00AB843E /* EnvironmentValuesExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50137D0526A7479C00AB843E /* EnvironmentValuesExtension.swift */; }; + 503300FC2706763800289BB5 /* BannerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503300FB2706763800289BB5 /* BannerViewModelTests.swift */; }; + 503300FE27079FC900289BB5 /* BannerTextViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503300FD27079FC900289BB5 /* BannerTextViewModelTests.swift */; }; + 503E361C26CC2D0900158CB4 /* CompositeViewModelFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503E361B26CC2D0900158CB4 /* CompositeViewModelFactoryTests.swift */; }; + 503F4B672704EDC800C17DA6 /* BannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503F4B662704EDC800C17DA6 /* BannerView.swift */; }; + 503F4B692704EDD100C17DA6 /* BannerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503F4B682704EDD100C17DA6 /* BannerViewModel.swift */; }; + 503F4B6B2704EE6800C17DA6 /* BannerTextViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503F4B6A2704EE6800C17DA6 /* BannerTextViewModel.swift */; }; + 503F4B6D2705065700C17DA6 /* BannerTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503F4B6C2705065700C17DA6 /* BannerTextView.swift */; }; + 50C6DECC270E48BE0085D04B /* BannerInfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C6DECB270E48BE0085D04B /* BannerInfoType.swift */; }; + 50C6DECF270E4F020085D04B /* BannerInfoTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C6DECE270E4F020085D04B /* BannerInfoTypeTests.swift */; }; + 50E2D68926D9495B006B0EF4 /* InfoHeaderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2D68826D9495B006B0EF4 /* InfoHeaderViewModelTests.swift */; }; + 50E2D69126EAAB33006B0EF4 /* CompositeParticipantsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2D69026EAAB33006B0EF4 /* CompositeParticipantsList.swift */; }; + 50E2D69526EACB74006B0EF4 /* ParticipantsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2D69426EACB74006B0EF4 /* ParticipantsListViewModel.swift */; }; + 50E2D69726EBCD15006B0EF4 /* CompositeParticipantsListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2D69626EBCD15006B0EF4 /* CompositeParticipantsListCell.swift */; }; + 50E2D69926F4FA05006B0EF4 /* ParticipantsListCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2D69826F4FA05006B0EF4 /* ParticipantsListCellViewModel.swift */; }; + 50E2D6A926FA52A3006B0EF4 /* ParticipantsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2D6A826FA52A3006B0EF4 /* ParticipantsListViewModelTests.swift */; }; + 50FA45F5267D3BD3001844AC /* ControlBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA45F4267D3BD3001844AC /* ControlBarView.swift */; }; + 50FA45F8267D466D001844AC /* InfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA45F7267D466D001844AC /* InfoHeaderView.swift */; }; + 50FA45FC26813FB8001844AC /* ParticipantGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA45FB26813FB8001844AC /* ParticipantGridView.swift */; }; + 50FA45FE26813FCE001844AC /* ParticipantGridViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA45FD26813FCE001844AC /* ParticipantGridViewModel.swift */; }; + 50FA460C26829162001844AC /* IconWithLabelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA460B26829162001844AC /* IconWithLabelButton.swift */; }; + 50FA460E26829345001844AC /* InfoHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA460D26829345001844AC /* InfoHeaderViewModel.swift */; }; + 50FA461026829B7A001844AC /* ControlBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA460F26829B7A001844AC /* ControlBarViewModel.swift */; }; + 50FA4616268D13E9001844AC /* LocalUserReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA4615268D13E9001844AC /* LocalUserReducerTests.swift */; }; + 50FA46182696214D001844AC /* CallingMiddlewareHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA46172696214C001844AC /* CallingMiddlewareHandlerTests.swift */; }; + 50FA461D2698DCEC001844AC /* IconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA461C2698DCEB001844AC /* IconButton.swift */; }; + 50FA46212698EA7A001844AC /* IconWithLabelButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA46202698EA7A001844AC /* IconWithLabelButtonViewModel.swift */; }; + 50FA4623269CA1D4001844AC /* IconButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FA4622269CA1D3001844AC /* IconButtonViewModel.swift */; }; + 5A314059D7E61D1083A8C473 /* LocalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A314A985811198B09DA2688 /* LocalVideoView.swift */; }; + 5A3140EA80ACDCB14140A060 /* CompositeViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3145FC912A71B20D5ECBF6 /* CompositeViewFactory.swift */; }; + 5A31416F1C27AA7C96202B17 /* TeamsMeetingOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3140924D957941B2CE1AED /* TeamsMeetingOptionsTests.swift */; }; + 5A3142646DD55C66CF290F7A /* LocalVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A314317E4A1B700E6BE5E2E /* LocalVideoViewModel.swift */; }; + 5A31429C377C213B53F19163 /* DependencyContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A314F041B5D1869C41C242E /* DependencyContainerTests.swift */; }; + 5A3142DAFAB4EE338ABF2196 /* CompositeViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3148FC4DC06C7515E1E8D9 /* CompositeViewModelFactory.swift */; }; + 5A314750E50CDF6A6F4577E5 /* GroupCallOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A314F62143032D2313CB616 /* GroupCallOptionsTests.swift */; }; + 5A31489916982D3699CD756D /* LocalVideoViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3141390FEFB58AD2A2E22F /* LocalVideoViewModelTests.swift */; }; + 5A31497E0DEBAB0256AD0B91 /* ErrorMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3146B0B684264EB57EDA69 /* ErrorMocking.swift */; }; + 5A314D26A09E235A6509C1C7 /* ACSCameraFacingExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A31447C18BC49B691800C9D /* ACSCameraFacingExtension.swift */; }; + 5A314F66ED6C53A7EFC6EDA3 /* LobbyOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3148DD49F9B22A8E1C3D3F /* LobbyOverlayView.swift */; }; + 88701EB42742D54C00660EAB /* ErrorReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88701EB32742D54C00660EAB /* ErrorReducerTests.swift */; }; + 88701EB8274C29C600660EAB /* CallingMiddlewareErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88701EB7274C29C600660EAB /* CallingMiddlewareErrorHandler.swift */; }; + 88A70180270E50D100F817B8 /* PopupModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A7017F270E50D100F817B8 /* PopupModalView.swift */; }; + 88B87C572755911B00290DD1 /* CallInfoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B87C562755911B00290DD1 /* CallInfoModel.swift */; }; + 88F6EE1A2735F5CB001AD3E9 /* ErrorReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F6EE192735F5CB001AD3E9 /* ErrorReducer.swift */; }; + 88F6EE1C2735F8E8001AD3E9 /* ErrorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F6EE1B2735F8E8001AD3E9 /* ErrorState.swift */; }; + 981ACABC2685459400CD6A40 /* IconProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981ACABB2685459400CD6A40 /* IconProvider.swift */; }; + 9823AB4926A13F220006266D /* PreviewAreaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9823AB4826A13F220006266D /* PreviewAreaView.swift */; }; + 9823AB4B26A13F4B0006266D /* PreviewAreaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9823AB4A26A13F4B0006266D /* PreviewAreaViewModel.swift */; }; + 982C282E267C0C7500427246 /* Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C282D267C0C7500427246 /* Icon.swift */; }; + 982C2833267D0C4500427246 /* CompositeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C2832267D0C4500427246 /* CompositeButton.swift */; }; + 982C2835267D15AF00427246 /* CompositeAvatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C2834267D15AF00427246 /* CompositeAvatar.swift */; }; + 9852C2D326EFCF3D00435252 /* PopupMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9852C2D226EFCF3D00435252 /* PopupMenuViewController.swift */; }; + 9852C2D526EFD19300435252 /* ParticipantsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9852C2D426EFD19300435252 /* ParticipantsListViewController.swift */; }; + 98546EB626D9B9990069B246 /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546EB526D9B9990069B246 /* AudioSessionManager.swift */; }; + 98546EBA26DEF24D0069B246 /* AudioDeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546EB926DEF24D0069B246 /* AudioDeviceType.swift */; }; + 98546EBC26E1A8390069B246 /* AudioDeviceListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546EBB26E1A8390069B246 /* AudioDeviceListViewModel.swift */; }; + 985DF36C269650C900CFDA55 /* PreviewAreaViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985DF36B269650C900CFDA55 /* PreviewAreaViewModelTests.swift */; }; + 985DF372269D065400CFDA55 /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985DF371269D065400CFDA55 /* ViewExtension.swift */; }; + 985DF374269D096000CFDA55 /* SetupControlBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985DF373269D096000CFDA55 /* SetupControlBarView.swift */; }; + 985DF376269E07E700CFDA55 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985DF375269E07E700CFDA55 /* PrimaryButton.swift */; }; + 985DF378269E07F400CFDA55 /* PrimaryButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985DF377269E07F400CFDA55 /* PrimaryButtonViewModel.swift */; }; + 9885BA0726A2116200CC514B /* SetupControlBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9885BA0626A2116200CC514B /* SetupControlBarViewModel.swift */; }; + 988799912745EABE00AA3759 /* SourceViewSpace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988799902745EABE00AA3759 /* SourceViewSpace.swift */; }; + 988799932746CDEC00AA3759 /* LockPhoneOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988799922746CDEC00AA3759 /* LockPhoneOrientation.swift */; }; + 98966FE326A5E58D00069857 /* SetupControlBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98966FE226A5E58D00069857 /* SetupControlBarViewModelTests.swift */; }; + 98E8564626F5533200ECA304 /* PopupMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E8564526F5533200ECA304 /* PopupMenuViewModel.swift */; }; + 98E8564826FA609D00ECA304 /* AudioDeviceListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E8564726FA609D00ECA304 /* AudioDeviceListViewModelTests.swift */; }; + 98F13AA626E2E5D900145E5A /* CompositePopupMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F13AA526E2E5D900145E5A /* CompositePopupMenu.swift */; }; + 98FFD82826CC5FD900655398 /* PreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98FFD82726CC5FD900655398 /* PreferenceKey.swift */; }; + A8100385269E1B370015C26E /* GroupCallOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8100384269E1B370015C26E /* GroupCallOptions.swift */; }; + A8100387269F9DA70015C26E /* TeamsMeetingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8100386269F9DA70015C26E /* TeamsMeetingOptions.swift */; }; + A810038B26A0C1740015C26E /* CallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A810038A26A0C1740015C26E /* CallConfiguration.swift */; }; + A830C306264C3B6400766E3D /* AppStateReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A830C305264C3B6400766E3D /* AppStateReducerTests.swift */; }; + A830C30A264C8AAC00766E3D /* CallingMiddlewareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A830C309264C8AAC00766E3D /* CallingMiddlewareTests.swift */; }; + A830C30C264D7C0900766E3D /* PermissionReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A830C30B264D7C0900766E3D /* PermissionReducerTests.swift */; }; + A830C317264D8A0C00766E3D /* ContainerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A830C316264D8A0C00766E3D /* ContainerViewModelTests.swift */; }; + A837397A261CE9FF00D46787 /* DependancyContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8373979261CE9FF00D46787 /* DependancyContainer.swift */; }; + A837397E261CEA4300D46787 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A837397D261CEA4300D46787 /* Logger.swift */; }; + A8373994261F9D7600D46787 /* CallCompositeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8373993261F9D7600D46787 /* CallCompositeOptions.swift */; }; + A869A63826546A2E003CC4F2 /* CallingServiceMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A62F26546A2D003CC4F2 /* CallingServiceMocking.swift */; }; + A869A63926546A2E003CC4F2 /* LoggerMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63026546A2D003CC4F2 /* LoggerMocking.swift */; }; + A869A63A26546A2E003CC4F2 /* MiddlewareMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63126546A2D003CC4F2 /* MiddlewareMocking.swift */; }; + A869A63B26546A2E003CC4F2 /* PermissionsManagerMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63226546A2D003CC4F2 /* PermissionsManagerMocking.swift */; }; + A869A63C26546A2E003CC4F2 /* StateMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63326546A2D003CC4F2 /* StateMocking.swift */; }; + A869A63D26546A2E003CC4F2 /* ReducerMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63426546A2D003CC4F2 /* ReducerMocking.swift */; }; + A869A63E26546A2E003CC4F2 /* CallingSDKWrapperMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63526546A2D003CC4F2 /* CallingSDKWrapperMocking.swift */; }; + A869A63F26546A2E003CC4F2 /* VideoViewManagerMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63626546A2E003CC4F2 /* VideoViewManagerMocking.swift */; }; + A869A64026546A2E003CC4F2 /* StoreFactoryMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869A63726546A2E003CC4F2 /* StoreFactoryMocking.swift */; }; + A874EDC4266152AA003C7D92 /* AppLifeCycleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A874EDC3266152AA003C7D92 /* AppLifeCycleManager.swift */; }; + A874EDC626616728003C7D92 /* AppLifeCycleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A874EDC526616728003C7D92 /* AppLifeCycleState.swift */; }; + A874EDC826616792003C7D92 /* LifecycleAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A874EDC726616792003C7D92 /* LifecycleAction.swift */; }; + A874EDCA266167E5003C7D92 /* LifeCycleReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A874EDC9266167E5003C7D92 /* LifeCycleReducer.swift */; }; + A874EDCC2666DF07003C7D92 /* ContainerUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A874EDCB2666DF07003C7D92 /* ContainerUIHostingController.swift */; }; + A8810CD8263CC46500C88545 /* NavigationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8810CD7263CC46500C88545 /* NavigationRouter.swift */; }; + A8810CDD263CC75200C88545 /* ContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8810CDC263CC75200C88545 /* ContainerView.swift */; }; + A88CDFB9274FF9C10004E2F5 /* ErrorInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88CDFB8274FF9C10004E2F5 /* ErrorInfoView.swift */; }; + A88CDFBC27505A410004E2F5 /* ErrorInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88CDFBB27505A410004E2F5 /* ErrorInfoViewModel.swift */; }; + A8ED232D2645C637008A26B2 /* CallingServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8ED232C2645C637008A26B2 /* CallingServiceTests.swift */; }; + A8ED23302649EA61008A26B2 /* NavigationRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8ED232F2649EA61008A26B2 /* NavigationRouterTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1FBCB6A02645285100F57EEA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A8659FBC2602A22000C807DE /* Project object */; + proxyType = 1; + remoteGlobalIDString = A8659FC42602A22000C807DE; + remoteInfo = CallComposite; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 01D765D8184BB2E6A5879C73 /* Pods-AzureCommunicationUI.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AzureCommunicationUI.release.xcconfig"; path = "Target Support Files/Pods-AzureCommunicationUI/Pods-AzureCommunicationUI.release.xcconfig"; sourceTree = ""; }; + 1B70D52727191073007CC933 /* DeviceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceExtension.swift; sourceTree = ""; }; + 1B79BD4F273F2D87002D5A36 /* UIViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerExtension.swift; sourceTree = ""; }; + 1DD8A6B167FDF205AD440568 /* Pods-AzureCommunicationUI.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AzureCommunicationUI.debug.xcconfig"; path = "Target Support Files/Pods-AzureCommunicationUI/Pods-AzureCommunicationUI.debug.xcconfig"; sourceTree = ""; }; + 1F03D3CD2638A6AE0055C456 /* CallingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingView.swift; sourceTree = ""; }; + 1F03D3D12638A6B70055C456 /* CallingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingViewModel.swift; sourceTree = ""; }; + 1F09A10326BA472A00BACED7 /* ParticipantInfoModelBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParticipantInfoModelBuilder.swift; sourceTree = ""; }; + 1F09A10926BA47EC00BACED7 /* SetupViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetupViewModelTests.swift; sourceTree = ""; }; + 1F09A10C26BA484000BACED7 /* ControlBarViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ControlBarViewModelTests.swift; sourceTree = ""; }; + 1F09A10E26BA484000BACED7 /* ParticipantCellViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParticipantCellViewModelTests.swift; sourceTree = ""; }; + 1F09A10F26BA484000BACED7 /* CallingViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallingViewModelTests.swift; sourceTree = ""; }; + 1F09A11026BA484000BACED7 /* ParticipantGridsViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParticipantGridsViewModelTests.swift; sourceTree = ""; }; + 1F09A11C26BB5B0D00BACED7 /* ArrayExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayExtension.swift; sourceTree = ""; }; + 1F11D2BC2640BD9700D33838 /* CallingMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingMiddleware.swift; sourceTree = ""; }; + 1F13D36B26A8B83300E31666 /* RemoteParticipantsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteParticipantsState.swift; sourceTree = ""; }; + 1F13D36D26AA82D400E31666 /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = ""; }; + 1F13D36F26AB293700E31666 /* NavigationReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationReducer.swift; sourceTree = ""; }; + 1F13D37226AB3F1200E31666 /* ACSCallingStateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACSCallingStateExtension.swift; sourceTree = ""; }; + 1F2BED63262F86E600D98266 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; + 1F2BED67262F86F400D98266 /* SetupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupViewModel.swift; sourceTree = ""; }; + 1F2BED6B262F8B7000D98266 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + 1F2BED71262F8F4D00D98266 /* Reducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; + 1F2BED75262F944500D98266 /* ReduxState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReduxState.swift; sourceTree = ""; }; + 1F2BED872631EB7900D98266 /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = ""; }; + 1F2BED982631FA6C00D98266 /* PermissionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionState.swift; sourceTree = ""; }; + 1F2BEDA4263252E100D98266 /* Middleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Middleware.swift; sourceTree = ""; }; + 1F2BEDA82632530D00D98266 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + 1F2BEDBA26328EBA00D98266 /* PermissionReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReducer.swift; sourceTree = ""; }; + 1F2BEDBF26328F2700D98266 /* CallingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingState.swift; sourceTree = ""; }; + 1F2BEDCC26329E5600D98266 /* PermissionAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAction.swift; sourceTree = ""; }; + 1F2BEDD026329E7C00D98266 /* CallingAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingAction.swift; sourceTree = ""; }; + 1F47F78626E9E18E000AE4B7 /* DrawerContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerContainerViewController.swift; sourceTree = ""; }; + 1F48B400274879F000B6E5F9 /* DiagnosticConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticConfig.swift; sourceTree = ""; }; + 1F48B40227496E2800B6E5F9 /* DiagnosticConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticConfigTests.swift; sourceTree = ""; }; + 1F4A24BB26D6F5E600C11083 /* CompositeViewModelFactoryMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeViewModelFactoryMocking.swift; sourceTree = ""; }; + 1F4B0EF7269BD17600E87014 /* CompositeErrorManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeErrorManagerTests.swift; sourceTree = ""; }; + 1F4B0EF9269BD3D000E87014 /* CallCompositeMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallCompositeMocking.swift; sourceTree = ""; }; + 1F4B0F0026A2025900E87014 /* ParticipantGridLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantGridLayoutView.swift; sourceTree = ""; }; + 1F4B0F0426A6469F00E87014 /* RemoteParticipantsEventsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteParticipantsEventsAdapter.swift; sourceTree = ""; }; + 1F6470A82639DCFA0008B9E9 /* CallingService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallingService.swift; sourceTree = ""; }; + 1F6470AB2639FE670008B9E9 /* CancelBag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelBag.swift; sourceTree = ""; }; + 1F7BF43D2707D86000974139 /* BannerTextViewModelMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerTextViewModelMocking.swift; sourceTree = ""; }; + 1F83813E26803ADF0096A454 /* CallingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingReducer.swift; sourceTree = ""; }; + 1F87A7B8263A7E6E00F49C7E /* MappedSequence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MappedSequence.swift; sourceTree = ""; }; + 1F87A7BA263A7EE900F49C7E /* CommunicationIdentifierExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunicationIdentifierExtension.swift; sourceTree = ""; }; + 1F8E0B712684EBC100DDD18E /* CallingMiddlewareHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallingMiddlewareHandler.swift; sourceTree = ""; }; + 1F8E0B73268697B700DDD18E /* CallingMiddlewareHandlerMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingMiddlewareHandlerMocking.swift; sourceTree = ""; }; + 1F8E0B77268D0EF000DDD18E /* CompositeErrorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeErrorManager.swift; sourceTree = ""; }; + 1F91849826B31B640049EF5B /* ActionMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMocking.swift; sourceTree = ""; }; + 1F9184AC26B491840049EF5B /* LifeCycleReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifeCycleReducerTests.swift; sourceTree = ""; }; + 1F9184B326B4A2EF0049EF5B /* CallingReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingReducerTests.swift; sourceTree = ""; }; + 1F9184B526B4A3340049EF5B /* NavigationReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationReducerTests.swift; sourceTree = ""; }; + 1F94DADB2673E87700691D1E /* ThemeConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeConfiguration.swift; sourceTree = ""; }; + 1F94DADE2673E94F00691D1E /* CallComposite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallComposite.swift; sourceTree = ""; }; + 1F94DAE02673EA9500691D1E /* CallCompositeEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallCompositeEventsHandler.swift; sourceTree = ""; }; + 1F94DAE326740BCB00691D1E /* LocalUserAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUserAction.swift; sourceTree = ""; }; + 1F94DAE526740D4400691D1E /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 1F94DAE72676885700691D1E /* AppStateReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateReducer.swift; sourceTree = ""; }; + 1F94DAF226772B3C00691D1E /* UIWindowExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIWindowExtension.swift; sourceTree = ""; }; + 1F94DAF42677D48400691D1E /* ErrorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorEvent.swift; sourceTree = ""; }; + 1FA23F9D265DA21400B4A080 /* VideoStreamInfoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamInfoModel.swift; sourceTree = ""; }; + 1FA24BAC2666F0CC00B4A080 /* ColorThemeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorThemeProvider.swift; sourceTree = ""; }; + 1FBCB665264519DA00F57EEA /* VideoViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoViewManager.swift; sourceTree = ""; }; + 1FBCB682264522F200F57EEA /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 1FBCB69A2645285100F57EEA /* AzureCommunicationUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AzureCommunicationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 1FBCB6A7264528A900F57EEA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1FBCB6A92645AC2F00F57EEA /* VideoRenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRenderView.swift; sourceTree = ""; }; + 1FBCB6AB2649993500F57EEA /* CallingSDKWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingSDKWrapper.swift; sourceTree = ""; }; + 1FBCB6AE2649DC7900F57EEA /* ParticipantInfoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantInfoModel.swift; sourceTree = ""; }; + 1FBCB6B02649EB1200F57EEA /* RemoteParticipantExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteParticipantExtension.swift; sourceTree = ""; }; + 1FBCB6B5264BA8A200F57EEA /* ParticipantGridCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantGridCellViewModel.swift; sourceTree = ""; }; + 1FBCB6B8264BA90000F57EEA /* ParticipantGridCellVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantGridCellVideoView.swift; sourceTree = ""; }; + 1FBCB6D62654765D00F57EEA /* LocalUserState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUserState.swift; sourceTree = ""; }; + 1FBCB6D82655825200F57EEA /* LocalUserReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUserReducer.swift; sourceTree = ""; }; + 1FC308432668166C007EC537 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 1FC30845266949E8007EC537 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; + 1FCB056A2671D8BE00126A4E /* StyleProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleProvider.swift; sourceTree = ""; }; + 1FCC1171618F8B6862B6118E /* Pods-AzureCommunicationUI-AzureCommunicationUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AzureCommunicationUI-AzureCommunicationUITests.debug.xcconfig"; path = "Target Support Files/Pods-AzureCommunicationUI-AzureCommunicationUITests/Pods-AzureCommunicationUI-AzureCommunicationUITests.debug.xcconfig"; sourceTree = ""; }; + 1FD299632723345B0084B9ED /* DefaultLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultLogger.swift; sourceTree = ""; }; + 1FD299652728B72B0084B9ED /* CallingSDKEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingSDKEventsHandler.swift; sourceTree = ""; }; + 1FE1BE7E26FE38C9000A8D18 /* CompsiteError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompsiteError.swift; sourceTree = ""; }; + 1FFEA01326A68D0D00E90816 /* UIFontExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFontExtension.swift; sourceTree = ""; }; + 50137D0326A245E100AB843E /* ConfirmLeaveOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLeaveOverlayView.swift; sourceTree = ""; }; + 50137D0526A7479C00AB843E /* EnvironmentValuesExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValuesExtension.swift; sourceTree = ""; }; + 503300FB2706763800289BB5 /* BannerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerViewModelTests.swift; sourceTree = ""; }; + 503300FD27079FC900289BB5 /* BannerTextViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerTextViewModelTests.swift; sourceTree = ""; }; + 503E361B26CC2D0900158CB4 /* CompositeViewModelFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeViewModelFactoryTests.swift; sourceTree = ""; }; + 503F4B662704EDC800C17DA6 /* BannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerView.swift; sourceTree = ""; }; + 503F4B682704EDD100C17DA6 /* BannerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerViewModel.swift; sourceTree = ""; }; + 503F4B6A2704EE6800C17DA6 /* BannerTextViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerTextViewModel.swift; sourceTree = ""; }; + 503F4B6C2705065700C17DA6 /* BannerTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerTextView.swift; sourceTree = ""; }; + 50C6DECB270E48BE0085D04B /* BannerInfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerInfoType.swift; sourceTree = ""; }; + 50C6DECE270E4F020085D04B /* BannerInfoTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerInfoTypeTests.swift; sourceTree = ""; }; + 50E2D68826D9495B006B0EF4 /* InfoHeaderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoHeaderViewModelTests.swift; sourceTree = ""; }; + 50E2D69026EAAB33006B0EF4 /* CompositeParticipantsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeParticipantsList.swift; sourceTree = ""; }; + 50E2D69426EACB74006B0EF4 /* ParticipantsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsListViewModel.swift; sourceTree = ""; }; + 50E2D69626EBCD15006B0EF4 /* CompositeParticipantsListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeParticipantsListCell.swift; sourceTree = ""; }; + 50E2D69826F4FA05006B0EF4 /* ParticipantsListCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsListCellViewModel.swift; sourceTree = ""; }; + 50E2D6A826FA52A3006B0EF4 /* ParticipantsListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsListViewModelTests.swift; sourceTree = ""; }; + 50FA45F4267D3BD3001844AC /* ControlBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlBarView.swift; sourceTree = ""; }; + 50FA45F7267D466D001844AC /* InfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoHeaderView.swift; sourceTree = ""; }; + 50FA45FB26813FB8001844AC /* ParticipantGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantGridView.swift; sourceTree = ""; }; + 50FA45FD26813FCE001844AC /* ParticipantGridViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantGridViewModel.swift; sourceTree = ""; }; + 50FA46012681409D001844AC /* ParticipantGridCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantGridCellView.swift; sourceTree = ""; }; + 50FA460B26829162001844AC /* IconWithLabelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithLabelButton.swift; sourceTree = ""; }; + 50FA460D26829345001844AC /* InfoHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoHeaderViewModel.swift; sourceTree = ""; }; + 50FA460F26829B7A001844AC /* ControlBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlBarViewModel.swift; sourceTree = ""; }; + 50FA4615268D13E9001844AC /* LocalUserReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUserReducerTests.swift; sourceTree = ""; }; + 50FA46172696214C001844AC /* CallingMiddlewareHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingMiddlewareHandlerTests.swift; sourceTree = ""; }; + 50FA461C2698DCEB001844AC /* IconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButton.swift; sourceTree = ""; }; + 50FA46202698EA7A001844AC /* IconWithLabelButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithLabelButtonViewModel.swift; sourceTree = ""; }; + 50FA4622269CA1D3001844AC /* IconButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButtonViewModel.swift; sourceTree = ""; }; + 5A3140924D957941B2CE1AED /* TeamsMeetingOptionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TeamsMeetingOptionsTests.swift; sourceTree = ""; }; + 5A3141390FEFB58AD2A2E22F /* LocalVideoViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalVideoViewModelTests.swift; sourceTree = ""; }; + 5A314317E4A1B700E6BE5E2E /* LocalVideoViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalVideoViewModel.swift; sourceTree = ""; }; + 5A31447C18BC49B691800C9D /* ACSCameraFacingExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ACSCameraFacingExtension.swift; sourceTree = ""; }; + 5A3145FC912A71B20D5ECBF6 /* CompositeViewFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompositeViewFactory.swift; sourceTree = ""; }; + 5A3146B0B684264EB57EDA69 /* ErrorMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorMocking.swift; sourceTree = ""; }; + 5A3148DD49F9B22A8E1C3D3F /* LobbyOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LobbyOverlayView.swift; sourceTree = ""; }; + 5A3148FC4DC06C7515E1E8D9 /* CompositeViewModelFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompositeViewModelFactory.swift; sourceTree = ""; }; + 5A314A985811198B09DA2688 /* LocalVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalVideoView.swift; sourceTree = ""; }; + 5A314F041B5D1869C41C242E /* DependencyContainerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DependencyContainerTests.swift; sourceTree = ""; }; + 5A314F62143032D2313CB616 /* GroupCallOptionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallOptionsTests.swift; sourceTree = ""; }; + 5CFD410F47A7F36849928867 /* Pods-AzureCommunicationUI-AzureCommunicationUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AzureCommunicationUI-AzureCommunicationUITests.release.xcconfig"; path = "Target Support Files/Pods-AzureCommunicationUI-AzureCommunicationUITests/Pods-AzureCommunicationUI-AzureCommunicationUITests.release.xcconfig"; sourceTree = ""; }; + 88701EB32742D54C00660EAB /* ErrorReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorReducerTests.swift; sourceTree = ""; }; + 88701EB7274C29C600660EAB /* CallingMiddlewareErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingMiddlewareErrorHandler.swift; sourceTree = ""; }; + 88A7017F270E50D100F817B8 /* PopupModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupModalView.swift; sourceTree = ""; }; + 88B87C562755911B00290DD1 /* CallInfoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInfoModel.swift; sourceTree = ""; }; + 88F6EE192735F5CB001AD3E9 /* ErrorReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorReducer.swift; sourceTree = ""; }; + 88F6EE1B2735F8E8001AD3E9 /* ErrorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorState.swift; sourceTree = ""; }; + 981ACABB2685459400CD6A40 /* IconProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconProvider.swift; sourceTree = ""; }; + 9823AB4826A13F220006266D /* PreviewAreaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAreaView.swift; sourceTree = ""; }; + 9823AB4A26A13F4B0006266D /* PreviewAreaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAreaViewModel.swift; sourceTree = ""; }; + 982C282D267C0C7500427246 /* Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icon.swift; sourceTree = ""; }; + 982C2832267D0C4500427246 /* CompositeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeButton.swift; sourceTree = ""; }; + 982C2834267D15AF00427246 /* CompositeAvatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeAvatar.swift; sourceTree = ""; }; + 9852C2D226EFCF3D00435252 /* PopupMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupMenuViewController.swift; sourceTree = ""; }; + 9852C2D426EFD19300435252 /* ParticipantsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsListViewController.swift; sourceTree = ""; }; + 98546EB526D9B9990069B246 /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = ""; }; + 98546EB926DEF24D0069B246 /* AudioDeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceType.swift; sourceTree = ""; }; + 98546EBB26E1A8390069B246 /* AudioDeviceListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceListViewModel.swift; sourceTree = ""; }; + 985DF36B269650C900CFDA55 /* PreviewAreaViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAreaViewModelTests.swift; sourceTree = ""; }; + 985DF371269D065400CFDA55 /* ViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtension.swift; sourceTree = ""; }; + 985DF373269D096000CFDA55 /* SetupControlBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupControlBarView.swift; sourceTree = ""; }; + 985DF375269E07E700CFDA55 /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; + 985DF377269E07F400CFDA55 /* PrimaryButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButtonViewModel.swift; sourceTree = ""; }; + 9885BA0626A2116200CC514B /* SetupControlBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupControlBarViewModel.swift; sourceTree = ""; }; + 988799902745EABE00AA3759 /* SourceViewSpace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceViewSpace.swift; sourceTree = ""; }; + 988799922746CDEC00AA3759 /* LockPhoneOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockPhoneOrientation.swift; sourceTree = ""; }; + 98966FE226A5E58D00069857 /* SetupControlBarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupControlBarViewModelTests.swift; sourceTree = ""; }; + 98E8564526F5533200ECA304 /* PopupMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupMenuViewModel.swift; sourceTree = ""; }; + 98E8564726FA609D00ECA304 /* AudioDeviceListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceListViewModelTests.swift; sourceTree = ""; }; + 98F13AA526E2E5D900145E5A /* CompositePopupMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositePopupMenu.swift; sourceTree = ""; }; + 98FFD82726CC5FD900655398 /* PreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceKey.swift; sourceTree = ""; }; + A8100384269E1B370015C26E /* GroupCallOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallOptions.swift; sourceTree = ""; }; + A8100386269F9DA70015C26E /* TeamsMeetingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamsMeetingOptions.swift; sourceTree = ""; }; + A810038A26A0C1740015C26E /* CallConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallConfiguration.swift; sourceTree = ""; }; + A830C305264C3B6400766E3D /* AppStateReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateReducerTests.swift; sourceTree = ""; }; + A830C309264C8AAC00766E3D /* CallingMiddlewareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingMiddlewareTests.swift; sourceTree = ""; }; + A830C30B264D7C0900766E3D /* PermissionReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReducerTests.swift; sourceTree = ""; }; + A830C316264D8A0C00766E3D /* ContainerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerViewModelTests.swift; sourceTree = ""; }; + A8373979261CE9FF00D46787 /* DependancyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependancyContainer.swift; sourceTree = ""; }; + A837397D261CEA4300D46787 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + A8373993261F9D7600D46787 /* CallCompositeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallCompositeOptions.swift; sourceTree = ""; }; + A8659FC52602A22000C807DE /* AzureCommunicationUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AzureCommunicationUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A8659FC92602A22000C807DE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A869A62F26546A2D003CC4F2 /* CallingServiceMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CallingServiceMocking.swift; path = Mocking/CallingServiceMocking.swift; sourceTree = ""; }; + A869A63026546A2D003CC4F2 /* LoggerMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LoggerMocking.swift; path = Mocking/LoggerMocking.swift; sourceTree = ""; }; + A869A63126546A2D003CC4F2 /* MiddlewareMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MiddlewareMocking.swift; path = Mocking/MiddlewareMocking.swift; sourceTree = ""; }; + A869A63226546A2D003CC4F2 /* PermissionsManagerMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PermissionsManagerMocking.swift; path = Mocking/PermissionsManagerMocking.swift; sourceTree = ""; }; + A869A63326546A2D003CC4F2 /* StateMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = StateMocking.swift; path = Mocking/StateMocking.swift; sourceTree = ""; }; + A869A63426546A2D003CC4F2 /* ReducerMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ReducerMocking.swift; path = Mocking/ReducerMocking.swift; sourceTree = ""; }; + A869A63526546A2D003CC4F2 /* CallingSDKWrapperMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CallingSDKWrapperMocking.swift; path = Mocking/CallingSDKWrapperMocking.swift; sourceTree = ""; }; + A869A63626546A2E003CC4F2 /* VideoViewManagerMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = VideoViewManagerMocking.swift; path = Mocking/VideoViewManagerMocking.swift; sourceTree = ""; }; + A869A63726546A2E003CC4F2 /* StoreFactoryMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = StoreFactoryMocking.swift; path = Mocking/StoreFactoryMocking.swift; sourceTree = ""; }; + A874EDC3266152AA003C7D92 /* AppLifeCycleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifeCycleManager.swift; sourceTree = ""; }; + A874EDC526616728003C7D92 /* AppLifeCycleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifeCycleState.swift; sourceTree = ""; }; + A874EDC726616792003C7D92 /* LifecycleAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleAction.swift; sourceTree = ""; }; + A874EDC9266167E5003C7D92 /* LifeCycleReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifeCycleReducer.swift; sourceTree = ""; }; + A874EDCB2666DF07003C7D92 /* ContainerUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerUIHostingController.swift; sourceTree = ""; }; + A8810CD7263CC46500C88545 /* NavigationRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouter.swift; sourceTree = ""; }; + A8810CDC263CC75200C88545 /* ContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerView.swift; sourceTree = ""; }; + A88CDFB8274FF9C10004E2F5 /* ErrorInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorInfoView.swift; sourceTree = ""; }; + A88CDFBB27505A410004E2F5 /* ErrorInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorInfoViewModel.swift; sourceTree = ""; }; + A8ED232C2645C637008A26B2 /* CallingServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallingServiceTests.swift; sourceTree = ""; }; + A8ED232F2649EA61008A26B2 /* NavigationRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterTests.swift; sourceTree = ""; }; + AEF91CC2CFA3B351A6C90172 /* Pods_AzureCommunicationUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AzureCommunicationUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D02090EE8283A8254134AA10 /* Pods_AzureCommunicationUI_AzureCommunicationUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AzureCommunicationUI_AzureCommunicationUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1FBCB6972645285100F57EEA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1FBCB69F2645285100F57EEA /* AzureCommunicationUI.framework in Frameworks */, + 0F521429FD06F4F9B397730A /* Pods_AzureCommunicationUI_AzureCommunicationUITests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8659FC22602A22000C807DE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4EFDD23E634871B55C951638 /* Pods_AzureCommunicationUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1F03D3CC2638A6860055C456 /* Calling */ = { + isa = PBXGroup; + children = ( + 1F4B0EFF26A2024100E87014 /* Grid */, + 50FA45F6267D45FD001844AC /* CallingViewComponent */, + 1F03D3CD2638A6AE0055C456 /* CallingView.swift */, + 1F03D3D12638A6B70055C456 /* CallingViewModel.swift */, + ); + path = Calling; + sourceTree = ""; + }; + 1F04A64E273491230028AAC8 /* Button */ = { + isa = PBXGroup; + children = ( + 50FA460B26829162001844AC /* IconWithLabelButton.swift */, + 50FA46202698EA7A001844AC /* IconWithLabelButtonViewModel.swift */, + 50FA461C2698DCEB001844AC /* IconButton.swift */, + 50FA4622269CA1D3001844AC /* IconButtonViewModel.swift */, + 985DF375269E07E700CFDA55 /* PrimaryButton.swift */, + 985DF377269E07F400CFDA55 /* PrimaryButtonViewModel.swift */, + ); + path = Button; + sourceTree = ""; + }; + 1F04A64F273491620028AAC8 /* VideoView */ = { + isa = PBXGroup; + children = ( + 5A314317E4A1B700E6BE5E2E /* LocalVideoViewModel.swift */, + 5A314A985811198B09DA2688 /* LocalVideoView.swift */, + 1FBCB6A92645AC2F00F57EEA /* VideoRenderView.swift */, + ); + path = VideoView; + sourceTree = ""; + }; + 1F04A650273491780028AAC8 /* Drawer */ = { + isa = PBXGroup; + children = ( + 98546EBB26E1A8390069B246 /* AudioDeviceListViewModel.swift */, + 98E8564526F5533200ECA304 /* PopupMenuViewModel.swift */, + 988799902745EABE00AA3759 /* SourceViewSpace.swift */, + ); + path = Drawer; + sourceTree = ""; + }; + 1F04A651273496670028AAC8 /* Utilities */ = { + isa = PBXGroup; + children = ( + 50137D0526A7479C00AB843E /* EnvironmentValuesExtension.swift */, + 985DF371269D065400CFDA55 /* ViewExtension.swift */, + 98FFD82726CC5FD900655398 /* PreferenceKey.swift */, + 1FFEA01326A68D0D00E90816 /* UIFontExtension.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 1F09A10226BA472A00BACED7 /* Builder */ = { + isa = PBXGroup; + children = ( + 1F09A10326BA472A00BACED7 /* ParticipantInfoModelBuilder.swift */, + ); + path = Builder; + sourceTree = ""; + }; + 1F09A10B26BA484000BACED7 /* Calling */ = { + isa = PBXGroup; + children = ( + 1F09A10C26BA484000BACED7 /* ControlBarViewModelTests.swift */, + 1F09A10E26BA484000BACED7 /* ParticipantCellViewModelTests.swift */, + 1F09A10F26BA484000BACED7 /* CallingViewModelTests.swift */, + 1F09A11026BA484000BACED7 /* ParticipantGridsViewModelTests.swift */, + 50E2D68826D9495B006B0EF4 /* InfoHeaderViewModelTests.swift */, + 50E2D6A826FA52A3006B0EF4 /* ParticipantsListViewModelTests.swift */, + 503300FB2706763800289BB5 /* BannerViewModelTests.swift */, + 503300FD27079FC900289BB5 /* BannerTextViewModelTests.swift */, + 50C6DECE270E4F020085D04B /* BannerInfoTypeTests.swift */, + ); + path = Calling; + sourceTree = ""; + }; + 1F0E79982695109500BF349F /* SetupViewComponent */ = { + isa = PBXGroup; + children = ( + 985DF373269D096000CFDA55 /* SetupControlBarView.swift */, + 9885BA0626A2116200CC514B /* SetupControlBarViewModel.swift */, + 9823AB4826A13F220006266D /* PreviewAreaView.swift */, + 9823AB4A26A13F4B0006266D /* PreviewAreaViewModel.swift */, + ); + path = SetupViewComponent; + sourceTree = ""; + }; + 1F11D2B92640AD2200D33838 /* Presentation */ = { + isa = PBXGroup; + children = ( + A8810CDF2640A3E100C88545 /* Factories */, + 1F94DAE22673F72000691D1E /* Manager */, + A8810CD6263CC45800C88545 /* Navigation */, + 1F94DADD2673E92300691D1E /* Style */, + 1FBCB665264519DA00F57EEA /* VideoViewManager.swift */, + 982C282C267BF07300427246 /* FluentUI */, + 1F2BED60262F848000D98266 /* SwiftUI */, + ); + path = Presentation; + sourceTree = ""; + }; + 1F13D37126AB3DDB00E31666 /* Extension */ = { + isa = PBXGroup; + children = ( + 1F87A7BA263A7EE900F49C7E /* CommunicationIdentifierExtension.swift */, + 1FBCB6B02649EB1200F57EEA /* RemoteParticipantExtension.swift */, + 1F13D37226AB3F1200E31666 /* ACSCallingStateExtension.swift */, + 5A31447C18BC49B691800C9D /* ACSCameraFacingExtension.swift */, + ); + path = Extension; + sourceTree = ""; + }; + 1F2BED60262F848000D98266 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 1F04A651273496670028AAC8 /* Utilities */, + 50FA46192698BC01001844AC /* ViewComponents */, + A8810CD9263CC73600C88545 /* Container */, + 1F03D3CC2638A6860055C456 /* Calling */, + 1F2BED61262F848B00D98266 /* Setup */, + ); + path = SwiftUI; + sourceTree = ""; + }; + 1F2BED61262F848B00D98266 /* Setup */ = { + isa = PBXGroup; + children = ( + 1F0E79982695109500BF349F /* SetupViewComponent */, + 1F2BED63262F86E600D98266 /* SetupView.swift */, + 1F2BED67262F86F400D98266 /* SetupViewModel.swift */, + ); + path = Setup; + sourceTree = ""; + }; + 1F2BED62262F859D00D98266 /* Redux */ = { + isa = PBXGroup; + children = ( + 1F2BED6B262F8B7000D98266 /* Store.swift */, + 1F9B3B2C26433831000CED4B /* Middleware */, + 1F2BEDCB26329E4600D98266 /* Action */, + 1F2BEDBE26328EFA00D98266 /* State */, + 1F2BEDB526328E9D00D98266 /* Reducer */, + ); + path = Redux; + sourceTree = ""; + }; + 1F2BED862631EB5100D98266 /* Service */ = { + isa = PBXGroup; + children = ( + 1F9B3B2D2643A3D1000CED4B /* Calling */, + ); + path = Service; + sourceTree = ""; + }; + 1F2BEDB526328E9D00D98266 /* Reducer */ = { + isa = PBXGroup; + children = ( + 1F2BED71262F8F4D00D98266 /* Reducer.swift */, + 1F2BEDBA26328EBA00D98266 /* PermissionReducer.swift */, + 1FBCB6D82655825200F57EEA /* LocalUserReducer.swift */, + A874EDC9266167E5003C7D92 /* LifeCycleReducer.swift */, + 1F94DAE72676885700691D1E /* AppStateReducer.swift */, + 1F13D36F26AB293700E31666 /* NavigationReducer.swift */, + 88F6EE192735F5CB001AD3E9 /* ErrorReducer.swift */, + 1F83813E26803ADF0096A454 /* CallingReducer.swift */, + ); + path = Reducer; + sourceTree = ""; + }; + 1F2BEDBE26328EFA00D98266 /* State */ = { + isa = PBXGroup; + children = ( + 1F2BED75262F944500D98266 /* ReduxState.swift */, + 1F2BED982631FA6C00D98266 /* PermissionState.swift */, + 1F2BEDBF26328F2700D98266 /* CallingState.swift */, + 1FBCB6D62654765D00F57EEA /* LocalUserState.swift */, + A874EDC526616728003C7D92 /* AppLifeCycleState.swift */, + 1F94DAE526740D4400691D1E /* AppState.swift */, + 1F13D36D26AA82D400E31666 /* NavigationState.swift */, + 1F13D36B26A8B83300E31666 /* RemoteParticipantsState.swift */, + 88F6EE1B2735F8E8001AD3E9 /* ErrorState.swift */, + ); + path = State; + sourceTree = ""; + }; + 1F2BEDCB26329E4600D98266 /* Action */ = { + isa = PBXGroup; + children = ( + 1F2BEDA82632530D00D98266 /* Action.swift */, + 1F2BEDCC26329E5600D98266 /* PermissionAction.swift */, + 1F2BEDD026329E7C00D98266 /* CallingAction.swift */, + A874EDC726616792003C7D92 /* LifecycleAction.swift */, + 1F94DAE326740BCB00691D1E /* LocalUserAction.swift */, + ); + path = Action; + sourceTree = ""; + }; + 1F4B0EF6269BD08100E87014 /* Manager */ = { + isa = PBXGroup; + children = ( + 1F4B0EF7269BD17600E87014 /* CompositeErrorManagerTests.swift */, + ); + path = Manager; + sourceTree = ""; + }; + 1F4B0EFB269BD3D800E87014 /* Redux */ = { + isa = PBXGroup; + children = ( + A869A63126546A2D003CC4F2 /* MiddlewareMocking.swift */, + A869A63426546A2D003CC4F2 /* ReducerMocking.swift */, + A869A63326546A2D003CC4F2 /* StateMocking.swift */, + 1F91849826B31B640049EF5B /* ActionMocking.swift */, + A869A63726546A2E003CC4F2 /* StoreFactoryMocking.swift */, + ); + name = Redux; + sourceTree = ""; + }; + 1F4B0EFF26A2024100E87014 /* Grid */ = { + isa = PBXGroup; + children = ( + 50FA45FB26813FB8001844AC /* ParticipantGridView.swift */, + 50FA45FD26813FCE001844AC /* ParticipantGridViewModel.swift */, + 1F4B0F0026A2025900E87014 /* ParticipantGridLayoutView.swift */, + 1FBCB6B7264BA8A900F57EEA /* Cell */, + ); + path = Grid; + sourceTree = ""; + }; + 1F6470AA2639FE560008B9E9 /* Utilities */ = { + isa = PBXGroup; + children = ( + 1F87A7B8263A7E6E00F49C7E /* MappedSequence.swift */, + 1F6470AB2639FE670008B9E9 /* CancelBag.swift */, + 1FC30845266949E8007EC537 /* ColorExtension.swift */, + 1F94DAF226772B3C00691D1E /* UIWindowExtension.swift */, + 1F09A11C26BB5B0D00BACED7 /* ArrayExtension.swift */, + 1FE1BE7E26FE38C9000A8D18 /* CompsiteError.swift */, + 1B70D52727191073007CC933 /* DeviceExtension.swift */, + 1B79BD4F273F2D87002D5A36 /* UIViewControllerExtension.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 1F7BF43C2707D84A00974139 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 1F7BF43D2707D86000974139 /* BannerTextViewModelMocking.swift */, + ); + name = ViewModels; + sourceTree = ""; + }; + 1F94DADD2673E92300691D1E /* Style */ = { + isa = PBXGroup; + children = ( + 1FCB056A2671D8BE00126A4E /* StyleProvider.swift */, + 1FA24BAC2666F0CC00B4A080 /* ColorThemeProvider.swift */, + 981ACABB2685459400CD6A40 /* IconProvider.swift */, + ); + path = Style; + sourceTree = ""; + }; + 1F94DAE22673F72000691D1E /* Manager */ = { + isa = PBXGroup; + children = ( + 98546EB926DEF24D0069B246 /* AudioDeviceType.swift */, + 98546EB526D9B9990069B246 /* AudioSessionManager.swift */, + 1F2BED872631EB7900D98266 /* PermissionsManager.swift */, + A874EDC3266152AA003C7D92 /* AppLifeCycleManager.swift */, + 1F8E0B77268D0EF000DDD18E /* CompositeErrorManager.swift */, + ); + path = Manager; + sourceTree = ""; + }; + 1F9B3B2C26433831000CED4B /* Middleware */ = { + isa = PBXGroup; + children = ( + 1F8E0B712684EBC100DDD18E /* CallingMiddlewareHandler.swift */, + 1F2BEDA4263252E100D98266 /* Middleware.swift */, + 1F11D2BC2640BD9700D33838 /* CallingMiddleware.swift */, + 88701EB7274C29C600660EAB /* CallingMiddlewareErrorHandler.swift */, + ); + path = Middleware; + sourceTree = ""; + }; + 1F9B3B2D2643A3D1000CED4B /* Calling */ = { + isa = PBXGroup; + children = ( + 1F13D37126AB3DDB00E31666 /* Extension */, + 1F6470A82639DCFA0008B9E9 /* CallingService.swift */, + 1FBCB6AB2649993500F57EEA /* CallingSDKWrapper.swift */, + 1F4B0F0426A6469F00E87014 /* RemoteParticipantsEventsAdapter.swift */, + 1FD299652728B72B0084B9ED /* CallingSDKEventsHandler.swift */, + ); + path = Calling; + sourceTree = ""; + }; + 1FBCB69B2645285100F57EEA /* AzureCommunicationUITests */ = { + isa = PBXGroup; + children = ( + 1F09A10226BA472A00BACED7 /* Builder */, + A869A62E26546A16003CC4F2 /* Mocking */, + A830C311264D817F00766E3D /* Redux */, + A830C310264D815E00766E3D /* Presentation */, + A830C30E264D80F800766E3D /* Service */, + 1F4B0EF6269BD08100E87014 /* Manager */, + 1FBCB6A7264528A900F57EEA /* Info.plist */, + 5A314192C1EC6B1FB3D9A707 /* CallCompositeOptions */, + 5A31454AEB090BC1B66FB680 /* DI */, + ); + path = AzureCommunicationUITests; + sourceTree = ""; + }; + 1FBCB6AD2649DA4100F57EEA /* Model */ = { + isa = PBXGroup; + children = ( + 1FBCB6AE2649DC7900F57EEA /* ParticipantInfoModel.swift */, + 1FA23F9D265DA21400B4A080 /* VideoStreamInfoModel.swift */, + 88B87C562755911B00290DD1 /* CallInfoModel.swift */, + ); + path = Model; + sourceTree = ""; + }; + 1FBCB6B7264BA8A900F57EEA /* Cell */ = { + isa = PBXGroup; + children = ( + 1FBCB6B8264BA90000F57EEA /* ParticipantGridCellVideoView.swift */, + 1FBCB6B5264BA8A200F57EEA /* ParticipantGridCellViewModel.swift */, + 50FA46012681409D001844AC /* ParticipantGridCellView.swift */, + ); + path = Cell; + sourceTree = ""; + }; + 3D2417886FF0829BD3CB9E96 /* Pods */ = { + isa = PBXGroup; + children = ( + 1DD8A6B167FDF205AD440568 /* Pods-AzureCommunicationUI.debug.xcconfig */, + 01D765D8184BB2E6A5879C73 /* Pods-AzureCommunicationUI.release.xcconfig */, + 1FCC1171618F8B6862B6118E /* Pods-AzureCommunicationUI-AzureCommunicationUITests.debug.xcconfig */, + 5CFD410F47A7F36849928867 /* Pods-AzureCommunicationUI-AzureCommunicationUITests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 503E361A26CC2CFE00158CB4 /* Factories */ = { + isa = PBXGroup; + children = ( + 503E361B26CC2D0900158CB4 /* CompositeViewModelFactoryTests.swift */, + ); + path = Factories; + sourceTree = ""; + }; + 50C6DECD270E49270085D04B /* Banner */ = { + isa = PBXGroup; + children = ( + 503F4B662704EDC800C17DA6 /* BannerView.swift */, + 503F4B682704EDD100C17DA6 /* BannerViewModel.swift */, + 503F4B6C2705065700C17DA6 /* BannerTextView.swift */, + 503F4B6A2704EE6800C17DA6 /* BannerTextViewModel.swift */, + 50C6DECB270E48BE0085D04B /* BannerInfoType.swift */, + ); + path = Banner; + sourceTree = ""; + }; + 50FA45F6267D45FD001844AC /* CallingViewComponent */ = { + isa = PBXGroup; + children = ( + 50C6DECD270E49270085D04B /* Banner */, + 50FA45F7267D466D001844AC /* InfoHeaderView.swift */, + 50FA460D26829345001844AC /* InfoHeaderViewModel.swift */, + 50FA45F4267D3BD3001844AC /* ControlBarView.swift */, + 50FA460F26829B7A001844AC /* ControlBarViewModel.swift */, + 50137D0326A245E100AB843E /* ConfirmLeaveOverlayView.swift */, + 5A3148DD49F9B22A8E1C3D3F /* LobbyOverlayView.swift */, + 50E2D69426EACB74006B0EF4 /* ParticipantsListViewModel.swift */, + 50E2D69826F4FA05006B0EF4 /* ParticipantsListCellViewModel.swift */, + ); + path = CallingViewComponent; + sourceTree = ""; + }; + 50FA46192698BC01001844AC /* ViewComponents */ = { + isa = PBXGroup; + children = ( + 988799922746CDEC00AA3759 /* LockPhoneOrientation.swift */, + 88A7017F270E50D100F817B8 /* PopupModalView.swift */, + 1F04A64E273491230028AAC8 /* Button */, + 1F04A650273491780028AAC8 /* Drawer */, + A88CDFBA275059EB0004E2F5 /* Error */, + 1F04A64F273491620028AAC8 /* VideoView */, + ); + path = ViewComponents; + sourceTree = ""; + }; + 5A314192C1EC6B1FB3D9A707 /* CallCompositeOptions */ = { + isa = PBXGroup; + children = ( + 5A3140924D957941B2CE1AED /* TeamsMeetingOptionsTests.swift */, + 5A314F62143032D2313CB616 /* GroupCallOptionsTests.swift */, + 1F48B40227496E2800B6E5F9 /* DiagnosticConfigTests.swift */, + ); + path = CallCompositeOptions; + sourceTree = ""; + }; + 5A3142665337D92211691536 /* ViewComponents */ = { + isa = PBXGroup; + children = ( + 5A3141390FEFB58AD2A2E22F /* LocalVideoViewModelTests.swift */, + 98E8564726FA609D00ECA304 /* AudioDeviceListViewModelTests.swift */, + ); + path = ViewComponents; + sourceTree = ""; + }; + 5A31454AEB090BC1B66FB680 /* DI */ = { + isa = PBXGroup; + children = ( + 5A314F041B5D1869C41C242E /* DependencyContainerTests.swift */, + ); + path = DI; + sourceTree = ""; + }; + 982C282C267BF07300427246 /* FluentUI */ = { + isa = PBXGroup; + children = ( + 982C2831267D0C3400427246 /* Wrapper */, + 982C282D267C0C7500427246 /* Icon.swift */, + ); + path = FluentUI; + sourceTree = ""; + }; + 982C2831267D0C3400427246 /* Wrapper */ = { + isa = PBXGroup; + children = ( + 982C2834267D15AF00427246 /* CompositeAvatar.swift */, + 982C2832267D0C4500427246 /* CompositeButton.swift */, + 98F13AA526E2E5D900145E5A /* CompositePopupMenu.swift */, + 50E2D69026EAAB33006B0EF4 /* CompositeParticipantsList.swift */, + 50E2D69626EBCD15006B0EF4 /* CompositeParticipantsListCell.swift */, + 1F47F78626E9E18E000AE4B7 /* DrawerContainerViewController.swift */, + 9852C2D226EFCF3D00435252 /* PopupMenuViewController.swift */, + 9852C2D426EFD19300435252 /* ParticipantsListViewController.swift */, + ); + path = Wrapper; + sourceTree = ""; + }; + 98966FE426A5E91A00069857 /* Setup */ = { + isa = PBXGroup; + children = ( + 1F09A10926BA47EC00BACED7 /* SetupViewModelTests.swift */, + 98966FE226A5E58D00069857 /* SetupControlBarViewModelTests.swift */, + 985DF36B269650C900CFDA55 /* PreviewAreaViewModelTests.swift */, + ); + path = Setup; + sourceTree = ""; + }; + A830C30D264D80E600766E3D /* Middleware */ = { + isa = PBXGroup; + children = ( + A830C309264C8AAC00766E3D /* CallingMiddlewareTests.swift */, + 50FA46172696214C001844AC /* CallingMiddlewareHandlerTests.swift */, + ); + path = Middleware; + sourceTree = ""; + }; + A830C30E264D80F800766E3D /* Service */ = { + isa = PBXGroup; + children = ( + A8ED232C2645C637008A26B2 /* CallingServiceTests.swift */, + A8ED232F2649EA61008A26B2 /* NavigationRouterTests.swift */, + ); + path = Service; + sourceTree = ""; + }; + A830C30F264D810A00766E3D /* Reducer */ = { + isa = PBXGroup; + children = ( + A830C30B264D7C0900766E3D /* PermissionReducerTests.swift */, + A830C305264C3B6400766E3D /* AppStateReducerTests.swift */, + 50FA4615268D13E9001844AC /* LocalUserReducerTests.swift */, + 1F9184AC26B491840049EF5B /* LifeCycleReducerTests.swift */, + 1F9184B326B4A2EF0049EF5B /* CallingReducerTests.swift */, + 1F9184B526B4A3340049EF5B /* NavigationReducerTests.swift */, + 88701EB32742D54C00660EAB /* ErrorReducerTests.swift */, + ); + path = Reducer; + sourceTree = ""; + }; + A830C310264D815E00766E3D /* Presentation */ = { + isa = PBXGroup; + children = ( + 503E361A26CC2CFE00158CB4 /* Factories */, + 1F09A10B26BA484000BACED7 /* Calling */, + 98966FE426A5E91A00069857 /* Setup */, + A830C316264D8A0C00766E3D /* ContainerViewModelTests.swift */, + 5A3142665337D92211691536 /* ViewComponents */, + ); + path = Presentation; + sourceTree = ""; + }; + A830C311264D817F00766E3D /* Redux */ = { + isa = PBXGroup; + children = ( + A830C30D264D80E600766E3D /* Middleware */, + A830C30F264D810A00766E3D /* Reducer */, + ); + path = Redux; + sourceTree = ""; + }; + A83739AF26210CC200D46787 /* CallCompositeOptions */ = { + isa = PBXGroup; + children = ( + A8373993261F9D7600D46787 /* CallCompositeOptions.swift */, + 1F94DADB2673E87700691D1E /* ThemeConfiguration.swift */, + 1F94DAE02673EA9500691D1E /* CallCompositeEventsHandler.swift */, + 1F94DAF42677D48400691D1E /* ErrorEvent.swift */, + A8100384269E1B370015C26E /* GroupCallOptions.swift */, + A8100386269F9DA70015C26E /* TeamsMeetingOptions.swift */, + A810038A26A0C1740015C26E /* CallConfiguration.swift */, + 1F48B400274879F000B6E5F9 /* DiagnosticConfig.swift */, + ); + path = CallCompositeOptions; + sourceTree = ""; + }; + A8659FBB2602A22000C807DE = { + isa = PBXGroup; + children = ( + A8659FC72602A22000C807DE /* AzureCommunicationUI */, + 1FBCB69B2645285100F57EEA /* AzureCommunicationUITests */, + A8659FC62602A22000C807DE /* Products */, + 3D2417886FF0829BD3CB9E96 /* Pods */, + D925FB8A0861AF0C1415C6C4 /* Frameworks */, + ); + sourceTree = ""; + }; + A8659FC62602A22000C807DE /* Products */ = { + isa = PBXGroup; + children = ( + A8659FC52602A22000C807DE /* AzureCommunicationUI.framework */, + 1FBCB69A2645285100F57EEA /* AzureCommunicationUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + A8659FC72602A22000C807DE /* AzureCommunicationUI */ = { + isa = PBXGroup; + children = ( + 1F94DADE2673E94F00691D1E /* CallComposite.swift */, + 1F11D2B92640AD2200D33838 /* Presentation */, + A83739AF26210CC200D46787 /* CallCompositeOptions */, + A8810CDE2640A39700C88545 /* DI */, + A8C35ADC2626190E00E43F53 /* Logger */, + 1F2BED62262F859D00D98266 /* Redux */, + 1FBCB6AD2649DA4100F57EEA /* Model */, + 1F2BED862631EB5100D98266 /* Service */, + 1F6470AA2639FE560008B9E9 /* Utilities */, + A8659FC92602A22000C807DE /* Info.plist */, + 1FC308432668166C007EC537 /* Assets.xcassets */, + ); + path = AzureCommunicationUI; + sourceTree = ""; + }; + A869A62E26546A16003CC4F2 /* Mocking */ = { + isa = PBXGroup; + children = ( + 1F7BF43C2707D84A00974139 /* ViewModels */, + 1F4B0EFB269BD3D800E87014 /* Redux */, + A869A63526546A2D003CC4F2 /* CallingSDKWrapperMocking.swift */, + A869A62F26546A2D003CC4F2 /* CallingServiceMocking.swift */, + A869A63026546A2D003CC4F2 /* LoggerMocking.swift */, + A869A63226546A2D003CC4F2 /* PermissionsManagerMocking.swift */, + A869A63626546A2E003CC4F2 /* VideoViewManagerMocking.swift */, + 1F8E0B73268697B700DDD18E /* CallingMiddlewareHandlerMocking.swift */, + 1F4B0EF9269BD3D000E87014 /* CallCompositeMocking.swift */, + 1F4A24BB26D6F5E600C11083 /* CompositeViewModelFactoryMocking.swift */, + 5A3146B0B684264EB57EDA69 /* ErrorMocking.swift */, + ); + name = Mocking; + sourceTree = ""; + }; + A8810CD6263CC45800C88545 /* Navigation */ = { + isa = PBXGroup; + children = ( + A8810CD7263CC46500C88545 /* NavigationRouter.swift */, + ); + path = Navigation; + sourceTree = ""; + }; + A8810CD9263CC73600C88545 /* Container */ = { + isa = PBXGroup; + children = ( + A8810CDC263CC75200C88545 /* ContainerView.swift */, + A874EDCB2666DF07003C7D92 /* ContainerUIHostingController.swift */, + ); + path = Container; + sourceTree = ""; + }; + A8810CDE2640A39700C88545 /* DI */ = { + isa = PBXGroup; + children = ( + A8373979261CE9FF00D46787 /* DependancyContainer.swift */, + ); + path = DI; + sourceTree = ""; + }; + A8810CDF2640A3E100C88545 /* Factories */ = { + isa = PBXGroup; + children = ( + 5A3145FC912A71B20D5ECBF6 /* CompositeViewFactory.swift */, + 5A3148FC4DC06C7515E1E8D9 /* CompositeViewModelFactory.swift */, + ); + path = Factories; + sourceTree = ""; + }; + A88CDFBA275059EB0004E2F5 /* Error */ = { + isa = PBXGroup; + children = ( + A88CDFB8274FF9C10004E2F5 /* ErrorInfoView.swift */, + A88CDFBB27505A410004E2F5 /* ErrorInfoViewModel.swift */, + ); + path = Error; + sourceTree = ""; + }; + A8C35ADC2626190E00E43F53 /* Logger */ = { + isa = PBXGroup; + children = ( + 1FD299632723345B0084B9ED /* DefaultLogger.swift */, + A837397D261CEA4300D46787 /* Logger.swift */, + ); + path = Logger; + sourceTree = ""; + }; + D925FB8A0861AF0C1415C6C4 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1FBCB682264522F200F57EEA /* XCTest.framework */, + AEF91CC2CFA3B351A6C90172 /* Pods_AzureCommunicationUI.framework */, + D02090EE8283A8254134AA10 /* Pods_AzureCommunicationUI_AzureCommunicationUITests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + A8659FC02602A22000C807DE /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 1FBCB6992645285100F57EEA /* AzureCommunicationUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1FBCB6A22645285100F57EEA /* Build configuration list for PBXNativeTarget "AzureCommunicationUITests" */; + buildPhases = ( + 56D8C17DA5FA0A63454DBF4F /* [CP] Check Pods Manifest.lock */, + 508E82962670110100ED93C6 /* Run SwiftLint */, + 1FBCB6962645285100F57EEA /* Sources */, + 1FBCB6972645285100F57EEA /* Frameworks */, + 1FBCB6982645285100F57EEA /* Resources */, + FC13CB8B582475470440657E /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 1FBCB6A12645285100F57EEA /* PBXTargetDependency */, + ); + name = AzureCommunicationUITests; + productName = AzureCommunicationUITests; + productReference = 1FBCB69A2645285100F57EEA /* AzureCommunicationUITests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A8659FC42602A22000C807DE /* AzureCommunicationUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = A8659FD92602A22000C807DE /* Build configuration list for PBXNativeTarget "AzureCommunicationUI" */; + buildPhases = ( + DE4C22BAF3D6FDD9D8F2F421 /* [CP] Check Pods Manifest.lock */, + A8659FC02602A22000C807DE /* Headers */, + A8659FC12602A22000C807DE /* Sources */, + A8659FC22602A22000C807DE /* Frameworks */, + A8659FC32602A22000C807DE /* Resources */, + 1F7BF43F270D758800974139 /* Run SwiftLint */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AzureCommunicationUI; + productName = AzureCommunicationUI; + productReference = A8659FC52602A22000C807DE /* AzureCommunicationUI.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A8659FBC2602A22000C807DE /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1250; + LastUpgradeCheck = 1300; + TargetAttributes = { + 1FBCB6992645285100F57EEA = { + CreatedOnToolsVersion = 12.5; + }; + A8659FC42602A22000C807DE = { + CreatedOnToolsVersion = 12.4; + LastSwiftMigration = 1240; + }; + }; + }; + buildConfigurationList = A8659FBF2602A22000C807DE /* Build configuration list for PBXProject "AzureCommunicationUI" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A8659FBB2602A22000C807DE; + productRefGroup = A8659FC62602A22000C807DE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A8659FC42602A22000C807DE /* AzureCommunicationUI */, + 1FBCB6992645285100F57EEA /* AzureCommunicationUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1FBCB6982645285100F57EEA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8659FC32602A22000C807DE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1FC308442668166C007EC537 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1F7BF43F270D758800974139 /* Run SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run SwiftLint"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "${PODS_ROOT}/SwiftLint/swiftlint lint --strict --config .swiftlint.yml\n"; + }; + 508E82962670110100ED93C6 /* Run SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run SwiftLint"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "${PODS_ROOT}/SwiftLint/swiftlint lint --strict --config .swiftlint.yml\n"; + }; + 56D8C17DA5FA0A63454DBF4F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-AzureCommunicationUI-AzureCommunicationUITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DE4C22BAF3D6FDD9D8F2F421 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-AzureCommunicationUI-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FC13CB8B582475470440657E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-AzureCommunicationUI-AzureCommunicationUITests/Pods-AzureCommunicationUI-AzureCommunicationUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-AzureCommunicationUI-AzureCommunicationUITests/Pods-AzureCommunicationUI-AzureCommunicationUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AzureCommunicationUI-AzureCommunicationUITests/Pods-AzureCommunicationUI-AzureCommunicationUITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1FBCB6962645285100F57EEA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A830C317264D8A0C00766E3D /* ContainerViewModelTests.swift in Sources */, + A8ED23302649EA61008A26B2 /* NavigationRouterTests.swift in Sources */, + A869A63D26546A2E003CC4F2 /* ReducerMocking.swift in Sources */, + 503E361C26CC2D0900158CB4 /* CompositeViewModelFactoryTests.swift in Sources */, + 503300FC2706763800289BB5 /* BannerViewModelTests.swift in Sources */, + A869A63F26546A2E003CC4F2 /* VideoViewManagerMocking.swift in Sources */, + A830C306264C3B6400766E3D /* AppStateReducerTests.swift in Sources */, + 1F4B0EFA269BD3D000E87014 /* CallCompositeMocking.swift in Sources */, + 985DF36C269650C900CFDA55 /* PreviewAreaViewModelTests.swift in Sources */, + A830C30A264C8AAC00766E3D /* CallingMiddlewareTests.swift in Sources */, + A869A64026546A2E003CC4F2 /* StoreFactoryMocking.swift in Sources */, + 1F9184B426B4A2EF0049EF5B /* CallingReducerTests.swift in Sources */, + 1F4B0EF8269BD17600E87014 /* CompositeErrorManagerTests.swift in Sources */, + A869A63826546A2E003CC4F2 /* CallingServiceMocking.swift in Sources */, + A869A63C26546A2E003CC4F2 /* StateMocking.swift in Sources */, + 1F09A11326BA484000BACED7 /* ParticipantCellViewModelTests.swift in Sources */, + 50FA46182696214D001844AC /* CallingMiddlewareHandlerTests.swift in Sources */, + 50E2D68926D9495B006B0EF4 /* InfoHeaderViewModelTests.swift in Sources */, + 98E8564826FA609D00ECA304 /* AudioDeviceListViewModelTests.swift in Sources */, + 1F48B40327496E2800B6E5F9 /* DiagnosticConfigTests.swift in Sources */, + 1F9184AD26B491840049EF5B /* LifeCycleReducerTests.swift in Sources */, + A869A63E26546A2E003CC4F2 /* CallingSDKWrapperMocking.swift in Sources */, + 88701EB42742D54C00660EAB /* ErrorReducerTests.swift in Sources */, + 1F09A11426BA484000BACED7 /* CallingViewModelTests.swift in Sources */, + 50E2D6A926FA52A3006B0EF4 /* ParticipantsListViewModelTests.swift in Sources */, + 1F09A11126BA484000BACED7 /* ControlBarViewModelTests.swift in Sources */, + 50FA4616268D13E9001844AC /* LocalUserReducerTests.swift in Sources */, + 1F4A24BC26D6F5E600C11083 /* CompositeViewModelFactoryMocking.swift in Sources */, + 503300FE27079FC900289BB5 /* BannerTextViewModelTests.swift in Sources */, + 1F8E0B74268697B700DDD18E /* CallingMiddlewareHandlerMocking.swift in Sources */, + A869A63A26546A2E003CC4F2 /* MiddlewareMocking.swift in Sources */, + 1F91849926B31B640049EF5B /* ActionMocking.swift in Sources */, + 50C6DECF270E4F020085D04B /* BannerInfoTypeTests.swift in Sources */, + A8ED232D2645C637008A26B2 /* CallingServiceTests.swift in Sources */, + 1F09A11526BA484000BACED7 /* ParticipantGridsViewModelTests.swift in Sources */, + 98966FE326A5E58D00069857 /* SetupControlBarViewModelTests.swift in Sources */, + A830C30C264D7C0900766E3D /* PermissionReducerTests.swift in Sources */, + A869A63B26546A2E003CC4F2 /* PermissionsManagerMocking.swift in Sources */, + 1F09A10A26BA47EC00BACED7 /* SetupViewModelTests.swift in Sources */, + A869A63926546A2E003CC4F2 /* LoggerMocking.swift in Sources */, + 1F9184B626B4A3340049EF5B /* NavigationReducerTests.swift in Sources */, + 5A31416F1C27AA7C96202B17 /* TeamsMeetingOptionsTests.swift in Sources */, + 5A314750E50CDF6A6F4577E5 /* GroupCallOptionsTests.swift in Sources */, + 5A31429C377C213B53F19163 /* DependencyContainerTests.swift in Sources */, + 1F09A10426BA472A00BACED7 /* ParticipantInfoModelBuilder.swift in Sources */, + 5A31489916982D3699CD756D /* LocalVideoViewModelTests.swift in Sources */, + 1F7BF43E2707D86000974139 /* BannerTextViewModelMocking.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8659FC12602A22000C807DE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1FBCB6B6264BA8A200F57EEA /* ParticipantGridCellViewModel.swift in Sources */, + 1F03D3CE2638A6AE0055C456 /* CallingView.swift in Sources */, + 1B79BD50273F2D87002D5A36 /* UIViewControllerExtension.swift in Sources */, + 503F4B692704EDD100C17DA6 /* BannerViewModel.swift in Sources */, + 982C2835267D15AF00427246 /* CompositeAvatar.swift in Sources */, + 50FA460C26829162001844AC /* IconWithLabelButton.swift in Sources */, + 50FA4623269CA1D4001844AC /* IconButtonViewModel.swift in Sources */, + 1F8E0B722684EBC100DDD18E /* CallingMiddlewareHandler.swift in Sources */, + 1F2BEDA5263252E100D98266 /* Middleware.swift in Sources */, + 98F13AA626E2E5D900145E5A /* CompositePopupMenu.swift in Sources */, + 9823AB4B26A13F4B0006266D /* PreviewAreaViewModel.swift in Sources */, + 1F6470A92639DCFA0008B9E9 /* CallingService.swift in Sources */, + 1F83813F26803ADF0096A454 /* CallingReducer.swift in Sources */, + 1F2BED64262F86E600D98266 /* SetupView.swift in Sources */, + A874EDCC2666DF07003C7D92 /* ContainerUIHostingController.swift in Sources */, + A8810CD8263CC46500C88545 /* NavigationRouter.swift in Sources */, + A8100385269E1B370015C26E /* GroupCallOptions.swift in Sources */, + 982C282E267C0C7500427246 /* Icon.swift in Sources */, + 50FA461D2698DCEC001844AC /* IconButton.swift in Sources */, + A837397E261CEA4300D46787 /* Logger.swift in Sources */, + 98546EB626D9B9990069B246 /* AudioSessionManager.swift in Sources */, + A874EDCA266167E5003C7D92 /* LifeCycleReducer.swift in Sources */, + 1FBCB6D72654765D00F57EEA /* LocalUserState.swift in Sources */, + 1F94DAE82676885700691D1E /* AppStateReducer.swift in Sources */, + 1F2BED68262F86F400D98266 /* SetupViewModel.swift in Sources */, + 9885BA0726A2116200CC514B /* SetupControlBarViewModel.swift in Sources */, + 1FD299662728B72B0084B9ED /* CallingSDKEventsHandler.swift in Sources */, + 1FBCB6AC2649993500F57EEA /* CallingSDKWrapper.swift in Sources */, + 1F2BED72262F8F4D00D98266 /* Reducer.swift in Sources */, + 50FA460E26829345001844AC /* InfoHeaderViewModel.swift in Sources */, + 503F4B6D2705065700C17DA6 /* BannerTextView.swift in Sources */, + 1F94DAF326772B3C00691D1E /* UIWindowExtension.swift in Sources */, + 1F94DAE12673EA9500691D1E /* CallCompositeEventsHandler.swift in Sources */, + 88701EB8274C29C600660EAB /* CallingMiddlewareErrorHandler.swift in Sources */, + 1F2BED992631FA6C00D98266 /* PermissionState.swift in Sources */, + 1F94DADF2673E94F00691D1E /* CallComposite.swift in Sources */, + 1B70D52827191073007CC933 /* DeviceExtension.swift in Sources */, + 50E2D69726EBCD15006B0EF4 /* CompositeParticipantsListCell.swift in Sources */, + A8373994261F9D7600D46787 /* CallCompositeOptions.swift in Sources */, + 88A70180270E50D100F817B8 /* PopupModalView.swift in Sources */, + A88CDFBC27505A410004E2F5 /* ErrorInfoViewModel.swift in Sources */, + A88CDFB9274FF9C10004E2F5 /* ErrorInfoView.swift in Sources */, + 50E2D69526EACB74006B0EF4 /* ParticipantsListViewModel.swift in Sources */, + 50FA45FC26813FB8001844AC /* ParticipantGridView.swift in Sources */, + 1F2BEDD126329E7C00D98266 /* CallingAction.swift in Sources */, + 985DF376269E07E700CFDA55 /* PrimaryButton.swift in Sources */, + 1FBCB6AF2649DC7900F57EEA /* ParticipantInfoModel.swift in Sources */, + 988799932746CDEC00AA3759 /* LockPhoneOrientation.swift in Sources */, + 1F47F78726E9E18E000AE4B7 /* DrawerContainerViewController.swift in Sources */, + 1FBCB6AA2645AC2F00F57EEA /* VideoRenderView.swift in Sources */, + 1F4B0EFE26A1466D00E87014 /* ParticipantGridCellView.swift in Sources */, + 1FBCB6D92655825200F57EEA /* LocalUserReducer.swift in Sources */, + A874EDC826616792003C7D92 /* LifecycleAction.swift in Sources */, + 9852C2D526EFD19300435252 /* ParticipantsListViewController.swift in Sources */, + 503F4B672704EDC800C17DA6 /* BannerView.swift in Sources */, + 1F2BED76262F944500D98266 /* ReduxState.swift in Sources */, + 50C6DECC270E48BE0085D04B /* BannerInfoType.swift in Sources */, + 1F03D3D22638A6B70055C456 /* CallingViewModel.swift in Sources */, + 50FA45FE26813FCE001844AC /* ParticipantGridViewModel.swift in Sources */, + 1F94DAF52677D48400691D1E /* ErrorEvent.swift in Sources */, + 1F94DAE626740D4400691D1E /* AppState.swift in Sources */, + 50137D0426A245E100AB843E /* ConfirmLeaveOverlayView.swift in Sources */, + 50137D0626A7479C00AB843E /* EnvironmentValuesExtension.swift in Sources */, + 1FD299642723345B0084B9ED /* DefaultLogger.swift in Sources */, + 1F2BEDA92632530D00D98266 /* Action.swift in Sources */, + 1FE1BE7F26FE38C9000A8D18 /* CompsiteError.swift in Sources */, + 985DF374269D096000CFDA55 /* SetupControlBarView.swift in Sources */, + 1FA24BAD2666F0CC00B4A080 /* ColorThemeProvider.swift in Sources */, + A810038B26A0C1740015C26E /* CallConfiguration.swift in Sources */, + 88B87C572755911B00290DD1 /* CallInfoModel.swift in Sources */, + 50FA45F8267D466D001844AC /* InfoHeaderView.swift in Sources */, + 1F13D37026AB293700E31666 /* NavigationReducer.swift in Sources */, + 50FA45F5267D3BD3001844AC /* ControlBarView.swift in Sources */, + 1FCB056B2671D8BE00126A4E /* StyleProvider.swift in Sources */, + 98E8564626F5533200ECA304 /* PopupMenuViewModel.swift in Sources */, + 1F2BEDBB26328EBA00D98266 /* PermissionReducer.swift in Sources */, + 1FBCB6B9264BA90000F57EEA /* ParticipantGridCellVideoView.swift in Sources */, + 1F48B401274879F000B6E5F9 /* DiagnosticConfig.swift in Sources */, + 98546EBC26E1A8390069B246 /* AudioDeviceListViewModel.swift in Sources */, + 985DF372269D065400CFDA55 /* ViewExtension.swift in Sources */, + 9823AB4926A13F220006266D /* PreviewAreaView.swift in Sources */, + 50FA46212698EA7A001844AC /* IconWithLabelButtonViewModel.swift in Sources */, + 50E2D69926F4FA05006B0EF4 /* ParticipantsListCellViewModel.swift in Sources */, + 1F09A11D26BB5B0D00BACED7 /* ArrayExtension.swift in Sources */, + A874EDC4266152AA003C7D92 /* AppLifeCycleManager.swift in Sources */, + 1F8E0B78268D0EF000DDD18E /* CompositeErrorManager.swift in Sources */, + 98546EBA26DEF24D0069B246 /* AudioDeviceType.swift in Sources */, + A8810CDD263CC75200C88545 /* ContainerView.swift in Sources */, + A8100387269F9DA70015C26E /* TeamsMeetingOptions.swift in Sources */, + 981ACABC2685459400CD6A40 /* IconProvider.swift in Sources */, + 1F2BEDCD26329E5600D98266 /* PermissionAction.swift in Sources */, + 1F94DADC2673E87700691D1E /* ThemeConfiguration.swift in Sources */, + 1F94DAE426740BCB00691D1E /* LocalUserAction.swift in Sources */, + 1F11D2BD2640BD9800D33838 /* CallingMiddleware.swift in Sources */, + 1F87A7B9263A7E6E00F49C7E /* MappedSequence.swift in Sources */, + 503F4B6B2704EE6800C17DA6 /* BannerTextViewModel.swift in Sources */, + 982C2833267D0C4500427246 /* CompositeButton.swift in Sources */, + 1F13D37326AB3F1200E31666 /* ACSCallingStateExtension.swift in Sources */, + 988799912745EABE00AA3759 /* SourceViewSpace.swift in Sources */, + 1FBCB6B12649EB1200F57EEA /* RemoteParticipantExtension.swift in Sources */, + 88F6EE1C2735F8E8001AD3E9 /* ErrorState.swift in Sources */, + 1F2BEDC026328F2700D98266 /* CallingState.swift in Sources */, + 9852C2D326EFCF3D00435252 /* PopupMenuViewController.swift in Sources */, + 88F6EE1A2735F5CB001AD3E9 /* ErrorReducer.swift in Sources */, + A874EDC626616728003C7D92 /* AppLifeCycleState.swift in Sources */, + 1F13D36C26A8B83300E31666 /* RemoteParticipantsState.swift in Sources */, + 1FFEA01426A68D0D00E90816 /* UIFontExtension.swift in Sources */, + 1FBCB666264519DA00F57EEA /* VideoViewManager.swift in Sources */, + 50FA461026829B7A001844AC /* ControlBarViewModel.swift in Sources */, + 1F2BED882631EB7900D98266 /* PermissionsManager.swift in Sources */, + 1FA23F9E265DA21400B4A080 /* VideoStreamInfoModel.swift in Sources */, + 98FFD82826CC5FD900655398 /* PreferenceKey.swift in Sources */, + 1F13D36E26AA82D400E31666 /* NavigationState.swift in Sources */, + 1F2BED6C262F8B7000D98266 /* Store.swift in Sources */, + 50E2D69126EAAB33006B0EF4 /* CompositeParticipantsList.swift in Sources */, + 985DF378269E07F400CFDA55 /* PrimaryButtonViewModel.swift in Sources */, + 1FC30846266949E8007EC537 /* ColorExtension.swift in Sources */, + 1F6470AC2639FE670008B9E9 /* CancelBag.swift in Sources */, + 1F4B0F0526A6469F00E87014 /* RemoteParticipantsEventsAdapter.swift in Sources */, + 1F11D2B2263CA10000D33838 /* CommunicationIdentifierExtension.swift in Sources */, + 1F4B0F0126A2025900E87014 /* ParticipantGridLayoutView.swift in Sources */, + A837397A261CE9FF00D46787 /* DependancyContainer.swift in Sources */, + 5A314F66ED6C53A7EFC6EDA3 /* LobbyOverlayView.swift in Sources */, + 5A3142646DD55C66CF290F7A /* LocalVideoViewModel.swift in Sources */, + 5A314059D7E61D1083A8C473 /* LocalVideoView.swift in Sources */, + 5A3140EA80ACDCB14140A060 /* CompositeViewFactory.swift in Sources */, + 5A3142DAFAB4EE338ABF2196 /* CompositeViewModelFactory.swift in Sources */, + 5A314D26A09E235A6509C1C7 /* ACSCameraFacingExtension.swift in Sources */, + 5A31497E0DEBAB0256AD0B91 /* ErrorMocking.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 1FBCB6A12645285100F57EEA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = A8659FC42602A22000C807DE /* AzureCommunicationUI */; + targetProxy = 1FBCB6A02645285100F57EEA /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1FBCB6A32645285100F57EEA /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1FCC1171618F8B6862B6118E /* Pods-AzureCommunicationUI-AzureCommunicationUITests.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 9KBH5RKYEW; + INFOPLIST_FILE = AzureCommunicationUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.azure.ios.communication.ui.meetings.AzureCommunicationUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1FBCB6A42645285100F57EEA /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5CFD410F47A7F36849928867 /* Pods-AzureCommunicationUI-AzureCommunicationUITests.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 9KBH5RKYEW; + INFOPLIST_FILE = AzureCommunicationUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.azure.ios.communication.ui.meetings.AzureCommunicationUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A8659FD72602A22000C807DE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + A8659FD82602A22000C807DE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_BITCODE = NO; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + A8659FDA2602A22000C807DE /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1DD8A6B167FDF205AD440568 /* Pods-AzureCommunicationUI.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = UBF8T346G9; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AzureCommunicationUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = "1.0.0-alpha.2"; + PRODUCT_BUNDLE_IDENTIFIER = com.azure.ios.communication.ui.meetings.AzureCommunicationUI; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A8659FDB2602A22000C807DE /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 01D765D8184BB2E6A5879C73 /* Pods-AzureCommunicationUI.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = UBF8T346G9; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AzureCommunicationUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = "1.0.0-alpha.2"; + PRODUCT_BUNDLE_IDENTIFIER = com.azure.ios.communication.ui.meetings.AzureCommunicationUI; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1FBCB6A22645285100F57EEA /* Build configuration list for PBXNativeTarget "AzureCommunicationUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1FBCB6A32645285100F57EEA /* Debug */, + 1FBCB6A42645285100F57EEA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A8659FBF2602A22000C807DE /* Build configuration list for PBXProject "AzureCommunicationUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8659FD72602A22000C807DE /* Debug */, + A8659FD82602A22000C807DE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A8659FD92602A22000C807DE /* Build configuration list for PBXNativeTarget "AzureCommunicationUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8659FDA2602A22000C807DE /* Debug */, + A8659FDB2602A22000C807DE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A8659FBC2602A22000C807DE /* Project object */; +} diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUI.xcscheme b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUI.xcscheme new file mode 100644 index 000000000..23bfb7d54 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUI.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/contents.xcworkspacedata b/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..4189fd7a6 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDETemplateMacros.plist b/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 000000000..6f93b2949 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,11 @@ + + + + + FILEHEADER + +//  Copyright (c) Microsoft Corporation. All rights reserved. +//  Licensed under the MIT License. +// + + diff --git a/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/ACSPrimaryColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/ACSPrimaryColor.colorset/Contents.json new file mode 100644 index 000000000..d7bfa7be6 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/ACSPrimaryColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.831", + "green" : "0.471", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.941", + "green" : "0.525", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/backgroundColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/backgroundColor.colorset/Contents.json new file mode 100644 index 000000000..524e4f34e --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/backgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x13", + "green" : "0x13", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/disabledColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/disabledColor.colorset/Contents.json new file mode 100644 index 000000000..c873708f2 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/disabledColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE1", + "green" : "0xE1", + "red" : "0xE1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x40", + "green" : "0x40", + "red" : "0x40" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/drawerColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/drawerColor.colorset/Contents.json new file mode 100644 index 000000000..a452b464a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/drawerColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x21", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/errorColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/errorColor.colorset/Contents.json new file mode 100644 index 000000000..1edb9fe6f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/errorColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2C", + "green" : "0x2C", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3A", + "green" : "0x3A", + "red" : "0xE8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gradientColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gradientColor.colorset/Contents.json new file mode 100644 index 000000000..6919df059 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gradientColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gridLayoutBackgroundColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gridLayoutBackgroundColor.colorset/Contents.json new file mode 100644 index 000000000..2960d7217 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/gridLayoutBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/hangupColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/hangupColor.colorset/Contents.json new file mode 100644 index 000000000..c3809e0f4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/hangupColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x43", + "green" : "0x2E", + "red" : "0xA4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x33", + "red" : "0xCC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/mute.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/mute.colorset/Contents.json new file mode 100644 index 000000000..7c7e45a61 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/mute.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.078", + "green" : "0.078", + "red" : "0.078" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x91", + "green" : "0x91", + "red" : "0x91" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6E", + "green" : "0x6E", + "red" : "0x6E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onBackgroundColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onBackgroundColor.colorset/Contents.json new file mode 100644 index 000000000..ea296a30c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x13", + "green" : "0x13", + "red" : "0x13" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onDisabledColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onDisabledColor.colorset/Contents.json new file mode 100644 index 000000000..aa6d9ed4b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onDisabledColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAC", + "green" : "0xAC", + "red" : "0xAC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onErrorColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onErrorColor.colorset/Contents.json new file mode 100644 index 000000000..855c60979 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onErrorColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onPrimaryColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onPrimaryColor.colorset/Contents.json new file mode 100644 index 000000000..855c60979 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onPrimaryColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSuccessColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSuccessColor.colorset/Contents.json new file mode 100644 index 000000000..855c60979 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSuccessColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSurfaceColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSurfaceColor.colorset/Contents.json new file mode 100644 index 000000000..8f066e43b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onSurfaceColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onWarningColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onWarningColor.colorset/Contents.json new file mode 100644 index 000000000..2c255dda6 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/onWarningColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x14", + "green" : "0x14", + "red" : "0x14" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/overlayColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/overlayColor.colorset/Contents.json new file mode 100644 index 000000000..9e5653aa7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/overlayColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0xE0", + "green" : "0xE0", + "red" : "0xE0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0x13", + "green" : "0x13", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/popoverColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/popoverColor.colorset/Contents.json new file mode 100644 index 000000000..3931ea8a7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/popoverColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x40", + "green" : "0x40", + "red" : "0x40" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/successColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/successColor.colorset/Contents.json new file mode 100644 index 000000000..fed69a832 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/successColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0E", + "green" : "0xA1", + "red" : "0x13" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x44", + "green" : "0xB2", + "red" : "0x0E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceColor.colorset/Contents.json new file mode 100644 index 000000000..63d38b272 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE1", + "green" : "0xE1", + "red" : "0xE1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x29", + "green" : "0x29", + "red" : "0x29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceDarkColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceDarkColor.colorset/Contents.json new file mode 100644 index 000000000..d05919ed9 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceDarkColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceLightColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceLightColor.colorset/Contents.json new file mode 100644 index 000000000..55cf57e64 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/surfaceLightColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/warningColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/warningColor.colorset/Contents.json new file mode 100644 index 000000000..589a8b5e3 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Color/warningColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x35", + "green" : "0xD3", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0xC3", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/Contents.json new file mode 100644 index 000000000..bc0e9c62d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_call_end_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/ic_fluent_call_end_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_call_end_24_filled.imageset/ic_fluent_call_end_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d17e34d8a3eaac925fee4c5a653e52f6a3ed703c GIT binary patch literal 1486 zcmZuxO^*~g487-9R)mtTL8ytSU){PGh+86PcyO3j zgQFH&rd+G%ptWR8{?@>f6AOC^nqzC^Zw$=ZBqlIvXdbz$n5=wTjFj0>8C7i(tM&VXCubno*hko&=UA^`}{y?3^I`^7qJp)K5QJ8RP8Zek?wcDDO#7{=}7KmLmy#?$@t>uc}3%jI>y z2mbD_*30MPPycalitXgxfzx3*?@kA%ML&(-H>-Z~c1tYwbm!x+?FVE;kB{Kxas$kx zw=;Bdkr;HmV7fQ!D@gTL!NMQ%oR8lTOAqul3D3ndi&x@@Bl=hg#9opG5rJ z^q(BY!R>CO*#e9iMi=n@A-FPs7>DO7v{Ql7fr5%y&YQtEr{~3RZ&;)e7^;1Q%vu)!XQ2>v_`8 z^J#LsX?`iS$^39Y%Lp`IJ!_mQ|J^ z1kQzwtgu8IObW_PjkP##nwkSvfMlCjymR!Sc=JI zhff69<%mv8w0vm+2|fqx7h-)aI)yc!h1E*?FTHh)PT8_o$HAq}SY&7eg^rpToRlVw5CbJXr)mI8MpVsB#U$@FJ-YqNgQP zB`Fn3tRYUJ1tFXSw%xPVH|aD}MJV@r7rLd`6uO-Q={4Q%Z5Yw4IInxI6iKhCq*5DF z0^Q>2SpwDL87)y)u^5}Vs8iN?YlLv0a0m$rUqFHh6i>IaMrsQz#Xz-jiewRNkWzDM z`sjd?K;%>EwLo05^}<|quGtw(rt?62R>>hLL_^PmgzAV?(-(atR$$o?WSvT`BXGt4acE&X6H9`+U zYEeQ)pZbkc4*eWAOx$3IZ4$j?OWBJX@P(c|A}gnid#9bBV&^Bf^Ai;G5kDdF1wVs(f>>esj&3xM+zri3J)lra zhT?6;t{mtTPI|~*$p)i1E5!juONtP|g7wrF?fM25LT?#FzlacA%qqbE|MmEm0OzhaYJT%J>Xbe`-MfRJh+lD~{Hm*_AAndmt1Eyyu zhXR?S$C9}fOR2)L!nj+v+lkTF{06Hqo^+G<9!a*Gvg(fXSknz}^YfN58npRntz$W8 zw>^y3EH~6r>=?tl$J`{qty-M*(wG;Z&|@BKH0mBOQg3^oGp6BdVKfI?TFl9|)iwpn zbWtBty+xy|Eow#F+^|GPVp)`p`47X(k8ihHgZ^vjye!9k+K;GIDynq`fc|c zZxja`2Bz3=#_k}_ldU$ZS$NheH;@E<(>5hGB$W!A4oryidoTgF7m5}zw&+&TM|8^( zlWSDXp7+}GPFVK5*PeIsvgf_Fy!W+Z&pU5bhrEZvU`R7>RWz1m?~+Y)8#aNdmBqB5 zn2K#$?g0s#S4>M@t=Q^daO_*X;;F5zR2UURfwDXwFtqoWwEA98GE}_}5;Fv1M?YtW z<^i21<_;-k-VQ8nT8FHGAYtg-!C~Bxb?{;6+_8_Mz=GfHyPJ5?WXx-&86Z=rxi(uW z+`%?x-l2mz11yYY2rbV;7$fiF01SxOS-6rJ;*h(Y)BBVDkg*EGpv{hh(gQlI+vV*} z?2+FwMd9b^e){{Q&l2l5hwTG}zD4v87wmO*e7E3@cK@Dn^{4y$%hTih<4=6w;P2`m z|NYl_zIuD}{=5QzJ-@rX`TFwv{9|k3=F!N98`ZTxZIZ6fkC)#bPUpv2Ur?@Baj!4; zPv?6y>hsA4zP)(>o8t-{KC%cJA78HieESYlXL)cvZ+c!|e&eovVgDn@K~j1qsk{05 z=IQ3c<^6h9PCtIih~FR1KTnj*)biQ>ne`qTXW;b@M(RJ@4+#2s4)MB4DD*dPrgM68xf6IJpI*KC=5OErA7{tM%m4rY literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/Contents.json new file mode 100644 index 000000000..ce66777a8 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_clock_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/ic_fluent_clock_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_clock_24_filled.imageset/ic_fluent_clock_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3de09147da22bdc29f33e3303b608f1d65cb1d7a GIT binary patch literal 1430 zcmZuxO>dk)488kT__9)R2+Z)cAVray-KMJAs$0@q)B|NF8>(GsfmCUKeLU=pXAt4S z@N7TZ4|`U-?cJ3O)&LR(c0Yds;`KG&+<;w^vnCb^cpE<2Ms$WQ8_%;NYwE9Y~VR^bs^_y&51%!1f$(u;H6 zvUMSN#}YQ0{GeT=C;a~Jx598&9h3pSsC`r2b}#tgByffB-;4NUnMyTu z&wZ^1)Z1Pfx=q)Ps-=v2)h)QIdcrU@karqgR`0*wy#n(xE@}V( literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/Contents.json new file mode 100644 index 000000000..bc20e11f2 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_dismiss_16_regular.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/ic_fluent_dismiss_16_regular.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_dismiss_16_regular.imageset/ic_fluent_dismiss_16_regular.pdf new file mode 100644 index 0000000000000000000000000000000000000000..184d46b71041c40cb14276e2f8c83cf26a02f11e GIT binary patch literal 1554 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 o1m*JTbb6Y(VTtCKyO{`&2I0ZeUg8UO$Q literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/Contents.json new file mode 100644 index 000000000..e767271b0 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_mic_off_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/ic_fluent_mic_off_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_off_24_filled.imageset/ic_fluent_mic_off_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..67b0c0fb3e6d15a8e06b913cb6fbc1ad547061e2 GIT binary patch literal 2715 zcmbW3O>Y}F5QgvjD|iWz97J*WO&|!+I89NsMO~-2pa)gnBrX)Gm6Rgwug^2PtCeC0 z=*J!m`qj*E=H)v>KDvJKav`>Kofv0!zy8)4^Yp2C_N?o-xB74AFY)E~{r2tgoe6+9 zYIQj7`_=ny`J(^xZr$H}^W0p$y8Lf_=>F)$y5TeKv{=lm`8R+1O>9jeNjB^EowMRw z6mod5IIOn>_SJ=uj5OPPuC5vNX|=?djCa;cD=y=m#HS)N)PVRcm)X^eS$CRN37$0p za|kUsbHcJ>lA22zPn|Q))z+Lran5rA!Md7?t7dZnQbEkIhD)&67D^!1VoP&1i^(S| znHid)bq82Oh&f-_iOuBP0#I}k8hLu_V|B#?)liZ+vXfvjYNeQDbCXm(iC~$eq+9`A zNIp`y*yx+oXrj$y!tCmk9^e^Tcg>c2*aj?d7%iz?qMWf-3XHLWl^ofF#o*{D+T|8Y zJ|oT6HgL4=W-E55sFAp6V+)d^0gSDBRvRp146%_q?68Gz1VlDjsgM(&;gs(gT6b^~ zQmLQYO=*a4O5WT-ae)S7Zfr8 zDt0;5+MG5%K^%4{B7~BMXzB_9DtWqDUB56
1KO}FiS`tOshaMZ2F_-{s8vWyf- zVitI%NCz{^B-;?tO)v}SKqGFTI@3#``fL`}iquvhf^Hf<^_L)xL%F~hPL)iOmUy;I zHjOBBXN!v@c1p0!_|V=`l;)~UQDNx8FIXPCYZ1r#n1iT$90zBNomA@dS21T4Y-)dv z$m~G@pJ7u*gZIgD&Yq0OuBFU~UcimZY^!;KmXJ{-Xk|(EU?DdKpfVCNGjhNlu)O~N z=ZjjBG&uX`tBNU(>NqGJ^&bB8bOZAr1#*rL>kMyS{+xL-P_4k zi#~iQiI4mKO~*ajjpgfKlM|0813Z0P{=>>_XDE=Telz5`bsj);?LDi&&2|rBHq6j* zzq;G>`;O=1^4k~C@%XTN-#;;r?{1%rq%3cCyQ9&92ZEQc?*8t{et1T%_Q#=FFyfGUn`#g literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/Contents.json new file mode 100644 index 000000000..14be2beb1 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_mic_on_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/ic_fluent_mic_on_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_mic_on_24_filled.imageset/ic_fluent_mic_on_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..167b6ddfa16ea0028d30e700812c4af5c3907188 GIT binary patch literal 1941 zcmZvdO>fjN5Qgvm6~0tz4|P2LN-R~C=$0Y`h_dBYaR}M8U9_9PCPjr`&x{j0Zc9$i z6MH`18BdO`FRxBPl#BtFy#M}#aej8j&(B%gZRnM$oEIP4?tXmW8sVCD4dbD$_pG>V ze{Y+1{qBOVZt}NgV80j$86Ga<;xw(&HygjHxM=oF3Q*nv#_(z}G#vvilqJ&~lAS$K zr@ZqnqsY9`#^X*YN+>IXUy*qYRuj_}%2M)%frHVwjjxQ>;omzGF&MTGJ8C2yMuz7^>11%}NK}5|PqK zC%G3Ew6h-Vrw}v663eyCp>vopc9#eemCCheUMT57`wV85s`xcy*fHTjeKML%;+;wt8SjIam7|1bFX?a!CXt4jxIHnv zZo{!vPcdV1yURfBCYON$G$L{p!0Z)vy{f>G-E+anMN#L-DUI^ zZ@R7@2Y&kvM=?Id*FQg7UR>Ar?F>G(n{9p3Kk?ge?btS+&?8Oy2(Rk{GHz#-{SOGG|3vX&yTMR7Ww7Sa&#HgKv+yeZ0ZC>Fr%`5U zN^@0@^{&64k5baZ|AKfrw0BJMl1)_%5;$vdBq5$3KvlwrE+LI^K10~MFL!~V`SX02 ztuWT-$l6?LmoY z!e&AYeXmG7GRTkKD}rTkiINr&P^Lr_^*B)Sfr4fzV?{a+%Ots+<5#JYe#0sRoV zAUS6aC@JK@CeMh}8>_t_Tjhpv98(;d70Qi1<^UvzXnZcOf)8H%!H&iSht$MHmxN%P zZJ}h}>_)KQ6S22M(d7}fXUa?h4Jnvp5+gY<2is@p-K)=ByR}?%;}Wb3ud)D3`6=tU zXWd2PG^~2oDO(QxppFu4-+`fP1#buIgc7n#oEM>^o>Od%AlV_kL2Lcs=1K%aaUmu+ z2rF?i2gO~@g$w&P#}lwABFNCp#WaKWbNV3%TcIWgojHWLIi=3r># zDpK_=(cn+OUho}geQ?f~AeJAT7VmRNNE*#gIgEiJ2+Nx}NXg#-Y-hy{c_!o`m?BIF zBE(+@KOi-R;2kT~qca!gH6r1#CpV`m zi2=+hMH@MP3_j+SAT)~PG>SAfI*<=2V^U_MjWP^a1lN*NA&3h_;{@zw-GNosgrV?A z)|drPWi2y9sKA7(b4gUh+7bgEh~>fWR6==08x2H?%WItJ@)nwCm~^{TSS=B99v#hL zH#--7FvD};t@3q410`+#oveeX2I#^zJISwkliHpscgw#Up-L3SjI~O=B(S9?ePf=s*=ql@b!7py0T~26fG8Acp?zq60>gRCBHTSL1@=f(E=ixJi|jJ_v;C z&O^xNgr4vtR?s98dk7J3DUTqFt9ykmr_F9ZofLQP$7|5>@_f9XUg?*++gCFwtM&1C g(OhsLc=ciT|Ag$zm)GWWDdjjvR=s%f{jcx80gbl3mjD0& literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/Contents.json new file mode 100644 index 000000000..476384b22 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_speaker_2_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/ic_fluent_speaker_2_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_filled.imageset/ic_fluent_speaker_2_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bc93853dd644b0e8d88af69750077a132ca9b9da GIT binary patch literal 2280 zcmZuzO>Y}F5WVwP@M0i2gyQgH$N_=?jolPQThvv03wluHjibVrT1hF={`$V*uDI*C zds@9Ehwo!Z>gx99-79j%7_#0Te*eQ*`}(zg^TrJOJN;?G5?}o|?C;N?Yy`O0QK$29 z*gTrm&G7ebJFLHZYp>rge{E0ZFGJ3^$NJ05tNzx%h0C|#g3rEGyL~j?QIJI7@a*NZ z-5ZLRNmN`;NvPV#1&W$UBC&l~fZj>*%w!3=Lrz%9yC^lqY6%}nq9jX>C{q=?HSAI- z64+)(zKX9X$1aHHhK1-wf@MsI05K|PF~W-k$sA^6YalMG7c&NG5K$s8uDR48Oc2%W)EdxBfZ zMUI6N#H&RqDwJxj0qL31%#`7Db*YFKB{;ec5thV}<$V60}rgD!ZE zxJ8dy30n$$u~Nv|1c)9U`x3CrfpQ3?wr~Y~r1dcpHl?-_+6I#pBkbB5Cm$tO92|%l z8#on6Fa21-c_0)A+lC%yNLa&=T>r@B90b7mDEs$YVrvw7kKS!ZVBLU zVlNFuP9QL@+K!}(2_-~KRU!>qYV9EQbxA1G=1%3b9b#+Ed0iNaHk5XT&IExL>@?5; zHt9ehA%JbG_$*?L{-fEOUyNQ#(~Wbvp7bUfuPx#z{nkny#%;FxX1_n2PxixaxZUuq ze*Ndq!LDvM_rnDIG~DerZx3JWhxw%KCuzW$FxoM{T`H}I)8X@RJDjZEpX;gI^tu21e8J7y+=^=01o85OPf~P(mq7rajeFY)yuOJCf{dAPz zbh<$2<7W3T9F3;;{SEARaXLH>FYSxn-OJIG)%tKaTNT_4UcKM_JAi%rPqI0ln;iU+ MsOIYG+n?Y42QCZOSpWb4 literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/Contents.json new file mode 100644 index 000000000..a2497fd0f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_speaker_2_24_regular.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/ic_fluent_speaker_2_24_regular.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_speaker_2_24_regular.imageset/ic_fluent_speaker_2_24_regular.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4c0c90cea58c892bff207ea5adf23b06b9cf9775 GIT binary patch literal 2853 zcmbVOO^@3)5WVYH@UlR1FwG%{9|Hsdn%$--+M-*hx1a~tt885C+O6#rX@7m*P!gql z9MbBrD?gE(d2b$vyt=)4xgwjpj*K(M-~Z^0dG^daf8OLt;r{&TPn8A>oLx#c~!W(d@cp*=ONk zh%J{`d@&CIy||>a*@hHyVv{UU2tFy~C4~@8w1PLn$_?6epc9q-0(_{76&WZ%< zOOc#rxd`HL4Y7kHqnOILaBxVkVaWy;bHTb8l|ypK$qPo2X?Zkc*VTk$^7AOA3SMA4 zV*`0G#mwMDJh-by6(Lu|p;q0XW)QH)3XzZmcvjgN#dJ1k9#F8=4Dx4}669}?@{~YH z*K6Qh+lTILml2zjLwy2D!3Bd+a+al1b!i zwliuc6k#E$s%O&DW*aQkf6Mz1i{;CXkW3Eh+Q>@UDYZiavHbg2Q-$z z3Gy}`z)}p1?A5FTVPME!9P8|a?qFM#YDhn%5PM>t0xd@ zJ}kIGK?)-+#m2}hwh8SCrGz_LimPZ{kSNG;WReXq(hUKv8)eLvoY8!BHe0+VB+qBZAKOf(ncKzAtK~J0gbe|Y z4?5%U;m{!T?T;bcFTh$KGmY|aM%t&L(19>FeQIm6*8^8cL%X95QUmbN^=aL1g zgtoZ^k*bswJ)~euCaBYnDFqctmUz&3qRhf!0kI3#Iw1kGXf1@rL!0do&8?gfO1G#{ z7^xR_mTHL#I3g~z&Mlyo)Iu?bNEj!$Ln*0KD%>iTYm|~8Hrg67(ZOF@%p$1~oZ(eO zTg+`R5k|!Z8k<|}iKB`s;YKEmvO_H=eXOv*52PkvNSLr`eCAmTV>Qm86)aAmiWDa^ zdsEj<-@*MioK#%d*FQf!WSes5C-BK{_vKCV4EL{GSrJb>?dC{@@0zE!@*RY?pqRp~ zn!5KjW(*I(3^!$q3~(q0w0JH8iqA;)VZTMG5Gp8O&a-Ns(3hX+?~p7a;jk_ujb*ON zzC1L$$u$(+y(Z$h^$&!BA>+LXzmRK?0@sAbf46FVR z85dFCmivQmNjSatYi!4P*Bt!?obR_6V^gxCX?h3+4-L-l_J2L9AKurp?T2!>kd~aC Iz5RUq0>O_p^#A|> literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/Contents.json new file mode 100644 index 000000000..669ac90a2 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_video_off_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/ic_fluent_video_off_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_video_off_24_filled.imageset/ic_fluent_video_off_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5e377f36db83dccf0c65bb0648857338f3c0167b GIT binary patch literal 1932 zcmbVN%WfM-5WLS<%te57(Cl>28!!aKk{tw25QWZ7@PVP$5)EqC=F*0fuTS+pDanAH z?1328PWN_I*YuoRU%fsjlawNDb^F%`rS*#!`r<;>&8_@Ze2iDW*3I4FUORv*+0`BP z_4+|oSM`_ere6L0QeVD3{5gkG9j|d|u7(czQ=B2Nx*n&4aRrxOhSh&lcUL zL1NC=p*38QnxSv_G< zFbc&6$tS@P!P(>(fg%_xV$G~687_sA)jJfEIHrh%(WK~75h|k^6by1$vPY0Nu0)?5 z^3D5#$pa?o##nF2QV2xWk~1MEn=-N_ zc2SgY23(5IIv4^KD?)*HqT;=b+{zAOG2VU{Ep||0CWD zQ!ilQ47Lg01G!*5V}0&WA`yu6!enbxAwk)vSbR^f@1;IxcsFErk1;gYa~QQ5c4la8 zJrPohIujU5ig_?Hg$T>Sl$;oS6|JM#T!Wa+w$CR$L5Zk&rWwtX z6(?YsxYbWh(;hl~^9~ajPxa^DA8TD*ukY#+_^rO(u3xra^^F{+q51QIXEMub%CxGx z_Va#IcUorrYD{<8Hix>w7iGXt;PrYBOotIVx&{QAuZQZ*_7U;!| z%aGzRgfsCJ3JG)T@vaz_z{7sM-PL=A`?C7w3UZuw?L&R0Pq(*cgDBOiZ4X)q_X1aM ZxBt|rzP~ru`$JEM5?OU}^6K~3{{rg;lnnp? literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/Contents.json new file mode 100644 index 000000000..af6e192a8 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_warning_24_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/ic_fluent_warning_24_filled.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_fluent_warning_24_filled.imageset/ic_fluent_warning_24_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..22a6541929666ecc69437c0abf45ae37d8e74a6d GIT binary patch literal 1906 zcmZuyO^+Kl487}D=n^0~L>4LPgFq0V*=>rVExL7j3wm(9lZ}gAyS1Gn?XS{*9ah}~=zkNxeI!8YG-JgFs?_a(0uV1@idux9kU*e0Ohwa_*qfY>rW_393 zht-2yTo3ckdYVqLBH{80ZzHXwpp>1%ZQ9%b-xE zcnVaa6!Xh9m&Qn7O2@)TA1ISl_GlJ2u&-bxtlZRMMy1q1T{PBcm6;XCG-(tmLZcR9 zY7NScYBCyRAWKRnJYEwOxS)MfOY4nHs0jhT(c88A=*T9gDVmy1brtO`N5>@9Gltev zOdP$0mQzdYFOU?O8|Dk6GFhcibF30!HAS+Xroxz=IpGYU3lOZt%-VZD+Sw%6BF29T zhGCi@L9{97B{-FKjy7{TXPu#$J^I9+tQsj*M*5l*j7CtO+GD95{Su% z8R$e5cV>jD3bGnuW~yxjwPF@xP?1Vx!88&kr7Oi&y5+36C+2FJ_WoOOxRU*TcA;k) z8AYSKZVHo5P8ipc4Yfop1^b~(qqP^A357$OE^EtpYkhwrwYA`b+q&PJt;cEioff6- z#BqTV*2=s;aeL2ub@APHyE`8I`#-Q}@hpD-@9)7cZdP~01pG4GZdPx0pZ$AWvZrg4 zH`^!zbI315?^5N7TPlrsdgXO2aM&yL6aYI8sA9lnZ-AFm~yF!anGK8|LfZ?u(FQ^ literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/Contents.json b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/Contents.json new file mode 100644 index 000000000..a53e74839 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_ios_arrow_left_24_outlined.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/ic_ios_arrow_left_24_outlined.pdf b/AzureCommunicationUI/AzureCommunicationUI/Assets.xcassets/Icon/ic_ios_arrow_left_24.imageset/ic_ios_arrow_left_24_outlined.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2829345ec4cd1cd7876e412253e9d84dc6c54d20 GIT binary patch literal 4019 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 PE(3vqg@n{~G{FA{gJ{xQ literal 0 HcmV?d00001 diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallComposite.swift b/AzureCommunicationUI/AzureCommunicationUI/CallComposite.swift new file mode 100644 index 000000000..6eb73b66c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallComposite.swift @@ -0,0 +1,139 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit +import SwiftUI +import FluentUI +import AzureCommunicationCalling + +public class CallComposite { + private var logger: Logger? + private let themeConfiguration: ThemeConfiguration? + private let callCompositeEventsHandler = CallCompositeEventsHandler() + private var errorManager: ErrorManager? + private var lifeCycleManager: UIKitAppLifeCycleManager? + private var permissionManager: AppPermissionsManager? + private var audioSessionManager: AppAudioSessionManager? + + public init(withOptions options: CallCompositeOptions) { + themeConfiguration = options.themeConfiguration + } + + public func setTarget(didFail action: ((ErrorEvent) -> Void)?) { + callCompositeEventsHandler.didFail = action + } + + deinit { + logger?.debug("Composite deallocated") + } + + private func launch(_ callConfiguration: CallConfiguration) { + let dependencyContainer = DependencyContainer() + logger = dependencyContainer.resolve() as Logger + logger?.debug("launch composite experience") + + dependencyContainer.registerDependencies(callConfiguration) + + setupColorTheming() + let toolkitHostingController = makeToolkitHostingController(router: dependencyContainer.resolve(), + logger: dependencyContainer.resolve(), + viewFactory: dependencyContainer.resolve()) + setupManagers(store: dependencyContainer.resolve(), + containerHostingController: toolkitHostingController, + logger: dependencyContainer.resolve()) + present(toolkitHostingController) + } + + public func launch(with options: GroupCallOptions) { + let callConfiguration = CallConfiguration( + communicationTokenCredential: options.communicationTokenCredential, + groupId: options.groupId, + displayName: options.displayName) + + launch(callConfiguration) + } + + public func launch(with options: TeamsMeetingOptions) { + let callConfiguration = CallConfiguration( + communicationTokenCredential: options.communicationTokenCredential, + meetingLink: options.meetingLink, + displayName: options.displayName) + + launch(callConfiguration) + } + + private func setupManagers(store: Store, + containerHostingController: ContainerUIHostingController, + logger: Logger) { + let errorManager = CompositeErrorManager(store: store, + callCompositeEventsHandler: callCompositeEventsHandler) + self.errorManager = errorManager + + let lifeCycleManager = UIKitAppLifeCycleManager(store: store, logger: logger) + self.lifeCycleManager = lifeCycleManager + + let permissionManager = AppPermissionsManager(store: store) + self.permissionManager = permissionManager + + let audioSessionManager = AppAudioSessionManager(store: store, + logger: logger) + self.audioSessionManager = audioSessionManager + } + + private func cleanUpManagers() { + self.errorManager = nil + self.lifeCycleManager = nil + self.permissionManager = nil + self.audioSessionManager = nil + } + + private func makeToolkitHostingController(router: NavigationRouter, + logger: Logger, + viewFactory: CompositeViewFactory) -> ContainerUIHostingController { + let rootView = ContainerView(router: router, + logger: logger, + viewFactory: viewFactory) + let toolkitHostingController = ContainerUIHostingController(rootView: rootView, + callComposite: self) + toolkitHostingController.modalPresentationStyle = .fullScreen + + router.setDismissComposite { [weak toolkitHostingController, weak self] in + toolkitHostingController?.dismissSelf() + self?.cleanUpManagers() + } + + return toolkitHostingController + } + + private func present(_ viewController: UIViewController) { + DispatchQueue.main.async { + guard self.isCompositePresentable(), + let topViewController = UIWindow.keyWindow?.topViewController else { + // go to throw the error in the delegate handler + return + } + topViewController.present(viewController, animated: true, completion: nil) + } + } + + private func setupColorTheming() { + let colorProvider = ColorThemeProvider(themeConfiguration: themeConfiguration) + StyleProvider.color = colorProvider + DispatchQueue.main.async { + if let window = UIWindow.keyWindow { + Colors.setProvider(provider: colorProvider, for: window) + } + } + } + + private func isCompositePresentable() -> Bool { + guard let keyWindow = UIWindow.keyWindow else { + return false + } + let hasCallComposite = keyWindow.hasViewController(ofKind: ContainerUIHostingController.self) + + return !hasCallComposite + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeEventsHandler.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeEventsHandler.swift new file mode 100644 index 000000000..4e8735949 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeEventsHandler.swift @@ -0,0 +1,11 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +import Foundation +import UIKit +import AzureCommunicationCalling + +class CallCompositeEventsHandler { + var didFail: ((ErrorEvent) -> Void)? +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeOptions.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeOptions.swift new file mode 100644 index 000000000..d9fa95c95 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallCompositeOptions.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit + +public struct CallCompositeOptions { + var themeConfiguration: ThemeConfiguration? + + public init(themeConfiguration: ThemeConfiguration) { + self.themeConfiguration = themeConfiguration + } + + public init() { } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallConfiguration.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallConfiguration.swift new file mode 100644 index 000000000..7c908bd6a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/CallConfiguration.swift @@ -0,0 +1,43 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCommon + +struct CallConfiguration { + let communicationTokenCredential: CommunicationTokenCredential + let displayName: String? + let groupId: UUID? + let meetingLink: String? + let compositeCallType: CompositeCallType + let diagnosticConfig: DiagnosticConfig + + init(communicationTokenCredential: CommunicationTokenCredential, + groupId: UUID, + displayName: String?) { + self.communicationTokenCredential = communicationTokenCredential + self.displayName = displayName + self.groupId = groupId + self.meetingLink = nil + self.compositeCallType = .groupCall + self.diagnosticConfig = DiagnosticConfig() + } + + init(communicationTokenCredential: CommunicationTokenCredential, + meetingLink: String, + displayName: String?) { + self.communicationTokenCredential = communicationTokenCredential + self.displayName = displayName + self.groupId = nil + self.meetingLink = meetingLink + self.compositeCallType = .teamsMeeting + self.diagnosticConfig = DiagnosticConfig() + } +} + +enum CompositeCallType { + case groupCall + case teamsMeeting +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/DiagnosticConfig.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/DiagnosticConfig.swift new file mode 100644 index 000000000..b6477b452 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/DiagnosticConfig.swift @@ -0,0 +1,22 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct DiagnosticConfig { + var tags = [String]() + private let callCompositeTagPrefix: String = "aci110" + private var callCompositeTag: String { + let version = Bundle(for: CallComposite.self).infoDictionary?["CFBundleShortVersionString"] + let versionStr = version as? String ?? "unknown" + + return "\(callCompositeTagPrefix)/\(versionStr)" + } + + init() { + tags.append(callCompositeTag) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ErrorEvent.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ErrorEvent.swift new file mode 100644 index 000000000..06ee8b293 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ErrorEvent.swift @@ -0,0 +1,36 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +public struct CallCompositeErrorCode { + public static let callJoin: String = "callJoin" + public static let callEnd: String = "callEnd" + public static let tokenExpired: String = "tokenExpired" +} + +public struct ErrorEvent { + public let code: String + public var error: Error? +} + +extension ErrorEvent: Equatable { + public static func == (lhs: ErrorEvent, rhs: ErrorEvent) -> Bool { + if let error1 = lhs.error as NSError?, + let error2 = rhs.error as NSError? { + return error1.domain == error2.domain + && error1.code == error2.code + && "\(error1.description)" == "\(error2.description)" + && lhs.code == rhs.code + } + + if let error1 = lhs.error as? CompositeError?, + let error2 = rhs.error as? CompositeError? { + return error1 == error2 && lhs.code == rhs.code + } + + return false + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/GroupCallOptions.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/GroupCallOptions.swift new file mode 100644 index 000000000..2580d9bed --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/GroupCallOptions.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCommon + +public struct GroupCallOptions { + public let communicationTokenCredential: CommunicationTokenCredential + public let groupId: UUID + public let displayName: String? + + public init(communicationTokenCredential: CommunicationTokenCredential, + groupId: UUID, + displayName: String) { + self.communicationTokenCredential = communicationTokenCredential + self.groupId = groupId + self.displayName = displayName + } + + public init(communicationTokenCredential: CommunicationTokenCredential, + groupId: UUID) { + self.communicationTokenCredential = communicationTokenCredential + self.groupId = groupId + self.displayName = nil + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/TeamsMeetingOptions.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/TeamsMeetingOptions.swift new file mode 100644 index 000000000..b2783f2b5 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/TeamsMeetingOptions.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCommon + +public struct TeamsMeetingOptions { + public let communicationTokenCredential: CommunicationTokenCredential + public let meetingLink: String + public let displayName: String? + + public init(communicationTokenCredential: CommunicationTokenCredential, + meetingLink: String, + displayName: String) { + self.communicationTokenCredential = communicationTokenCredential + self.meetingLink = meetingLink + self.displayName = displayName + } + + public init(communicationTokenCredential: CommunicationTokenCredential, + meetingLink: String) { + self.communicationTokenCredential = communicationTokenCredential + self.meetingLink = meetingLink + self.displayName = nil + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ThemeConfiguration.swift b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ThemeConfiguration.swift new file mode 100644 index 000000000..8fa8cf6a5 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/CallCompositeOptions/ThemeConfiguration.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit + +public protocol ThemeConfiguration { + var primaryColor: UIColor { get } +} + +public extension ThemeConfiguration { + var primaryColor: UIColor { + return UIColor.compositeColor(.primary) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/DI/DependancyContainer.swift b/AzureCommunicationUI/AzureCommunicationUI/DI/DependancyContainer.swift new file mode 100644 index 000000000..ef5f3811c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/DI/DependancyContainer.swift @@ -0,0 +1,68 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +final class DependencyContainer { + private var dependencies = [String: AnyObject]() + + init() { + registerDefaultDependencies() + } + + func register(_ dependency: T) { + let key = String(describing: T.self) + dependencies[key] = dependency as AnyObject + } + + func resolve() -> T { + let key = String(describing: T.self) + let dependency = dependencies[key] as? T + + precondition(dependency != nil, "No dependency found for \(key)! must register a dependency before resolve.") + + return dependency! + } + + private func registerDefaultDependencies() { + register(DefaultLogger() as Logger) + } + + func registerDependencies(_ callConfiguration: CallConfiguration) { + register(CallingSDKEventsHandler(logger: resolve()) as CallingSDKEventsHandling) + register(ACSCallingSDKWrapper(logger: resolve(), + callingEventsHandler: resolve(), + callConfiguration: callConfiguration) as CallingSDKWrapper) + register(VideoViewManager(callingSDKWrapper: resolve(), logger: resolve()) as VideoViewManager) + register(ACSCallingService(logger: resolve(), + callingSDKWrapper: resolve()) as CallingService) + register(makeStore(displayName: callConfiguration.displayName) as Store) + register(NavigationRouter(store: resolve(), + logger: resolve()) as NavigationRouter) + register(ACSCompositeViewModelFactory(logger: resolve(), + store: resolve()) as CompositeViewModelFactory) + register(ACSCompositeViewFactory(logger: resolve(), + videoViewManager: resolve(), + compositeViewModelFactory: resolve()) as CompositeViewFactory) + } + + private func makeStore(displayName: String?) -> Store { + let middlewaresHandler = CallingMiddlewareHandler(callingService: resolve(), logger: resolve()) + let middlewares: [Middleware] = [CallingMiddleware( + callingMiddlewareHandler: middlewaresHandler)] + let appStateReducer = AppStateReducer(permissionReducer: PermissionReducer(), + localUserReducer: LocalUserReducer(), + lifeCycleReducer: LifeCycleReducer(), + callingReducer: CallingReducer(), + navigationReducer: NavigationReducer(), + errorReducer: ErrorReducer()) + + let localUserState = LocalUserState(displayName: displayName) + return Store(reducer: appStateReducer, + middlewares: middlewares, + state: AppState(localUserState: localUserState)) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Info.plist b/AzureCommunicationUI/AzureCommunicationUI/Info.plist new file mode 100644 index 000000000..55ad9e17c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSCameraUsageDescription + RequireCamera + NSMicrophoneUsageDescription + This is required for calling. + + diff --git a/AzureCommunicationUI/AzureCommunicationUI/Logger/DefaultLogger.swift b/AzureCommunicationUI/AzureCommunicationUI/Logger/DefaultLogger.swift new file mode 100644 index 000000000..332011442 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Logger/DefaultLogger.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +import Foundation +import os.log + +struct DefaultLogger: Logger { + + private let osLogger = OSLog(subsystem: "com.azure", category: "CallComponent") + + func debug(_ message: @escaping () -> String?) { + log(message, atLevel: .debug) + } + + func info(_ message: @escaping () -> String?) { + log(message, atLevel: .info) + } + + func warning(_ message: @escaping () -> String?) { + log(message, atLevel: .warning) + } + + func error(_ message: @escaping () -> String?) { + log(message, atLevel: .error) + } + + func log(_ message: () -> String?, atLevel messageLevel: LogLevel) { + if let msg = message() { + os_log("%@", log: osLogger, type: osLogTypeFor(messageLevel), msg) + } + } + + // MARK: Private Methods + private func osLogTypeFor(_ level: LogLevel) -> OSLogType { + switch level { + case .error, + .warning: + return .error + case .info: + return .info + case .debug: + return .debug + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Logger/Logger.swift b/AzureCommunicationUI/AzureCommunicationUI/Logger/Logger.swift new file mode 100644 index 000000000..fe3b1e644 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Logger/Logger.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import os + +protocol Logger { + func debug(_: @autoclosure @escaping () -> String?) + func info(_: @autoclosure @escaping () -> String?) + func warning(_: @autoclosure @escaping () -> String?) + func error(_: @autoclosure @escaping () -> String?) +} + +enum LogLevel: Int { + case debug = 1 + case info = 2 + case warning = 3 + case error = 4 +} + +extension LogLevel: Comparable {} + +func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + return lhs.rawValue < rhs.rawValue +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Model/CallInfoModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Model/CallInfoModel.swift new file mode 100644 index 000000000..d582d9540 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Model/CallInfoModel.swift @@ -0,0 +1,9 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +struct CallInfoModel { + let status: CallingStatus + let errorCode: String +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Model/ParticipantInfoModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Model/ParticipantInfoModel.swift new file mode 100644 index 000000000..536de51c9 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Model/ParticipantInfoModel.swift @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct ParticipantInfoModel: Hashable, Equatable { + let displayName: String + let isSpeaking: Bool + let isMuted: Bool + + let isRemoteUser: Bool + let userIdentifier: String + + let recentSpeakingStamp: Date + + let screenShareVideoStreamModel: VideoStreamInfoModel? + let cameraVideoStreamModel: VideoStreamInfoModel? + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Model/VideoStreamInfoModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Model/VideoStreamInfoModel.swift new file mode 100644 index 000000000..749ffd4e2 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Model/VideoStreamInfoModel.swift @@ -0,0 +1,15 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct VideoStreamInfoModel: Hashable, Equatable { + enum MediaStreamType { + case cameraVideo + case screenSharing + } + let videoStreamIdentifier: String + let mediaStreamType: MediaStreamType +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewFactory.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewFactory.swift new file mode 100644 index 000000000..70b0ef1cd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewFactory.swift @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +protocol CompositeViewFactory { + func makeSetupView() -> SetupView + func makeCallingView() -> CallingView +} + +struct ACSCompositeViewFactory: CompositeViewFactory { + private let logger: Logger + private let compositeViewModelFactory: CompositeViewModelFactory + private let videoViewManager: VideoViewManager + + init(logger: Logger, + videoViewManager: VideoViewManager, + compositeViewModelFactory: CompositeViewModelFactory) { + self.logger = logger + self.videoViewManager = videoViewManager + self.compositeViewModelFactory = compositeViewModelFactory + } + + func makeSetupView() -> SetupView { + return SetupView(viewModel: compositeViewModelFactory.getSetupViewModel(), viewManager: videoViewManager) + } + + func makeCallingView() -> CallingView { + return CallingView(viewModel: compositeViewModelFactory.getCallingViewModel(), + viewManager: videoViewManager) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewModelFactory.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewModelFactory.swift new file mode 100644 index 000000000..0e9947a43 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Factories/CompositeViewModelFactory.swift @@ -0,0 +1,180 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import FluentUI + +protocol CompositeViewModelFactory { + // MARK: CompositeViewModels + func getSetupViewModel() -> SetupViewModel + func getCallingViewModel() -> CallingViewModel + + // MARK: ComponentViewModels + func makeIconButtonViewModel(iconName: CompositeIcon, + buttonType: IconButtonViewModel.ButtonType, + isDisabled: Bool, + action: @escaping (() -> Void)) -> IconButtonViewModel + func makeIconWithLabelButtonViewModel(iconName: CompositeIcon, + buttonTypeColor: IconWithLabelButtonViewModel.ButtonTypeColor, + buttonLabel: String, + isDisabled: Bool, + action: @escaping (() -> Void)) -> IconWithLabelButtonViewModel + func makeLocalVideoViewModel(dispatchAction: @escaping ActionDispatch) -> LocalVideoViewModel + func makePrimaryButtonViewModel(buttonStyle: FluentUI.ButtonStyle, + buttonLabel: String, + iconName: CompositeIcon?, + isDisabled: Bool, + action: @escaping (() -> Void)) -> PrimaryButtonViewModel + func makeAudioDeviceListViewModel(dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) -> AudioDeviceListViewModel + func makeErrorInfoViewModel() -> ErrorInfoViewModel + + // MARK: CallingViewModels + func makeControlBarViewModel(dispatchAction: @escaping ActionDispatch, + endCallConfirm: @escaping (() -> Void), + localUserState: LocalUserState) -> ControlBarViewModel + func makeInfoHeaderViewModel(localUserState: LocalUserState) -> InfoHeaderViewModel + func makeParticipantCellViewModel(participantModel: ParticipantInfoModel) -> ParticipantGridCellViewModel + func makeParticipantGridsViewModel() -> ParticipantGridViewModel + func makeParticipantsListViewModel(localUserState: LocalUserState) -> ParticipantsListViewModel + func makeBannerViewModel() -> BannerViewModel + func makeBannerTextViewModel() -> BannerTextViewModel + + // MARK: SetupViewModels + func makePreviewAreaViewModel(dispatchAction: @escaping ActionDispatch) -> PreviewAreaViewModel + func makeSetupControlBarViewModel(dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) -> SetupControlBarViewModel +} + +class ACSCompositeViewModelFactory: CompositeViewModelFactory { + private let logger: Logger + private let store: Store + + private weak var setupViewModel: SetupViewModel? + private weak var callingViewModel: CallingViewModel? + + init(logger: Logger, + store: Store) { + self.logger = logger + self.store = store + } + + // MARK: CompositeViewModels + func getSetupViewModel() -> SetupViewModel { + guard let viewModel = self.setupViewModel else { + let viewModel = SetupViewModel(compositeViewModelFactory: self, + logger: logger, + store: store) + self.setupViewModel = viewModel + self.callingViewModel = nil + return viewModel + } + return viewModel + } + + func getCallingViewModel() -> CallingViewModel { + guard let viewModel = self.callingViewModel else { + let viewModel = CallingViewModel(compositeViewModelFactory: self, + logger: logger, + store: store) + self.setupViewModel = nil + self.callingViewModel = viewModel + return viewModel + } + return viewModel + } + + // MARK: ComponentViewModels + func makeIconButtonViewModel(iconName: CompositeIcon, + buttonType: IconButtonViewModel.ButtonType = .controlButton, + isDisabled: Bool, + action: @escaping (() -> Void)) -> IconButtonViewModel { + IconButtonViewModel(iconName: iconName, + buttonType: buttonType, + isDisabled: isDisabled, + action: action) + } + func makeIconWithLabelButtonViewModel(iconName: CompositeIcon, + buttonTypeColor: IconWithLabelButtonViewModel.ButtonTypeColor, + buttonLabel: String, + isDisabled: Bool, + action: @escaping (() -> Void)) -> IconWithLabelButtonViewModel { + IconWithLabelButtonViewModel(iconName: iconName, + buttonTypeColor: buttonTypeColor, + buttonLabel: buttonLabel, + isDisabled: isDisabled, + action: action) + } + func makeLocalVideoViewModel(dispatchAction: @escaping ActionDispatch) -> LocalVideoViewModel { + LocalVideoViewModel(compositeViewModelFactory: self, + logger: logger, + dispatchAction: dispatchAction) + } + func makePrimaryButtonViewModel(buttonStyle: FluentUI.ButtonStyle, + buttonLabel: String, + iconName: CompositeIcon?, + isDisabled: Bool = false, + action: @escaping (() -> Void)) -> PrimaryButtonViewModel { + PrimaryButtonViewModel(buttonStyle: buttonStyle, + buttonLabel: buttonLabel, + iconName: iconName, + isDisabled: isDisabled, + action: action) + } + func makeAudioDeviceListViewModel(dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) -> AudioDeviceListViewModel { + AudioDeviceListViewModel(dispatchAction: dispatchAction, + localUserState: localUserState) + } + func makeErrorInfoViewModel() -> ErrorInfoViewModel { + ErrorInfoViewModel() + } + + // MARK: CallingViewModels + func makeControlBarViewModel(dispatchAction: @escaping ActionDispatch, + endCallConfirm: @escaping (() -> Void), + localUserState: LocalUserState) -> ControlBarViewModel { + ControlBarViewModel(compositeViewModelFactory: self, + logger: logger, + dispatchAction: dispatchAction, + endCallConfirm: endCallConfirm, + localUserState: localUserState) + } + func makeInfoHeaderViewModel(localUserState: LocalUserState) -> InfoHeaderViewModel { + InfoHeaderViewModel(compositeViewModelFactory: self, + logger: logger, + localUserState: localUserState) + } + func makeParticipantCellViewModel(participantModel: ParticipantInfoModel) -> ParticipantGridCellViewModel { + ParticipantGridCellViewModel(compositeViewModelFactory: self, participantModel: participantModel) + } + func makeParticipantGridsViewModel() -> ParticipantGridViewModel { + ParticipantGridViewModel(compositeViewModelFactory: self) + } + + func makeParticipantsListViewModel(localUserState: LocalUserState) -> ParticipantsListViewModel { + ParticipantsListViewModel(localUserState: localUserState) + } + func makeBannerViewModel() -> BannerViewModel { + BannerViewModel(compositeViewModelFactory: self) + } + func makeBannerTextViewModel() -> BannerTextViewModel { + BannerTextViewModel() + } + + // MARK: SetupViewModels + func makePreviewAreaViewModel(dispatchAction: @escaping ActionDispatch) -> PreviewAreaViewModel { + PreviewAreaViewModel(compositeViewModelFactory: self, + dispatchAction: dispatchAction) + } + + func makeSetupControlBarViewModel(dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) -> SetupControlBarViewModel { + SetupControlBarViewModel(compositeViewModelFactory: self, + logger: logger, + dispatchAction: dispatchAction, + localUserState: localUserState) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Icon.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Icon.swift new file mode 100644 index 000000000..640194aa4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Icon.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct Icon: View { + var name: CompositeIcon + var size: CGFloat + + var body: some View { + StyleProvider.icon.getImage(for: name) + .resizable() + .frame(width: size, height: size, alignment: .center) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeAvatar.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeAvatar.swift new file mode 100644 index 000000000..f82f54536 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeAvatar.swift @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct CompositeAvatar: View { + @Binding var displayName: String? + var isSpeaking: Bool + var avatarSize: MSFAvatarSize = .xxlarge + + var body: some View { + Avatar(style: .default, + size: avatarSize, + primaryText: displayName) + .ringColor(StyleProvider.color.primaryColor) + .isRingVisible(isSpeaking) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeButton.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeButton.swift new file mode 100644 index 000000000..28ed0a8ab --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeButton.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct CompositeButton: UIViewRepresentable { + let buttonStyle: FluentUI.ButtonStyle + let buttonLabel: String + let iconName: CompositeIcon? + + init(buttonStyle: FluentUI.ButtonStyle, buttonLabel: String, iconName: CompositeIcon? = nil) { + self.buttonStyle = buttonStyle + self.buttonLabel = buttonLabel + self.iconName = iconName + } + + func makeUIView(context: Context) -> FluentUI.Button { + let button = Button(style: buttonStyle) + button.setTitle(buttonLabel, for: .normal) + button.titleLabel?.numberOfLines = 0 + + if let iconName = iconName { + let icon = StyleProvider.icon.getUIImage(for: iconName) + button.image = icon + } + + return button + } + + func updateUIView(_ uiView: FluentUI.Button, context: Context) { + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsList.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsList.swift new file mode 100644 index 000000000..321be356f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsList.swift @@ -0,0 +1,56 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct CompositeParticipantsList: UIViewControllerRepresentable { + @Binding var isPresented: Bool + @Binding var isInfoHeaderDisplayed: Bool + @ObservedObject var viewModel: ParticipantsListViewModel + let sourceView: UIView + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented, + isInfoHeaderDisplayed: $isInfoHeaderDisplayed) + } + + func makeUIViewController(context: Context) -> DrawerContainerViewController { + let controller = ParticipantsListViewController(items: getParticipantsList(), + sourceView: sourceView) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: DrawerContainerViewController, + context: Context) { + uiViewController.updateDrawerList(items: getParticipantsList()) + } + + static func dismantleUIViewController(_ controller: DrawerContainerViewController, + coordinator: Coordinator) { + controller.dismissDrawer() + } + + private func getParticipantsList() -> [ParticipantsListCellViewModel] { + return viewModel.sortedParticipants() + } + + class Coordinator: NSObject, DrawerControllerDelegate { + @Binding var isPresented: Bool + @Binding var isInfoHeaderDisplayed: Bool + + init(isPresented: Binding, + isInfoHeaderDisplayed: Binding) { + self._isPresented = isPresented + self._isInfoHeaderDisplayed = isInfoHeaderDisplayed + } + + func drawerControllerDidDismiss(_ controller: DrawerController) { + isPresented = false + isInfoHeaderDisplayed = false + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsListCell.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsListCell.swift new file mode 100644 index 000000000..cd2398610 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositeParticipantsListCell.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import FluentUI + +class CompositeParticipantsListCell: TableViewCell { + func setup(displayName: String, isMuted: Bool, accessoryType: TableViewCellAccessoryType = .none) { + let avatar = MSFAvatar(style: .accent, size: .medium) + avatar.state.primaryText = displayName + let avatarView = avatar.view + + var micImageView: UIImageView? + if isMuted { + let micImage = StyleProvider.icon.getUIImage(for: .micOff)? + .withTintColor(StyleProvider.color.mute, renderingMode: .alwaysOriginal) + micImageView = UIImageView(image: micImage) + } + + backgroundColor = UIDevice.current.userInterfaceIdiom == .pad + ? StyleProvider.color.popoverColor + : StyleProvider.color.drawerColor + + setup(title: displayName, + customView: avatarView, + customAccessoryView: micImageView) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositePopupMenu.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositePopupMenu.swift new file mode 100644 index 000000000..418d3d3c7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/CompositePopupMenu.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct CompositePopupMenu: UIViewControllerRepresentable { + @Binding var isPresented: Bool + @ObservedObject var viewModel: AudioDeviceListViewModel + let sourceView: UIView + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + func makeUIViewController(context: Context) -> DrawerContainerViewController { + let controller = PopupMenuViewController(items: viewModel.audioDeviceList, + sourceView: sourceView) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: DrawerContainerViewController, + context: Context) { + uiViewController.updateDrawerList(items: viewModel.audioDeviceList) + } + + static func dismantleUIViewController(_ uiViewController: DrawerContainerViewController, + coordinator: Coordinator) { + uiViewController.dismissDrawer() + } + + class Coordinator: NSObject, DrawerControllerDelegate { + @Binding var isPresented: Bool + + init(isPresented: Binding) { + self._isPresented = isPresented + } + + func drawerControllerDidDismiss(_ controller: DrawerController) { + isPresented = false + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/DrawerContainerViewController.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/DrawerContainerViewController.swift new file mode 100644 index 000000000..4a639468f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/DrawerContainerViewController.swift @@ -0,0 +1,75 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI + +class DrawerContainerViewController: UIViewController, DrawerControllerDelegate { + var items: [T] = [] + let sourceView: UIView + + var drawerController: DrawerController? { + return nil + } + + weak var delegate: DrawerControllerDelegate? + + init(items: [T], sourceView: UIView) { + self.items = items + self.sourceView = sourceView + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + showDrawerView() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || isMovingFromParent { + sourceView.superview?.isUserInteractionEnabled = true + sourceView.removeFromSuperview() + } + if UIDevice.current.userInterfaceIdiom == .phone { + UIDevice.current.setValue(UIDevice.current.orientation.rawValue, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + } + } + + func dismissDrawer() { + drawerController?.dismiss(animated: false) + } + + func showDrawerView() { + DispatchQueue.main.async { + guard let topViewController = UIWindow.keyWindow?.topViewController, + let topView = topViewController.view else { + return + } + + if !topView.subviews.contains(self.sourceView) { + self.sourceView.isHidden = true + topView.isUserInteractionEnabled = false + topView.addSubview(self.sourceView) + } + + if let drawerController = self.getDrawerController(from: self.sourceView) { + topViewController.present(drawerController, animated: true, completion: nil) + } + } + } + + func updateDrawerList(items: [T]) { + self.items = items + } + + func getDrawerController(from sourceView: UIView) -> DrawerController? { + return nil + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/ParticipantsListViewController.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/ParticipantsListViewController.swift new file mode 100644 index 000000000..1a46ac182 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/ParticipantsListViewController.swift @@ -0,0 +1,107 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI + +class ParticipantsListViewController: DrawerContainerViewController { + private weak var controller: DrawerController? + override var drawerController: DrawerController? { + return controller + } + + var halfScreenHeight: CGFloat { + UIScreen.main.bounds.height / 2 + } + + let drawerResizeBarHeight: CGFloat = 25 + let backgroundColor: UIColor = UIDevice.current.userInterfaceIdiom == .pad + ? StyleProvider.color.popoverColor + : StyleProvider.color.drawerColor + + lazy var participantsListTableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundColor = backgroundColor + tableView.sectionHeaderHeight = 0 + tableView.sectionFooterHeight = 0 + tableView.allowsSelection = false + tableView.delegate = self + tableView.dataSource = self + tableView.register(CompositeParticipantsListCell.self, + forCellReuseIdentifier: CompositeParticipantsListCell.identifier) + return tableView + }() + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + resizeParticipantsListDrawer() + } + + override func updateDrawerList(items: [ParticipantsListCellViewModel]) { + super.updateDrawerList(items: items) + resizeParticipantsListDrawer() + } + + override func getDrawerController(from sourceView: UIView) -> DrawerController { + let controller = DrawerController( + sourceView: sourceView, + sourceRect: sourceView.bounds, + presentationDirection: .up) + controller.delegate = self.delegate + let contentView = participantsListTableView + controller.contentView = contentView + controller.resizingBehavior = .dismiss + controller.backgroundColor = backgroundColor + + self.controller = controller + resizeParticipantsListDrawer() + return controller + } + + private func resizeParticipantsListDrawer() { + let isiPhoneLayout = UIDevice.current.userInterfaceIdiom == .phone + var isScrollEnabled = !isiPhoneLayout + var drawerHeight = CGFloat(self.items.count * 44) + + if isiPhoneLayout { + // workaround to adjust cell divider height for drawer resize + let tableCellsDividerOffsetHeight = CGFloat(self.items.count * 3) + drawerHeight += tableCellsDividerOffsetHeight + self.drawerResizeBarHeight + } else { + drawerHeight = CGFloat(self.items.count) * 48.5 + } + if drawerHeight > self.halfScreenHeight { + drawerHeight = self.halfScreenHeight + isScrollEnabled = true + } + + DispatchQueue.main.async { + self.participantsListTableView.reloadData() + self.participantsListTableView.isScrollEnabled = isScrollEnabled + self.controller?.preferredContentSize = CGSize(width: 400, height: drawerHeight) + } + } +} + +extension ParticipantsListViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.items.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard indexPath.row < self.items.count, + let cell = tableView.dequeueReusableCell( + withIdentifier: CompositeParticipantsListCell.identifier, + for: indexPath) as? CompositeParticipantsListCell else { + return UITableViewCell() + } + let participant = self.items[indexPath.row] + cell.setup(displayName: participant.displayName, isMuted: participant.isMuted) + return cell + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/PopupMenuViewController.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/PopupMenuViewController.swift new file mode 100644 index 000000000..dfb578252 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/FluentUI/Wrapper/PopupMenuViewController.swift @@ -0,0 +1,52 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI + +class PopupMenuViewController: DrawerContainerViewController { + private weak var controller: PopupMenuController? + override var drawerController: DrawerController? { + return controller + } + + let backgroundColor: UIColor = UIDevice.current.userInterfaceIdiom == .pad + ? StyleProvider.color.popoverColor + : StyleProvider.color.drawerColor + + override func updateDrawerList(items: [PopupMenuViewModel]) { + super.updateDrawerList(items: items) + + if let selectedItemIndex = items.firstIndex(where: { $0.isSelected }) { + let selectedItemIndexPath = IndexPath(item: selectedItemIndex, section: 0) + controller?.selectedItemIndexPath = selectedItemIndexPath + controller?.viewWillAppear(true) + } + } + + override func getDrawerController(from sourceView: UIView) -> DrawerController { + let controller = PopupMenuController(sourceView: sourceView, + sourceRect: sourceView.bounds, + presentationDirection: .up) + controller.backgroundColor = backgroundColor + controller.delegate = self.delegate + let popupMenuItems = self.items.map({ item -> PopupMenuItem in + let popupMenuItem = PopupMenuItem(image: StyleProvider.icon.getUIImage(for: item.icon), + title: item.title, + isSelected: item.isSelected, + onSelected: item.onSelected) + popupMenuItem.imageColor = StyleProvider.color.onBackground + popupMenuItem.titleColor = StyleProvider.color.onBackground + popupMenuItem.imageSelectedColor = StyleProvider.color.onBackground + popupMenuItem.titleSelectedColor = StyleProvider.color.onBackground + popupMenuItem.accessoryCheckmarkColor = StyleProvider.color.onBackground + + return popupMenuItem + }) + controller.addItems(popupMenuItems) + + self.controller = controller + return controller + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AppLifeCycleManager.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AppLifeCycleManager.swift new file mode 100644 index 000000000..be50e3222 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AppLifeCycleManager.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit +import Combine + +protocol LifeCycleManager { + +} + +class UIKitAppLifeCycleManager: LifeCycleManager { + + private let logger: Logger + private let store: Store + + var cancellables = Set() + + init(store: Store, + logger: Logger) { + self.logger = logger + self.store = store + NotificationCenter.default.addObserver(self, + selector: #selector(willDeactivate), + name: UIScene.willDeactivateNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(didActivate), + name: UIScene.didActivateNotification, + object: nil) + } + + @objc func willDeactivate(_ notification: Notification) { + logger.debug("Will Deactivate") + let appLifeCycleAction = LifecycleAction.BackgroundEntered() + store.dispatch(action: appLifeCycleAction) + } + @objc func didActivate(_ notification: Notification) { + logger.debug("Did Activate") + + let appLifeCycleAction = LifecycleAction.ForegroundEntered() + store.dispatch(action: appLifeCycleAction) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioDeviceType.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioDeviceType.swift new file mode 100644 index 000000000..4068a035b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioDeviceType.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +enum AudioDeviceType: String, CaseIterable { + case receiver = "iOS" + case speaker = "Speaker" + + var name: String { + if self == .receiver { + switch UIDevice.current.userInterfaceIdiom { + case .phone: + return "iPhone" + case .pad: + return "iPad" + default: + return self.rawValue + } + } + return self.rawValue + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioSessionManager.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioSessionManager.swift new file mode 100644 index 000000000..d1218fa6d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/AudioSessionManager.swift @@ -0,0 +1,97 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import AVFoundation +import Combine + +protocol AudioSessionManager { + +} + +class AppAudioSessionManager: AudioSessionManager { + private let logger: Logger + private let store: Store + var cancellables = Set() + + init(store: Store, + logger: Logger) { + self.store = store + self.logger = logger + self.setupAudioSession() + store.$state + .sink { [weak self] state in + self?.receive(state: state) + }.store(in: &cancellables) + } + + private func receive(state: AppState) { + let localUserState = state.localUserState + handle(state: localUserState.audioState.device) + } + + private func handle(state: LocalUserState.AudioDeviceSelectionStatus) { + switch state { + case .speakerRequested: + switchAudioDevice(to: .speaker) + case .receiverRequested: + switchAudioDevice(to: .receiver) + default: + break + } + } + + private func setupAudioSession() { + let audioSession = AVAudioSession.sharedInstance() + do { + let options: AVAudioSession.CategoryOptions = [.allowBluetooth, + .duckOthers, + .interruptSpokenAudioAndMixWithOthers, + .allowBluetoothA2DP] + try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: options) + try audioSession.setActive(true) + } catch let error { + logger.error("Failed to set audio session category:\(error.localizedDescription)") + } + + NotificationCenter.default.addObserver(self, + selector: #selector(handleRouteChange), + name: AVAudioSession.routeChangeNotification, + object: nil) + } + + private func getCurrentAudioDevice() -> AudioDeviceType { + let audioSession = AVAudioSession.sharedInstance() + + for output in audioSession.currentRoute.outputs where output.portType == .builtInSpeaker { + return .speaker + } + return .receiver + } + + private func switchAudioDevice(to selectedAudioDevice: AudioDeviceType) { + let audioSession = AVAudioSession.sharedInstance() + + let audioPort: AVAudioSession.PortOverride + switch selectedAudioDevice { + case .receiver: + audioPort = .none + case .speaker: + audioPort = .speaker + } + + do { + try audioSession.setActive(true) + try audioSession.overrideOutputAudioPort(audioPort) + store.dispatch(action: LocalUserAction.AudioDeviceChangeSucceeded(device: selectedAudioDevice)) + } catch let error { + logger.error("Failed to select audio device, reason:\(error.localizedDescription)") + store.dispatch(action: LocalUserAction.AudioDeviceChangeFailed(error: error)) + } + } + + @objc func handleRouteChange(notification: Notification) { + store.dispatch(action: LocalUserAction.AudioDeviceChangeRequested(device: getCurrentAudioDevice())) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/CompositeErrorManager.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/CompositeErrorManager.swift new file mode 100644 index 000000000..1a0ffb2ba --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/CompositeErrorManager.swift @@ -0,0 +1,64 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +protocol ErrorManager { +} + +class CompositeErrorManager: ErrorManager { + private let store: Store + private weak var eventsHandler: CallCompositeEventsHandler? + private var error: ErrorEvent? + + var cancellables = Set() + + init(store: Store, + callCompositeEventsHandler: CallCompositeEventsHandler?) { + self.store = store + self.eventsHandler = callCompositeEventsHandler + store.$state + .receive(on: RunLoop.main) + .sink { [weak self] state in + self?.receive(state) + }.store(in: &cancellables) + } + + private func receive(_ state: AppState) { + if let error = state.errorState.error { + switch state.errorState.errorCategory { + case .fatal: + update(error: error) + respondToFatalError(code: error.code) + case .callState: + update(error: error) + case .none: + break + } + } + } + + private func update(error: ErrorEvent) { + guard self.error != error else { + return + } + + self.error = error + guard let eventsHandler = eventsHandler, + let didFail = eventsHandler.didFail else { + return + } + didFail(error) + } + + private func respondToFatalError(code: String) { + if code == CallCompositeErrorCode.tokenExpired || + code == CallCompositeErrorCode.callJoin || + code == CallCompositeErrorCode.callEnd { + store.dispatch(action: CompositeExitAction()) + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/PermissionsManager.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/PermissionsManager.swift new file mode 100644 index 000000000..3df9b2c41 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Manager/PermissionsManager.swift @@ -0,0 +1,152 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AVFoundation +import Combine + +protocol PermissionsManager { + func resolveStatus(for permission: AppPermission) -> AppPermission.Status + func request(_ permission: AppPermission) -> Future +} + +class AppPermissionsManager: PermissionsManager { + private var audioPermission: AppPermission.Status? + private var cameraPermission: AppPermission.Status? + private let store: Store + var cancellables = Set() + + init(store: Store) { + self.store = store + store.$state + .sink { [weak self] state in + self?.receive(state) + }.store(in: &cancellables) + } + + private func receive(_ state: AppState) { + let permissionState = state.permissionState + if audioPermission != permissionState.audioPermission { + audioPermission = permissionState.audioPermission + handle(permission: .audioPermission, + state: permissionState.audioPermission) + } + if cameraPermission != permissionState.cameraPermission { + cameraPermission = permissionState.cameraPermission + handle(permission: .cameraPermission, + state: permissionState.cameraPermission) + } + } + + private func handle(permission: AppPermission, state: AppPermission.Status) { + switch state { + case .unknown: + let state = resolveStatus(for: permission) + let setPermission = PermissionAction.generateAction(permission: permission, state: state) + store.dispatch(action: setPermission) + case .requesting: + request(permission) + .sink(receiveValue: { [weak self] state in + guard let self = self else { + return + } + + self.setPermissionState(permission: permission, state: state) + }).store(in: &cancellables) + default: + break + } + } + + private func setPermissionState(permission: AppPermission, state: AppPermission.Status) { + let setPermission = PermissionAction.generateAction(permission: permission, state: state) + self.store.dispatch(action: setPermission) + } + + func resolveStatus(for permission: AppPermission) -> AppPermission.Status { + switch permission { + case .audioPermission: + return getAudioPermissionStatus() + case .cameraPermission: + return getVideoPermissionStatus() + } + } + + func request(_ permission: AppPermission) -> Future { + switch permission { + case .audioPermission: + return requestAudioPermissions() + case .cameraPermission: + return requestVideoPermissions() + } + } +} + +// MARK: - Push Notifications + +extension AVAudioSession.RecordPermission { + var map: AppPermission.Status { + switch self { + case .denied: + return .denied + case .granted: + return .granted + case .undetermined: + return .notAsked + @unknown default: + return .notAsked + } + } +} + +extension AVAuthorizationStatus { + var map: AppPermission.Status { + switch self { + case .denied, + .restricted: + return .denied + case .authorized: + return .granted + case .notDetermined: + return .notAsked + @unknown default: + return .notAsked + } + } +} + +private extension AppPermissionsManager { + + func getAudioPermissionStatus() -> AppPermission.Status { + let audioSession = AVAudioSession.sharedInstance().recordPermission + return audioSession.map + + } + + func getVideoPermissionStatus() -> AppPermission.Status { + let audioSession = AVCaptureDevice.authorizationStatus(for: .video) + return audioSession.map + + } + + func requestAudioPermissions() -> Future { + return Future { promise in + + AVAudioSession.sharedInstance().requestRecordPermission { [weak self] _ in + promise(Result.success(self?.getAudioPermissionStatus() ?? .unknown)) + } + } + } + + func requestVideoPermissions() -> Future { + return Future { promise in + + AVCaptureDevice.requestAccess(for: .video) { [weak self] _ in + + promise(Result.success(self?.getVideoPermissionStatus() ?? .unknown)) + } + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Navigation/NavigationRouter.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Navigation/NavigationRouter.swift new file mode 100644 index 000000000..aac158988 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Navigation/NavigationRouter.swift @@ -0,0 +1,60 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import Combine + +enum ViewType { + case setupView + case callingView +} + +class NavigationRouter: ObservableObject { + private let store: Store + private let logger: Logger + private var isDismissed: Bool = false + @Published var currentView: ViewType = .setupView + + var cancellables = Set() + private var dismissCompositeHostingVC: (() -> Void)? + + init(store: Store, logger: Logger) { + self.store = store + self.logger = logger + + store.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.receive(state) + }.store(in: &cancellables) + } + + func receive(_ state: AppState) { + var viewToNavigateTo: ViewType? + + switch state.navigationState.status { + case .setup: + viewToNavigateTo = .setupView + case .inCall: + viewToNavigateTo = .callingView + case .exit: + guard !self.isDismissed else { + return + } + dismissCompositeHostingVC?() + isDismissed = true + } + + if viewToNavigateTo != nil, + currentView != viewToNavigateTo! { + logger.debug("Navigating to: \(viewToNavigateTo!)" ) + currentView = viewToNavigateTo! + } + } + + func setDismissComposite(_ closure: @escaping () -> Void) { + self.dismissCompositeHostingVC = closure + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/ColorThemeProvider.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/ColorThemeProvider.swift new file mode 100644 index 000000000..abf7c1d95 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/ColorThemeProvider.swift @@ -0,0 +1,71 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import FluentUI + +class ColorThemeProvider: ColorProviding { + let primaryColor: UIColor + let backgroundColor = UIColor.compositeColor(.background) + let gridLayoutBackground = UIColor.compositeColor(.gridLayoutBackground) + let onSurfaceColor = UIColor.compositeColor(.onSurface) + let mute = UIColor.compositeColor(.mute) + let disableColor = UIColor.compositeColor(.disabled) + let error = UIColor.compositeColor(.error) + let onBackground = UIColor.compositeColor(.onBackground) + let onDisabled = UIColor.compositeColor(.onDisabled) + let onError = UIColor.compositeColor(.onError) + let onPrimary = UIColor.compositeColor(.onPrimary) + let onSuccess = UIColor.compositeColor(.onSuccess) + let onSurface = UIColor.compositeColor(.onSurface) + let onWarning = UIColor.compositeColor(.onWarning) + let success = UIColor.compositeColor(.success) + let warning = UIColor.compositeColor(.warning) + let surface = UIColor.compositeColor(.surface) + let surfaceDarkColor = UIColor.compositeColor(.surfaceDarkColor) + let surfaceLightColor = UIColor.compositeColor(.surfaceLightColor) + let drawerColor = UIColor.compositeColor(.drawerColor) + let popoverColor = UIColor.compositeColor(.popoverColor) + let gradientColor = UIColor.compositeColor(.gradientColor) + let hangup = UIColor.compositeColor(.hangup) + let overlay = UIColor.compositeColor(.overlay) + + init(themeConfiguration: ThemeConfiguration?) { + self.primaryColor = themeConfiguration?.primaryColor ?? UIColor.compositeColor(.primary) + } + + func primaryColor(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryTint10Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryTint20Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryTint30Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryTint40Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryShade10Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryShade20Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + + func primaryShade30Color(for window: UIWindow) -> UIColor? { + return primaryColor + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/IconProvider.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/IconProvider.swift new file mode 100644 index 000000000..c5bb27b2a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/IconProvider.swift @@ -0,0 +1,38 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import FluentUI +import SwiftUI + +enum CompositeIcon: String { + case cameraSwitch = "ic_fluent_camera_switch_24_regular" + case meetNow = "ic_fluent_meet_now_20_regular" + case micOff = "ic_fluent_mic_off_24_filled" + case micOn = "ic_fluent_mic_on_24_filled" + case speakerFilled = "ic_fluent_speaker_2_24_filled" + case speakerRegular = "ic_fluent_speaker_2_24_regular" + case videoOn = "ic_fluent_video_24_filled" + case videoOff = "ic_fluent_video_off_24_filled" + case warning = "ic_fluent_warning_24_filled" + case endCall = "ic_fluent_call_end_24_filled" + case showParticipant = "ic_fluent_people_24_regular" + case leftArrow = "ic_ios_arrow_left_24" + case dismiss = "ic_fluent_dismiss_16_regular" + case clock = "ic_fluent_clock_24_filled" +} + +struct IconProvider { + func getUIImage(for iconName: CompositeIcon) -> UIImage? { + UIImage(named: "Icon/\(iconName.rawValue)", + in: Bundle(for: CallComposite.self), + compatibleWith: nil) + } + func getImage(for iconName: CompositeIcon) -> Image { + Image("Icon/\(iconName.rawValue)", bundle: Bundle(for: CallComposite.self)) + .resizable() + .renderingMode(.template) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/StyleProvider.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/StyleProvider.swift new file mode 100644 index 000000000..89ec675f5 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/Style/StyleProvider.swift @@ -0,0 +1,12 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct StyleProvider { + static var color = ColorThemeProvider(themeConfiguration: nil) + static var icon = IconProvider() + // static var font = FontProvider() +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingView.swift new file mode 100644 index 000000000..0181b66be --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingView.swift @@ -0,0 +1,148 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct CallingView: View { + @ObservedObject var viewModel: CallingViewModel + let viewManager: VideoViewManager + + @Environment(\.horizontalSizeClass) var widthSizeClass: UserInterfaceSizeClass? + @Environment(\.verticalSizeClass) var heightSizeClass: UserInterfaceSizeClass? + + var safeAreaIgnoreArea: Edge.Set { + return getSizeClass() != .iphoneLandscapeScreenSize ? []: [.bottom] + } + + var body: some View { + ZStack { + if getSizeClass() != .iphoneLandscapeScreenSize { + portraitCallingView + } else { + landscapeCallingView + } + } + .environment(\.screenSizeClass, getSizeClass()) + .edgesIgnoringSafeArea(safeAreaIgnoreArea) + .onAppear(perform: viewModel.startCall) + .modifier(PopupModalView(isPresented: viewModel.isConfirmLeaveOverlayDisplayed) { + ConfirmLeaveOverlayView(viewModel: viewModel) + }) + } + + var portraitCallingView: some View { + VStack(alignment: .center, spacing: 0) { + containerView + controlBarView + } + } + + var landscapeCallingView: some View { + HStack(alignment: .center, spacing: 0) { + containerView + controlBarView + } + } + + var containerView: some View { + Group { + ZStack(alignment: .bottomTrailing) { + videoGridView + topAlertAreaView + if viewModel.isParticipantGridDisplayed { + localVideoPipView + .padding(.horizontal, -12) + .padding(.vertical, -12) + } + } + .contentShape(Rectangle()) + .animation(.linear(duration: 0.167)) + .onTapGesture(perform: { + viewModel.infoHeaderViewModel.toggleDisplayInfoHeader() + }) + .modifier(PopupModalView(isPresented: viewModel.isLobbyOverlayDisplayed) { + LobbyOverlayView() + }) + } + } + + var localVideoPipView: some View { + let shapeCornerRadius: CGFloat = 4 + let isPortraitMode = getSizeClass() != .iphoneLandscapeScreenSize + let frameWidth: CGFloat = isPortraitMode ? 72 : 104 + let frameHeight: CGFloat = isPortraitMode ? 104 : 72 + + return Group { + LocalVideoView(viewModel: viewModel.localVideoViewModel, + viewManager: viewManager, + viewType: .localVideoPip) + .frame(width: frameWidth, height: frameHeight, alignment: .center) + .background(Color(StyleProvider.color.backgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: shapeCornerRadius)) + .padding() + } + } + + var topAlertAreaView: some View { + VStack { + bannerView + infoHeaderView + .padding(.horizontal, 8) + Spacer() + } + } + + var infoHeaderView: some View { + InfoHeaderView(viewModel: viewModel.infoHeaderViewModel) + } + + var bannerView: some View { + BannerView(viewModel: viewModel.bannerViewModel) + } + + var participantGridsView: some View { + ParticipantGridView(viewModel: viewModel.participantGridsViewModel, + videoViewManager: viewManager, + screenSize: getSizeClass()) + .edgesIgnoringSafeArea(safeAreaIgnoreArea) + } + + var localVideoFullscreenView: some View { + return Group { + LocalVideoView(viewModel: viewModel.localVideoViewModel, + viewManager: viewManager, + viewType: .localVideofull) + .background(Color(StyleProvider.color.surface)) + .edgesIgnoringSafeArea(safeAreaIgnoreArea) + } + } + + var videoGridView: some View { + Group { + if viewModel.isParticipantGridDisplayed { + participantGridsView + } else { + localVideoFullscreenView + } + } + } + + var controlBarView: some View { + ControlBarView(viewModel: viewModel.controlBarViewModel) + } + + private func getSizeClass() -> ScreenSizeClassType { + switch (widthSizeClass, heightSizeClass) { + case (.compact, .regular): + return .iphonePortraitScreenSize + case (.compact, .compact), + (.regular, .compact): + return .iphoneLandscapeScreenSize + default: + return .ipadScreenSize + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerInfoType.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerInfoType.swift new file mode 100644 index 000000000..eb328c8ac --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerInfoType.swift @@ -0,0 +1,86 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +enum BannerInfoType: Equatable { + case recordingAndTranscriptionStarted + case recordingStarted + case transcriptionStoppedStillRecording + case transcriptionStarted + case transcriptionStoppedAndSaved + case recordingStoppedStillTranscribing + case recordingStopped + case recordingAndTranscriptionStopped + + var title: String { + switch self { + case .recordingAndTranscriptionStarted: + return "Recording and transcription have started. " + case .recordingStarted: + return "Recording has started. " + case .transcriptionStoppedStillRecording: + return "Transcription has stopped. " + case .transcriptionStarted: + return "Transcription has started. " + case .transcriptionStoppedAndSaved: + return "Transcription is being saved. " + case .recordingStoppedStillTranscribing: + return "Recording has stopped. " + case .recordingStopped: + return "Recording is being saved. " + case .recordingAndTranscriptionStopped: + return "Recording and transcription are being saved. " + } + } + + var body: String { + switch self { + case .recordingAndTranscriptionStarted, + .recordingStarted, + .transcriptionStarted: + return "By joining, you are giving consent for this meeting to be transcribed. " + case .transcriptionStoppedStillRecording: + return "You are now only recording this meeting. " + case .transcriptionStoppedAndSaved: + return "Transcription has stopped. " + case .recordingStoppedStillTranscribing: + return "You are now only transcribing this meeting. " + case .recordingStopped: + return "Recording has stopped. " + case .recordingAndTranscriptionStopped: + return "Recording and transcription have stopped. " + } + } + + var linkDisplay: String { + switch self { + case .recordingAndTranscriptionStarted, + .recordingStarted, + .transcriptionStoppedStillRecording, + .transcriptionStarted, + .recordingStoppedStillTranscribing: + return "Privacy policy" + case .transcriptionStoppedAndSaved, + .recordingStopped, + .recordingAndTranscriptionStopped: + return "Learn more" + } + } + + var link: String { + switch self { + case .recordingAndTranscriptionStarted, + .recordingStarted, + .transcriptionStoppedStillRecording, + .transcriptionStarted, + .recordingStoppedStillTranscribing: + return "https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule" + case .transcriptionStoppedAndSaved, + .recordingStopped, + .recordingAndTranscriptionStopped: + return ("https://support.microsoft.com/en-us/office/" + + "record-a-meeting-in-teams-34dfbe7f-b07d-4a27-b4c6-de62f1348c24") + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextView.swift new file mode 100644 index 000000000..aaa9b0188 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextView.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct BannerTextView: View { + @ObservedObject var viewModel: BannerTextViewModel + + var body: some View { + Group { + Text(viewModel.title).bold() + + Text(viewModel.body) + + Text(viewModel.linkDisplay).underline() + } + .font(Fonts.footnote.font) + .onTapGesture { + if let url = URL(string: viewModel.link) { + UIApplication.shared.open(url) + } + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextViewModel.swift new file mode 100644 index 000000000..23e516aae --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerTextViewModel.swift @@ -0,0 +1,29 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI + +class BannerTextViewModel: ObservableObject { + var title: String = "" + var body: String = "" + var linkDisplay: String = "" + var link: String = "" + + func update(bannerInfoType: BannerInfoType?) { + if let bannerInfoType = bannerInfoType { + self.title = bannerInfoType.title + self.body = bannerInfoType.body + self.linkDisplay = bannerInfoType.linkDisplay + self.link = bannerInfoType.link + } else { + self.title = "" + self.body = "" + self.linkDisplay = "" + self.link = "" + } + objectWillChange.send() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerView.swift new file mode 100644 index 000000000..68125e507 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerView.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct BannerView: View { + @ObservedObject var viewModel: BannerViewModel + + var body: some View { + if viewModel.isBannerDisplayed { + HStack(alignment: .top) { + BannerTextView(viewModel: viewModel.bannerTextViewModel) + .padding([.top, .leading, .bottom]) + Spacer() + dismissButton + .padding([.top, .trailing]) + } + .background(Color(StyleProvider.color.backgroundColor)) + } else { + Spacer() + .frame(height: 8) + } + } + + var dismissButton: some View { + return IconButton(viewModel: viewModel.dismissButtonViewModel) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerViewModel.swift new file mode 100644 index 000000000..5341194da --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/Banner/BannerViewModel.swift @@ -0,0 +1,102 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class BannerViewModel: ObservableObject { + enum FeatureStatus: Equatable { + case on + case off + case stopped + } + + @Published var isBannerDisplayed: Bool = false + var bannerTextViewModel: BannerTextViewModel + + private var callingState = CallingState() + private var recordingState: FeatureStatus = .off + private var transcriptionState: FeatureStatus = .off + + var dismissButtonViewModel: IconButtonViewModel! + + init(compositeViewModelFactory: CompositeViewModelFactory) { + self.bannerTextViewModel = compositeViewModelFactory.makeBannerTextViewModel() + self.dismissButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .dismiss, + buttonType: .dismissButton, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.dismissBanner() + } + } + + func update(callingState: CallingState) { + if self.callingState.isRecordingActive != callingState.isRecordingActive || + self.callingState.isTranscriptionActive != callingState.isTranscriptionActive { + self.updateBanner(callingState: callingState) + self.callingState = callingState + } + } + + private func updateBanner(callingState: CallingState) { + if callingState.isRecordingActive { + recordingState = .on + } else { + recordingState = recordingState == .on ? .stopped : recordingState + } + if callingState.isTranscriptionActive { + transcriptionState = .on + } else { + transcriptionState = transcriptionState == .on ? .stopped : transcriptionState + } + displayBanner(recordingState: recordingState, transcriptionState: transcriptionState) + if recordingState == .stopped, + transcriptionState == .stopped { + recordingState = .off + transcriptionState = .off + } + } + + private func displayBanner(recordingState: FeatureStatus, + transcriptionState: FeatureStatus) { + var toDisplayBanner = true + switch(recordingState, transcriptionState) { + case (.on, .on): + bannerTextViewModel.update(bannerInfoType: .recordingAndTranscriptionStarted) + case (.on, .off): + bannerTextViewModel.update(bannerInfoType: .recordingStarted) + case (.on, .stopped): + bannerTextViewModel.update(bannerInfoType: .transcriptionStoppedStillRecording) + case (.off, .on): + bannerTextViewModel.update(bannerInfoType: .transcriptionStarted) + case (.off, .off): + bannerTextViewModel.update(bannerInfoType: nil) + toDisplayBanner = false + case (.off, .stopped): + bannerTextViewModel.update(bannerInfoType: .transcriptionStoppedAndSaved) + case (.stopped, .on): + bannerTextViewModel.update(bannerInfoType: .recordingStoppedStillTranscribing) + case (.stopped, .off): + bannerTextViewModel.update(bannerInfoType: .recordingStopped) + case (.stopped, .stopped): + bannerTextViewModel.update(bannerInfoType: .recordingAndTranscriptionStopped) + } + isBannerDisplayed = toDisplayBanner + } + + private func dismissBanner() { + isBannerDisplayed = false + if recordingState == .stopped { + recordingState = .off + } + if transcriptionState == .stopped { + transcriptionState = .off + } + bannerTextViewModel.update(bannerInfoType: nil) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ConfirmLeaveOverlayView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ConfirmLeaveOverlayView.swift new file mode 100644 index 000000000..84a66cc8b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ConfirmLeaveOverlayView.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ConfirmLeaveOverlayView: View { + @ObservedObject var viewModel: CallingViewModel + let controlWidth: CGFloat = 340 + + @Environment(\.screenSizeClass) var screenSizeClass: ScreenSizeClassType + + var body: some View { + GeometryReader { geometry in + VStack { + leaveCallButton + .frame(width: getButtonWidth(from: geometry)) + .padding(.all) + cancelButton + .frame(width: getButtonWidth(from: geometry)) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.7)) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + viewModel.dismissConfirmLeaveOverlay() + } + } + } + + func getButtonWidth(from geometry: GeometryProxy) -> CGFloat { + if screenSizeClass == .ipadScreenSize { + return geometry.size.width * 0.4 + } + return min(controlWidth, geometry.size.width * 0.8) + } + + var leaveCallButton: some View { + return PrimaryButton(viewModel: viewModel.getLeaveCallButtonViewModel()) + } + + var cancelButton: some View { + return PrimaryButton(viewModel: viewModel.getCancelButtonViewModel()) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarView.swift new file mode 100644 index 000000000..edefed34c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarView.swift @@ -0,0 +1,68 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ControlBarView: View { + @ObservedObject var viewModel: ControlBarViewModel + + let audioDeviceButtonSourceView = UIView() + + @Environment(\.screenSizeClass) var screenSizeClass: ScreenSizeClassType + + var body: some View { + Group { + if screenSizeClass != .iphoneLandscapeScreenSize { + HStack { + Spacer() + videoButton + micButton + audioDeviceButton + hangUpButton + Spacer() + } + } else { + VStack { + Spacer() + hangUpButton + audioDeviceButton + micButton + videoButton + Spacer() + } + } + } + .padding() + .background(Color(StyleProvider.color.backgroundColor)) + .modifier(PopupModalView(isPresented: viewModel.isAudioDeviceSelectionDisplayed) { + audioDeviceSelectionListView + }) + } + + var videoButton: some View { + IconButton(viewModel: viewModel.cameraButtonViewModel) + } + + var micButton: some View { + IconButton(viewModel: viewModel.micButtonViewModel) + .disabled(viewModel.isMicDisabled()) + } + + var audioDeviceButton: some View { + IconButton(viewModel: viewModel.audioDeviceButtonViewModel) + .background(SourceViewSpace(sourceView: audioDeviceButtonSourceView)) + } + + var hangUpButton: some View { + IconButton(viewModel: viewModel.hangUpButtonViewModel) + } + + var audioDeviceSelectionListView: some View { + CompositePopupMenu(isPresented: $viewModel.isAudioDeviceSelectionDisplayed, + viewModel: viewModel.audioDeviceListViewModel, + sourceView: audioDeviceButtonSourceView) + .modifier(LockPhoneOrientation()) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarViewModel.swift new file mode 100644 index 000000000..137b71bf8 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarViewModel.swift @@ -0,0 +1,138 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class ControlBarViewModel: ObservableObject { + @Published var cameraPermission: AppPermission.Status = .unknown + @Published var isAudioDeviceSelectionDisplayed: Bool = false + + let audioDeviceListViewModel: AudioDeviceListViewModel + var cameraButtonViewModel: IconButtonViewModel! + var micButtonViewModel: IconButtonViewModel! + var audioDeviceButtonViewModel: IconButtonViewModel! + var hangUpButtonViewModel: IconButtonViewModel! + + var cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + var audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + + var displayEndCallConfirm: (() -> Void) + private let logger: Logger + private let dispatch: ActionDispatch + + init(compositeViewModelFactory: CompositeViewModelFactory, + logger: Logger, + dispatchAction: @escaping ActionDispatch, + endCallConfirm: @escaping (() -> Void), + localUserState: LocalUserState) { + self.logger = logger + self.dispatch = dispatchAction + self.displayEndCallConfirm = endCallConfirm + self.audioDeviceListViewModel = compositeViewModelFactory.makeAudioDeviceListViewModel( + dispatchAction: dispatch, + localUserState: localUserState) + self.cameraButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .videoOff, + buttonType: .controlButton, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Toggle camera button tapped") + self.cameraButtonTapped() + } + self.micButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .micOff, + buttonType: .controlButton, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Toggle microphone button tapped") + self.microphoneButtonTapped() + } + self.audioDeviceButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .speakerFilled, + buttonType: .controlButton, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Select audio device button tapped") + self.selectAudioDeviceButtonTapped() + } + + self.hangUpButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .endCall, + buttonType: .roundedRectButton, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Hangup button tapped") + self.endCallButtonTapped() + } + } + + func endCallButtonTapped() { + displayEndCallConfirm() + } + + func cameraButtonTapped() { + let action: Action = cameraState.operation == .on ? + LocalUserAction.CameraOffTriggered() : LocalUserAction.CameraOnTriggered() + dispatch(action) + } + + func microphoneButtonTapped() { + let action: Action = audioState.operation == .on ? + LocalUserAction.MicrophoneOffTriggered() : LocalUserAction.MicrophoneOnTriggered() + dispatch(action) + } + + func selectAudioDeviceButtonTapped() { + self.isAudioDeviceSelectionDisplayed = true + } + + func isCameraDisabled() -> Bool { + cameraPermission == .denied || cameraState.operation == .pending + } + + func isMicDisabled() -> Bool { + audioState.operation == .pending + } + + func update(localUserState: LocalUserState, permissionState: PermissionState) { + if cameraPermission != permissionState.cameraPermission { + cameraPermission = permissionState.cameraPermission + } + + cameraState = localUserState.cameraState + audioState = localUserState.audioState + cameraButtonViewModel.update(iconName: cameraState.operation == .on ? .videoOn : .videoOff) + cameraButtonViewModel.update(isDisabled: isCameraDisabled()) + micButtonViewModel.update(iconName: audioState.operation == .on ? .micOn : .micOff) + micButtonViewModel.update(isDisabled: isMicDisabled()) + audioDeviceButtonViewModel.update( + iconName: deviceIconFor(audioDeviceStatus: localUserState.audioState.device) + ) + audioDeviceListViewModel.update(audioDeviceStatus: localUserState.audioState.device) + } + + private func deviceIconFor(audioDeviceStatus: LocalUserState.AudioDeviceSelectionStatus) -> CompositeIcon { + switch audioDeviceStatus { + case .receiverSelected: + return .speakerRegular + case .speakerSelected: + return .speakerFilled + default: + return self.audioDeviceButtonViewModel.iconName + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderView.swift new file mode 100644 index 000000000..36c48d30b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderView.swift @@ -0,0 +1,57 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct InfoHeaderView: View { + @ObservedObject var viewModel: InfoHeaderViewModel + + let participantsListButtonSourceView = UIView() + let foregroundColor: Color = .white + let shapeCornerRadius: CGFloat = 5 + let infoLabelHorizontalPadding: CGFloat = 16.0 + let hStackHorizontalPadding: CGFloat = 20.0 + + var body: some View { + if viewModel.isInfoHeaderDisplayed { + HStack { + Text(viewModel.infoLabel) + .padding(EdgeInsets(top: infoLabelHorizontalPadding, + leading: 0, + bottom: infoLabelHorizontalPadding, + trailing: 0)) + .foregroundColor(foregroundColor) + .font(Fonts.caption1.font) + Spacer() + participantListButton + } + .padding(EdgeInsets(top: 0, + leading: hStackHorizontalPadding, + bottom: 0, + trailing: hStackHorizontalPadding / 2.0)) + .background(Color(StyleProvider.color.surfaceDarkColor)) + .clipShape(RoundedRectangle(cornerRadius: shapeCornerRadius)) + .modifier(PopupModalView(isPresented: viewModel.isParticipantsListDisplayed) { + participantsListView + }) + } else { + EmptyView() + } + } + + var participantListButton: some View { + IconButton(viewModel: viewModel.participantListButtonViewModel) + .background(SourceViewSpace(sourceView: participantsListButtonSourceView)) + } + + var participantsListView: some View { + CompositeParticipantsList(isPresented: $viewModel.isParticipantsListDisplayed, + isInfoHeaderDisplayed: $viewModel.isInfoHeaderDisplayed, + viewModel: viewModel.participantsListViewModel, + sourceView: participantsListButtonSourceView) + .modifier(LockPhoneOrientation()) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderViewModel.swift new file mode 100644 index 000000000..bea253444 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/InfoHeaderViewModel.swift @@ -0,0 +1,93 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import Foundation +import Combine + +class InfoHeaderViewModel: ObservableObject { + @Published var infoLabel: String = "Waiting for others to join" + @Published var isInfoHeaderDisplayed: Bool = true + @Published var isParticipantsListDisplayed: Bool = false + private let logger: Logger + private var infoHeaderDismissTimer: Timer? + private var participantsCount: Int = 0 + + let participantsListViewModel: ParticipantsListViewModel + var participantListButtonViewModel: IconButtonViewModel! + + init(compositeViewModelFactory: CompositeViewModelFactory, + logger: Logger, + localUserState: LocalUserState) { + self.logger = logger + participantsListViewModel = compositeViewModelFactory.makeParticipantsListViewModel( + localUserState: localUserState) + self.participantListButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .showParticipant, + buttonType: .infoButton, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.showParticipantListButtonButtonTapped() + } + resetTimer() + } + + func showParticipantListButtonButtonTapped() { + logger.debug("Show participant list button tapped") + self.infoHeaderDismissTimer?.invalidate() + self.displayParticipantsList() + } + + func displayParticipantsList() { + self.isParticipantsListDisplayed = true + } + + func toggleDisplayInfoHeader() { + self.isInfoHeaderDisplayed ? hideInfoHeader() : displayWithTimer() + } + + func update(localUserState: LocalUserState, remoteParticipantsState: RemoteParticipantsState) { + if participantsCount != remoteParticipantsState.participantInfoList.count { + participantsCount = remoteParticipantsState.participantInfoList.count + updateInfoLabel() + } + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + } + + private func updateInfoLabel() { + let content: String + switch participantsCount { + case 0: + content = "Waiting for others to join" + case 1: + content = "Call with 1 person" + default: + content = "Call with \(participantsCount) people" + } + infoLabel = content + } + + private func displayWithTimer() { + self.isInfoHeaderDisplayed = true + resetTimer() + } + + @objc private func hideInfoHeader() { + self.isInfoHeaderDisplayed = false + self.infoHeaderDismissTimer?.invalidate() + } + + private func resetTimer() { + self.infoHeaderDismissTimer = Timer.scheduledTimer(timeInterval: 3.0, + target: self, + selector: #selector(hideInfoHeader), + userInfo: nil, + repeats: false) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/LobbyOverlayView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/LobbyOverlayView.swift new file mode 100644 index 000000000..2522a6ac0 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/LobbyOverlayView.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI +import Combine + +struct LobbyOverlayView: View { + let title: String = "Waiting for host" + let subtitle: String = "Someone in the meeting will let you in soon" + + private let layoutSpacing: CGFloat = 24 + private let iconImageSize: CGFloat = 24 + + var body: some View { + Color(StyleProvider.color.overlay) + .overlay( + VStack(spacing: layoutSpacing) { + Icon(name: .clock, size: iconImageSize) + Text(title) + .font(Fonts.headline.font) + Text(subtitle) + .font(Fonts.subhead.font) + }) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListCellViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListCellViewModel.swift new file mode 100644 index 000000000..0872c7df3 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListCellViewModel.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class ParticipantsListCellViewModel { + let displayName: String + let isMuted: Bool + + struct Constants { + static let localParticipantNamePostfix: String = "(You)" + } + + init(localUserState: LocalUserState) { + if let displayName = localUserState.displayName { + self.displayName = "\(displayName) \(Constants.localParticipantNamePostfix)" + } else { + self.displayName = "\(Constants.localParticipantNamePostfix)" + } + + self.isMuted = localUserState.audioState.operation != .on + } + + init(participantInfoModel: ParticipantInfoModel) { + self.displayName = participantInfoModel.displayName + self.isMuted = participantInfoModel.isMuted + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListViewModel.swift new file mode 100644 index 000000000..33c8c50ce --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewComponent/ParticipantsListViewModel.swift @@ -0,0 +1,41 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class ParticipantsListViewModel: ObservableObject { + + @Published var participantsList: [ParticipantsListCellViewModel] = [] + @Published var localParticipantsListCellViewModel: ParticipantsListCellViewModel + + var lastUpdateTimeStamp = Date() + + init(localUserState: LocalUserState) { + localParticipantsListCellViewModel = ParticipantsListCellViewModel(localUserState: localUserState) + } + + func update(localUserState: LocalUserState, + remoteParticipantsState: RemoteParticipantsState) { + + if localParticipantsListCellViewModel.isMuted != (localUserState.audioState.operation == .off) { + localParticipantsListCellViewModel = ParticipantsListCellViewModel(localUserState: localUserState) + } + + if lastUpdateTimeStamp != remoteParticipantsState.lastUpdateTimeStamp { + lastUpdateTimeStamp = remoteParticipantsState.lastUpdateTimeStamp + participantsList = remoteParticipantsState.participantInfoList.map { + ParticipantsListCellViewModel(participantInfoModel: $0) + } + } + } + + func sortedParticipants() -> [ParticipantsListCellViewModel] { + // alphabetical order + return ([localParticipantsListCellViewModel] + participantsList).sorted { + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewModel.swift new file mode 100644 index 000000000..7931087af --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/CallingViewModel.swift @@ -0,0 +1,128 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class CallingViewModel: ObservableObject { + @Published var isLobbyOverlayDisplayed: Bool = false + @Published var isConfirmLeaveOverlayDisplayed: Bool = false + @Published var isParticipantGridDisplayed: Bool = false + + private let compositeViewModelFactory: CompositeViewModelFactory + private let logger: Logger + private let store: Store + private var cancellables = Set() + + var controlBarViewModel: ControlBarViewModel! + var infoHeaderViewModel: InfoHeaderViewModel! + let localVideoViewModel: LocalVideoViewModel + let participantGridsViewModel: ParticipantGridViewModel + let bannerViewModel: BannerViewModel + + init(compositeViewModelFactory: CompositeViewModelFactory, + logger: Logger, + store: Store) { + self.logger = logger + self.compositeViewModelFactory = compositeViewModelFactory + self.store = store + let actionDispatch: ActionDispatch = store.dispatch + localVideoViewModel = compositeViewModelFactory.makeLocalVideoViewModel(dispatchAction: actionDispatch) + participantGridsViewModel = compositeViewModelFactory.makeParticipantGridsViewModel() + bannerViewModel = compositeViewModelFactory.makeBannerViewModel() + + infoHeaderViewModel = compositeViewModelFactory + .makeInfoHeaderViewModel(localUserState: store.state.localUserState) + + controlBarViewModel = compositeViewModelFactory + .makeControlBarViewModel(dispatchAction: actionDispatch, endCallConfirm: { [weak self] in + guard let self = self else { + return + } + self.displayConfirmLeaveOverlay() + }, localUserState: store.state.localUserState) + + store.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.receive(state) + }.store(in: &cancellables) + } + + // MARK: ConfirmLeaveOverlay + func getLeaveCallButtonViewModel() -> PrimaryButtonViewModel { + let leaveCallButtonViewModel = compositeViewModelFactory.makePrimaryButtonViewModel( + buttonStyle: .primaryFilled, + buttonLabel: "Leave call", + iconName: nil, + isDisabled: false, + action: { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Leave call button tapped") + self.endCall() + }) + return leaveCallButtonViewModel + } + + func getCancelButtonViewModel() -> PrimaryButtonViewModel { + let cancelButtonViewModel = compositeViewModelFactory.makePrimaryButtonViewModel( + buttonStyle: .primaryOutline, + buttonLabel: "Cancel", + iconName: nil, + isDisabled: false, + action: { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Cancel button tapped") + self.dismissConfirmLeaveOverlay() + }) + return cancelButtonViewModel + } + + func displayConfirmLeaveOverlay() { + self.isConfirmLeaveOverlayDisplayed = true + } + + func dismissConfirmLeaveOverlay() { + self.isConfirmLeaveOverlayDisplayed = false + } + + func startCall() { + store.dispatch(action: CallingAction.CallStartRequested()) + } + + func endCall() { + store.dispatch(action: CallingAction.CallEndRequested()) + dismissConfirmLeaveOverlay() + } + + func receive(_ state: AppState) { + guard state.lifeCycleState.currentStatus == .foreground else { + return + } + + controlBarViewModel.update(localUserState: state.localUserState, + permissionState: state.permissionState) + infoHeaderViewModel.update(localUserState: state.localUserState, + remoteParticipantsState: state.remoteParticipantsState) + localVideoViewModel.update(localUserState: state.localUserState) + participantGridsViewModel.update(remoteParticipantsState: state.remoteParticipantsState) + bannerViewModel.update(callingState: state.callingState) + let isCallConnected = state.callingState.status == .connected + let hasRemoteParticipants = state.remoteParticipantsState.participantInfoList.count > 0 + let shouldParticipantGridDisplayed = isCallConnected && hasRemoteParticipants + if shouldParticipantGridDisplayed != isParticipantGridDisplayed { + isParticipantGridDisplayed = shouldParticipantGridDisplayed + } + + let shouldLobbyOverlayDisplayed = state.callingState.status == .inLobby + if shouldLobbyOverlayDisplayed != isLobbyOverlayDisplayed { + isLobbyOverlayDisplayed = shouldLobbyOverlayDisplayed + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellVideoView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellVideoView.swift new file mode 100644 index 000000000..eb2bea832 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellVideoView.swift @@ -0,0 +1,42 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI +import Combine + +struct ParticipantGridCellVideoView: View { + let rendererView: UIView + @Binding var isSpeaking: Bool + @Binding var displayName: String? + @Binding var isMuted: Bool + @Environment(\.screenSizeClass) var screenSizeClass: ScreenSizeClassType + + let borderColor = Color(StyleProvider.color.primaryColor) + + var body: some View { + ZStack(alignment: .bottomLeading) { + VStack(alignment: .center, spacing: 0) { + VideoRendererView(rendererView: rendererView) + } + + ParticipantTitleView(displayName: $displayName, + isMuted: $isMuted, + titleFont: Fonts.caption1.font, + mutedIconSize: 14) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background(Color(StyleProvider.color.overlay)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .padding(.leading, 4) + .padding(.bottom, screenSizeClass == .iphoneLandscapeScreenSize + && UIDevice.current.hasHomeBar ? 16 : 4) + + }.overlay( + isSpeaking && !isMuted ? RoundedRectangle(cornerRadius: 4).strokeBorder(borderColor, lineWidth: 4) : nil + ).animation(.default) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellView.swift new file mode 100644 index 000000000..952417a7d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellView.swift @@ -0,0 +1,99 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI +import Combine + +struct ParticipantGridCellView: View { + @ObservedObject var viewModel: ParticipantGridCellViewModel + let getRemoteParticipantRendererView: (RemoteParticipantVideoViewId) -> UIView? + @State var displayedVideoStreamId: String? + @State var isVideoChanging: Bool = false + let avatarSize: CGFloat = 56 + + var body: some View { + Group { + GeometryReader { geometry in + if isVideoChanging { + EmptyView() + } else if let rendererView = getRendererView() { + ParticipantGridCellVideoView(rendererView: rendererView, + isSpeaking: $viewModel.isSpeaking, + displayName: $viewModel.displayName, + isMuted: $viewModel.isMuted) + } else { + avatarView + .frame(width: geometry.size.width, + height: geometry.size.height) + } + } + } + .onReceive(viewModel.$videoStreamId) { videoStreamId in + let cachedVideoStreamId = displayedVideoStreamId + if videoStreamId != displayedVideoStreamId { + displayedVideoStreamId = videoStreamId + } + + if videoStreamId != cachedVideoStreamId, + videoStreamId != nil { + // workaround to force rendererView being recreated + isVideoChanging = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isVideoChanging = false + } + } + } + } + + func getRendererView() -> UIView? { + guard let videoStreamId = viewModel.videoStreamId, + !videoStreamId.isEmpty else { + return nil + } + let userId = viewModel.participantIdentifier + let remoteParticipantVideoViewId = RemoteParticipantVideoViewId(userIdentifier: userId, + videoStreamIdentifier: videoStreamId) + + return getRemoteParticipantRendererView(remoteParticipantVideoViewId) + } + + var avatarView: some View { + VStack(alignment: .center, spacing: 5) { + CompositeAvatar(displayName: $viewModel.displayName, + isSpeaking: viewModel.isSpeaking && !viewModel.isMuted) + .frame(width: avatarSize, height: avatarSize) + Spacer().frame(height: 10) + ParticipantTitleView(displayName: $viewModel.displayName, + isMuted: $viewModel.isMuted, + titleFont: Fonts.button1.font, + mutedIconSize: 16) + } + } + +} + +struct ParticipantTitleView: View { + @Binding var displayName: String? + @Binding var isMuted: Bool + let titleFont: Font + let mutedIconSize: CGFloat + private let hSpace: CGFloat = 4 + + var body: some View { + HStack(alignment: .center, spacing: hSpace, content: { + if let displayName = displayName { + Text(displayName) + .font(titleFont) + .lineLimit(1) + .foregroundColor(Color(StyleProvider.color.onBackground)) + } + if isMuted { + Icon(name: .micOff, size: mutedIconSize) + } + }) + .animation(.default) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellViewModel.swift new file mode 100644 index 000000000..57756bea6 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/Cell/ParticipantGridCellViewModel.swift @@ -0,0 +1,53 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class ParticipantGridCellViewModel: ObservableObject, Identifiable { + let id = UUID() + + @Published var videoStreamId: String? + @Published var displayName: String? + @Published var isSpeaking: Bool + @Published var isMuted: Bool + var participantIdentifier: String + + init(compositeViewModelFactory: CompositeViewModelFactory, + participantModel: ParticipantInfoModel) { + self.displayName = participantModel.displayName + self.isSpeaking = participantModel.isSpeaking + self.participantIdentifier = participantModel.userIdentifier + self.isMuted = participantModel.isMuted + self.videoStreamId = getDisplayingVideoStreamId(participantModel) + } + + func update(participantModel: ParticipantInfoModel) { + self.participantIdentifier = participantModel.userIdentifier + let videoIdentifier = getDisplayingVideoStreamId(participantModel) + + if self.videoStreamId != videoIdentifier { + self.videoStreamId = videoIdentifier + } + + if self.displayName != participantModel.displayName { + self.displayName = participantModel.displayName + } + + if self.isSpeaking != participantModel.isSpeaking { + self.isSpeaking = participantModel.isSpeaking + } + + if self.isMuted != participantModel.isMuted { + self.isMuted = participantModel.isMuted + } + } + + private func getDisplayingVideoStreamId(_ participantModel: ParticipantInfoModel) -> String? { + let screenShareVideoStreamIdentifier = participantModel.screenShareVideoStreamModel?.videoStreamIdentifier + let cameraVideoStreamIdentifier = participantModel.cameraVideoStreamModel?.videoStreamIdentifier + return screenShareVideoStreamIdentifier ?? cameraVideoStreamIdentifier + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridLayoutView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridLayoutView.swift new file mode 100644 index 000000000..82735602c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridLayoutView.swift @@ -0,0 +1,65 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ParticipantGridLayoutView: View { + var cellViewModels: [ParticipantGridCellViewModel] + let getRemoteParticipantRendererView: (RemoteParticipantVideoViewId) -> UIView? + let screenSize: ScreenSizeClassType + let gridsMargin: CGFloat = 3 + + var body: some View { + Group { + switch screenSize { + case .iphonePortraitScreenSize: + vGridLayout + default: + hGridLayout + } + } + } + + func getChunkedCellViewModelArray() -> [[ParticipantGridCellViewModel]] { + let rowSize = cellViewModels.count == 2 ? 1 : 2 + return cellViewModels.chunkedAndReversed(into: rowSize) + } + + var hGridLayout: some View { + let chunkedArray = getChunkedCellViewModelArray() + return HStack(spacing: gridsMargin) { + ForEach(0.. some View { + return ForEach(cellsViewModel) { vm in + ParticipantGridCellView(viewModel: vm, + getRemoteParticipantRendererView: getRemoteParticipantRendererView) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(StyleProvider.color.surface)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + } + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridView.swift new file mode 100644 index 000000000..41e394b69 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridView.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ParticipantGridView: View { + let viewModel: ParticipantGridViewModel + let videoViewManager: VideoViewManager + let screenSize: ScreenSizeClassType + @State var gridsCount: Int = 0 + + var body: some View { + return Group { + ParticipantGridLayoutView(cellViewModels: viewModel.participantsCellViewModelArr, + getRemoteParticipantRendererView: getRemoteParticipantRendererView(videoViewId:), + screenSize: screenSize) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .id(gridsCount) + .onReceive(viewModel.$gridsCount) { + gridsCount = $0 + } + .onReceive(viewModel.$displayedParticipantInfoModelArr) { + updateVideoViewManager(displayedRemoteInfoModelArr: $0) + } + } + + func getRemoteParticipantRendererView(videoViewId: RemoteParticipantVideoViewId) -> UIView? { + return videoViewManager.getRemoteParticipantVideoRendererView(videoViewId) + } + + func updateVideoViewManager(displayedRemoteInfoModelArr: [ParticipantInfoModel]) { + let videoCacheIds: [RemoteParticipantVideoViewId] = displayedRemoteInfoModelArr.compactMap { + let screenShareVideoStreamIdentifier = $0.screenShareVideoStreamModel?.videoStreamIdentifier + let cameraVideoStreamIdentifier = $0.cameraVideoStreamModel?.videoStreamIdentifier + guard let videoStreamIdentifier = screenShareVideoStreamIdentifier ?? cameraVideoStreamIdentifier else { + return nil + } + return RemoteParticipantVideoViewId(userIdentifier: $0.userIdentifier, + videoStreamIdentifier: videoStreamIdentifier) + } + + videoViewManager.updateDisplayedRemoteVideoStream(videoCacheIds) + + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridViewModel.swift new file mode 100644 index 000000000..4f3bf7c1f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Calling/Grid/ParticipantGridViewModel.swift @@ -0,0 +1,136 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import Foundation +import Combine + +class ParticipantGridViewModel: ObservableObject { + private let maximumParticipantsDisplayed: Int = 6 + private var lastUpdateTimeStamp = Date() + private let compositeViewModelFactory: CompositeViewModelFactory + + @Published var gridsCount: Int = 0 + @Published var displayedParticipantInfoModelArr: [ParticipantInfoModel] = [] + + var participantsCellViewModelArr: [ParticipantGridCellViewModel] = [] + + init(compositeViewModelFactory: CompositeViewModelFactory) { + self.compositeViewModelFactory = compositeViewModelFactory + } + + func update(remoteParticipantsState: RemoteParticipantsState) { + guard lastUpdateTimeStamp != remoteParticipantsState.lastUpdateTimeStamp else { + return + } + lastUpdateTimeStamp = remoteParticipantsState.lastUpdateTimeStamp + + let remoteParticipants = remoteParticipantsState.participantInfoList + let newDisplayedInfoModelArr = getDisplayedInfoViewModels(remoteParticipants) + let orderedInfoModelArr = sortDisplayedInfoModels(newDisplayedInfoModelArr) + updateCellViewModel(for: orderedInfoModelArr) + + displayedParticipantInfoModelArr = orderedInfoModelArr + + if gridsCount != displayedParticipantInfoModelArr.count { + gridsCount = displayedParticipantInfoModelArr.count + } + } + + private func getDisplayedInfoViewModels(_ infoModels: [ParticipantInfoModel]) -> [ParticipantInfoModel] { + if let presentingParticipant = infoModels.first(where: { $0.screenShareVideoStreamModel != nil }) { + return [presentingParticipant] + } + + if infoModels.count <= maximumParticipantsDisplayed { + return infoModels + } + + let sortedInfoList = infoModels.sorted(by: { + $0.recentSpeakingStamp.compare($1.recentSpeakingStamp) == .orderedDescending + }) + let newDisplayRemoteParticipant = sortedInfoList.prefix(maximumParticipantsDisplayed) + // Need to filter if the user is on the lobby or not + return Array(newDisplayRemoteParticipant) + } + + private func sortDisplayedInfoModels(_ newInfoModels: [ParticipantInfoModel]) -> [ParticipantInfoModel] { + var localCacheInfoModelArr = displayedParticipantInfoModelArr + let infoModelToRemove = localCacheInfoModelArr.filter { old in + !newInfoModels.contains(where: { new in + new.userIdentifier == old.userIdentifier + }) + } + let infoModelToAdd = newInfoModels.filter { new in + !localCacheInfoModelArr.contains(where: { old in + new.userIdentifier == old.userIdentifier + }) + } + + guard infoModelToRemove.count == infoModelToAdd.count else { + // when there is a gridType change + // we just directly update the order based on the latest sorting + return newInfoModels + } + + var replacedIndex = [Int]() + // Otherwise, we keep those existed participant in same position when there is any update + for (index, item) in infoModelToRemove.enumerated() { + if let removeIndex = localCacheInfoModelArr.firstIndex(where: { + $0.userIdentifier == item.userIdentifier + }) { + localCacheInfoModelArr[removeIndex] = infoModelToAdd[index] + replacedIndex.append(removeIndex) + } + } + + // To update existed participantInfoModel + for (index, item) in localCacheInfoModelArr.enumerated() { + if !replacedIndex.contains(index), + let newItem = newInfoModels.first(where: {$0.userIdentifier == item.userIdentifier}) { + localCacheInfoModelArr[index] = newItem + } + } + + return localCacheInfoModelArr + } + + private func updateCellViewModel(for displayedRemoteParticipants: [ParticipantInfoModel]) { + if participantsCellViewModelArr.count == displayedRemoteParticipants.count { + updateOrderedCellViewModels(for: displayedRemoteParticipants) + } else { + updateAndReorderCellViewModels(for: displayedRemoteParticipants) + } + } + + private func updateOrderedCellViewModels(for displayedRemoteParticipants: [ParticipantInfoModel]) { + guard participantsCellViewModelArr.count == displayedRemoteParticipants.count else { + return + } + for (index, infoModel) in displayedRemoteParticipants.enumerated() { + let cellViewModel = participantsCellViewModelArr[index] + cellViewModel.update(participantModel: infoModel) + } + } + + private func updateAndReorderCellViewModels(for displayedRemoteParticipants: [ParticipantInfoModel]) { + var newCellViewModelArr = [ParticipantGridCellViewModel]() + for infoModel in displayedRemoteParticipants { + if let viewModel = participantsCellViewModelArr.first(where: { + $0.participantIdentifier == infoModel.userIdentifier + }) { + viewModel.update(participantModel: infoModel) + newCellViewModelArr.append(viewModel) + } else { + let cellViewModel = compositeViewModelFactory + .makeParticipantCellViewModel(participantModel: infoModel) + newCellViewModelArr.append(cellViewModel) + } + } + + participantsCellViewModelArr = newCellViewModelArr + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerUIHostingController.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerUIHostingController.swift new file mode 100644 index 000000000..f32656169 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerUIHostingController.swift @@ -0,0 +1,100 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI + +class ContainerUIHostingController: UIHostingController { + class EnvironmentProperty { + @Published var supportedOrientations: UIInterfaceOrientationMask + @Published var isProximitySensorOn: Bool + + init() { + self.supportedOrientations = UIDevice.current.userInterfaceIdiom == .pad ? .all : .allButUpsideDown + self.isProximitySensorOn = false + } + } + + private let callComposite: CallComposite + private let environmentProperties: EnvironmentProperty + private let cancelBag = CancelBag() + + init(rootView: ContainerView, + callComposite: CallComposite) { + let environmentProperties = EnvironmentProperty() + let environmentRoot = Root(containerView: rootView, + environmentProperties: environmentProperties) + self.callComposite = callComposite + self.environmentProperties = environmentProperties + super.init(rootView: environmentRoot) + subscribeEnvironmentProperties() + } + + @objc required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + UIApplication.shared.isIdleTimerDisabled = true + } + override func viewDidDisappear(_ animated: Bool) { + resetUIDeviceSetup() + super.viewDidDisappear(animated) + } + + open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + self.environmentProperties.supportedOrientations + } + + private func subscribeEnvironmentProperties() { + environmentProperties + .$supportedOrientations + .receive(on: RunLoop.main) + .dropFirst() + .sink(receiveValue: { orientation in + if orientation == .portrait || orientation == .landscape { + UIDevice.current.endGeneratingDeviceOrientationNotifications() + } else if (orientation == .all || orientation == .allButUpsideDown) + && !UIDevice.current.isGeneratingDeviceOrientationNotifications { + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + } + }).store(in: cancelBag) + + environmentProperties + .$isProximitySensorOn + .receive(on: RunLoop.main) + .sink(receiveValue: { isEnable in + UIDevice.current.toggleProximityMonitoringStatus(isEnabled: isEnable) + }).store(in: cancelBag) + } + + private func resetUIDeviceSetup() { + UIApplication.shared.isIdleTimerDisabled = false + UIDevice.current.toggleProximityMonitoringStatus(isEnabled: false) + + if (environmentProperties.supportedOrientations == .all + || environmentProperties.supportedOrientations == .allButUpsideDown) + && !UIDevice.current.isGeneratingDeviceOrientationNotifications { + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + } + } + + struct Root: View { + let containerView: ContainerView + let environmentProperties: EnvironmentProperty + + var body: some View { + containerView + .onPreferenceChange(SupportedOrientationsPreferenceKey.self) { + // Update the binding to set the value on the root controller. + self.environmentProperties.supportedOrientations = $0 + } + .onPreferenceChange(ProximitySensorPreferenceKey.self) { + self.environmentProperties.isProximitySensorOn = $0 + } + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerView.swift new file mode 100644 index 000000000..576de1577 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Container/ContainerView.swift @@ -0,0 +1,36 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ContainerView: View { + + @ObservedObject var router: NavigationRouter + + let logger: Logger + let viewFactory: CompositeViewFactory + let setupViewOrientationMask: UIInterfaceOrientationMask = .portrait + + var body: some View { + Group { + switch router.currentView { + case .setupView: + setupView.supportedOrientations(setupViewOrientationMask) + case .callingView: + callingView.proximitySensorEnabled(true) + } + } + } + + var setupView: SetupView { + logger.debug("Displaying view: setupView") + return viewFactory.makeSetupView() + } + + var callingView: CallingView { + logger.debug("Displaying view: callingView") + return viewFactory.makeCallingView() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupView.swift new file mode 100644 index 000000000..074033789 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupView.swift @@ -0,0 +1,82 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import Combine +import FluentUI + +struct SetupView: View { + @ObservedObject var viewModel: SetupViewModel + let viewManager: VideoViewManager + + let layoutSpacing: CGFloat = 24 + let horizontalPadding: CGFloat = 16 + let startCallButtonHeight: CGFloat = 52 + let errorHorizontalPadding: CGFloat = 8 + + var body: some View { + ZStack { + VStack(spacing: layoutSpacing) { + SetupTitleView(iconButtonViewModel: viewModel.dismissButtonViewModel) + VStack(spacing: layoutSpacing) { + ZStack(alignment: .bottom) { + PreviewAreaView(viewModel: viewModel.previewAreaViewModel, + viewManager: viewManager) + SetupControlBarView(viewModel: viewModel.setupControlBarViewModel) + } + .background(Color(StyleProvider.color.surface)) + .cornerRadius(4) + startCallButton + .padding(.bottom) + } + .padding(.horizontal, horizontalPadding) + } + errorInfoView + } + .onAppear { + viewModel.setupAudioPermissions() + viewModel.setupCall() + } + } + + var startCallButton: some View { + PrimaryButton(viewModel: viewModel.startCallButtonViewModel) + } + + var errorInfoView: some View { + VStack { + Spacer() + ErrorInfoView(viewModel: viewModel.errorInfoViewModel) + .padding(EdgeInsets(top: 0, + leading: errorHorizontalPadding, + bottom: startCallButtonHeight + layoutSpacing, + trailing: errorHorizontalPadding) + ) + } + } +} + +struct SetupTitleView: View { + let viewHeight: CGFloat = 44 + let verticalSpacing: CGFloat = 0 + var title: String = "" + var iconButtonViewModel: IconButtonViewModel + + var body: some View { + VStack(spacing: verticalSpacing) { + ZStack(alignment: .leading) { + IconButton(viewModel: iconButtonViewModel) + HStack { + Spacer() + Text(title) + .font(Fonts.headline.font) + .foregroundColor(Color(StyleProvider.color.onBackground)) + Spacer() + } + }.frame(height: viewHeight) + Divider() + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaView.swift new file mode 100644 index 000000000..34a82f4fb --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaView.swift @@ -0,0 +1,71 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct PreviewAreaView: View { + @ObservedObject var viewModel: PreviewAreaViewModel + let viewManager: VideoViewManager + + var body: some View { + Group { + if viewModel.showPermissionWarning() { + PermissionWarningView(displayIcon: viewModel.getPermissionWarningIcon(), + displayText: viewModel.getPermissionWarningText()) + } else { + localVideoPreviewView + } + } + } + + var localVideoPreviewView: some View { + return LocalVideoView(viewModel: viewModel.localVideoViewModel, + viewManager: viewManager, + viewType: .preview) + } +} + +struct PermissionWarningView: View { + let displayIcon: CompositeIcon + let displayText: String + + let verticalSpacing: CGFloat = 20 + let iconSize: CGFloat = 50 + let width: CGFloat = 220 + + var body: some View { + GeometryReader { geometry in + VStack(spacing: verticalSpacing) { + Icon(name: displayIcon, size: iconSize) + .foregroundColor(Color(StyleProvider.color.onSurface)) + Text(displayText) + .frame(width: width) + .font(Fonts.subhead.font) + .multilineTextAlignment(.center) + .foregroundColor(Color(StyleProvider.color.onSurface)) + }.frame(width: geometry.size.width, + height: geometry.size.height) + } + } +} + +struct GradientView: View { + var body: some View { + let height: CGFloat = 160 + + VStack { + Spacer() + Rectangle() + .fill( + LinearGradient(gradient: Gradient(stops: [ + Gradient.Stop(color: .black.opacity(0), location: 0.3914), + Gradient.Stop(color: Color(StyleProvider.color.gradientColor), location: 0.9965) + ]), startPoint: .top, endPoint: .bottom) + ) + .frame(maxHeight: height) + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaViewModel.swift new file mode 100644 index 000000000..211012f46 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/PreviewAreaViewModel.swift @@ -0,0 +1,67 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class PreviewAreaViewModel: ObservableObject { + @Published var cameraStatus: LocalUserState.CameraOperationalStatus = .off + @Published var cameraPermission: AppPermission.Status = .unknown + @Published var audioPermission: AppPermission.Status = .unknown + + let localVideoViewModel: LocalVideoViewModel! + + init(compositeViewModelFactory: CompositeViewModelFactory, + dispatchAction: @escaping ActionDispatch) { + localVideoViewModel = compositeViewModelFactory.makeLocalVideoViewModel(dispatchAction: dispatchAction) + } + + func getPermissionWarningIcon() -> CompositeIcon { + let displayIcon: CompositeIcon + + if self.audioPermission == .granted { + displayIcon = .videoOff + } else if self.cameraPermission == .denied { + displayIcon = .warning + } else { + displayIcon = .micOff + } + + return displayIcon + } + + func getPermissionWarningText() -> String { + let displayText: String + let goToSettingsText = "To enable, please go to Settings to allow access." + let enableAudioToStartText = "You must enable audio to start this call." + + if self.audioPermission == .granted { + displayText = "Your camera is disabled. \(goToSettingsText)" + } else if self.cameraPermission == .denied { + displayText = "Your camera and audio are disabled. \(goToSettingsText) \(enableAudioToStartText)" + } else { + displayText = "Your audio is disabled. \(goToSettingsText) \(enableAudioToStartText)" + } + + return displayText + } + + func showPermissionWarning() -> Bool { + self.cameraPermission == .denied || self.audioPermission == .denied + } + + func update(localUserState: LocalUserState, permissionState: PermissionState) { + if self.cameraStatus != localUserState.cameraState.operation { + self.cameraStatus = localUserState.cameraState.operation + } + if self.cameraPermission != permissionState.cameraPermission { + self.cameraPermission = permissionState.cameraPermission + } + if self.audioPermission != permissionState.audioPermission { + self.audioPermission = permissionState.audioPermission + } + localVideoViewModel.update(localUserState: localUserState) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarView.swift new file mode 100644 index 000000000..8a42a47b4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarView.swift @@ -0,0 +1,75 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct SetupControlBarView: View { + @ObservedObject var viewModel: SetupControlBarViewModel + + let audioDeviceButtonSourceView = UIView() + let layoutSpacing: CGFloat = 0 + let controlWidth: CGFloat = 315 + let controlHeight: CGFloat = 50 + let horizontalPadding: CGFloat = 16 + let verticalPadding: CGFloat = 13 + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .center) { + Spacer() + HStack(alignment: .center, spacing: layoutSpacing) { + Spacer() + cameraButton + Spacer() + micButton + Spacer() + audioDeviceButton + Spacer() + } + .frame(width: getWidth(from: geometry), + height: controlHeight) + .padding(.horizontal, getHorizontalPadding(from: geometry)) + .padding(.vertical, verticalPadding) + .hidden(viewModel.isAudioDisabled()) + } + } + .modifier(PopupModalView(isPresented: viewModel.isAudioDeviceSelectionDisplayed) { + audioDeviceSelectionListView + }) + } + + var cameraButton: some View { + IconWithLabelButton(viewModel: viewModel.cameraButtonViewModel) + } + + var micButton: some View { + IconWithLabelButton(viewModel: viewModel.micButtonViewModel) + } + + var audioDeviceButton: some View { + IconWithLabelButton(viewModel: viewModel.audioDeviceButtonViewModel) + .background(SourceViewSpace(sourceView: audioDeviceButtonSourceView)) + } + + var audioDeviceSelectionListView: some View { + CompositePopupMenu(isPresented: $viewModel.isAudioDeviceSelectionDisplayed, + viewModel: viewModel.audioDeviceListViewModel, + sourceView: audioDeviceButtonSourceView) + } + + private func getWidth(from geometry: GeometryProxy) -> CGFloat { + if controlWidth > geometry.size.width { + return geometry.size.width + } + return controlWidth + } + + private func getHorizontalPadding(from geometry: GeometryProxy) -> CGFloat { + if controlWidth > geometry.size.width { + return 0 + } + return (geometry.size.width - controlWidth) / 2 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarViewModel.swift new file mode 100644 index 000000000..59b5a2f4a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewComponent/SetupControlBarViewModel.swift @@ -0,0 +1,147 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class SetupControlBarViewModel: ObservableObject { + @Published var cameraPermission: AppPermission.Status = .unknown + @Published var audioPermission: AppPermission.Status = .unknown + @Published var isAudioDeviceSelectionDisplayed: Bool = false + private let logger: Logger + let audioDeviceListViewModel: AudioDeviceListViewModel + var cameraButtonViewModel: IconWithLabelButtonViewModel! + var micButtonViewModel: IconWithLabelButtonViewModel! + var audioDeviceButtonViewModel: IconWithLabelButtonViewModel! + + var cameraStatus: LocalUserState.CameraOperationalStatus = .off + var micStatus: LocalUserState.AudioOperationalStatus = .off + var localVideoStreamId: String? + + private let dispatch: ActionDispatch + + init(compositeViewModelFactory: CompositeViewModelFactory, + logger: Logger, + dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) { + self.logger = logger + self.dispatch = dispatchAction + + self.audioDeviceListViewModel = compositeViewModelFactory.makeAudioDeviceListViewModel( + dispatchAction: dispatchAction, + localUserState: localUserState) + self.cameraButtonViewModel = compositeViewModelFactory.makeIconWithLabelButtonViewModel( + iconName: .videoOff, + buttonTypeColor: .colorThemedWhite, + buttonLabel: "Video is off", + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Toggle camera button tapped") + self.videoButtonTapped() + } + self.micButtonViewModel = compositeViewModelFactory.makeIconWithLabelButtonViewModel( + iconName: .micOff, + buttonTypeColor: .colorThemedWhite, + buttonLabel: "Mic is off", + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Toggle microphone button tapped") + self.microphoneButtonTapped() + } + self.audioDeviceButtonViewModel = compositeViewModelFactory.makeIconWithLabelButtonViewModel( + iconName: .speakerFilled, + buttonTypeColor: .colorThemedWhite, + buttonLabel: "Device", + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.logger.debug("Select audio device button tapped") + self.selectAudioDeviceButtonTapped() + } + } + + func videoButtonTapped() { + let action: Action = self.cameraStatus == .on ? + LocalUserAction.CameraOffTriggered() : LocalUserAction.CameraPreviewOnTriggered() + dispatch(action) + } + + func microphoneButtonTapped() { + let action: Action = self.micStatus == .on ? + LocalUserAction.MicrophonePreviewOff() : LocalUserAction.MicrophonePreviewOn() + dispatch(action) + } + + func selectAudioDeviceButtonTapped() { + isAudioDeviceSelectionDisplayed = true + } + + func isCameraDisabled() -> Bool { + self.cameraPermission == .denied + } + + func isAudioDisabled() -> Bool { + self.audioPermission == .denied + } + + func update(localUserState: LocalUserState, permissionState: PermissionState) { + if self.cameraPermission != permissionState.cameraPermission { + self.cameraPermission = permissionState.cameraPermission + } + if self.audioPermission != permissionState.audioPermission { + self.audioPermission = permissionState.audioPermission + } + + self.cameraStatus = localUserState.cameraState.operation + self.micStatus = localUserState.audioState.operation + self.cameraButtonViewModel.update( + iconName: self.cameraStatus == .on ? .videoOn : .videoOff, + buttonLabel: "Video is \(self.cameraStatus == .on ? "on" : "off")") + self.cameraButtonViewModel.update(isDisabled: isCameraDisabled()) + self.micButtonViewModel.update( + iconName: self.micStatus == .on ? .micOn : .micOff, + buttonLabel: "Mic is \(self.micStatus == .on ? "on" : "off")") + self.audioDeviceButtonViewModel.update( + iconName: deviceIconFor(audioDeviceStatus: localUserState.audioState.device), + buttonLabel: deviceLabelFor(audioDeviceStatus: localUserState.audioState.device)) + + if self.localVideoStreamId != localUserState.localVideoStreamIdentifier { + self.localVideoStreamId = localUserState.localVideoStreamIdentifier + let buttonTypeColor: IconWithLabelButtonViewModel.ButtonTypeColor + = localVideoStreamId == nil ? .colorThemedWhite : .white + cameraButtonViewModel.update(buttonTypeColor: buttonTypeColor) + micButtonViewModel.update(buttonTypeColor: buttonTypeColor) + audioDeviceButtonViewModel.update(buttonTypeColor: buttonTypeColor) + } + audioDeviceListViewModel.update(audioDeviceStatus: localUserState.audioState.device) + } + + private func deviceIconFor(audioDeviceStatus: LocalUserState.AudioDeviceSelectionStatus) -> CompositeIcon { + switch audioDeviceStatus { + case .receiverSelected: + return .speakerRegular + case .speakerSelected: + return .speakerFilled + default: + return self.audioDeviceButtonViewModel.iconName + } + } + + private func deviceLabelFor(audioDeviceStatus: LocalUserState.AudioDeviceSelectionStatus) -> String { + switch audioDeviceStatus { + case .receiverSelected: + return AudioDeviceType.receiver.name + case .speakerSelected: + return AudioDeviceType.speaker.name + default: + return self.audioDeviceButtonViewModel.buttonLabel + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewModel.swift new file mode 100644 index 000000000..379f9cd85 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Setup/SetupViewModel.swift @@ -0,0 +1,80 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class SetupViewModel: ObservableObject { + private let logger: Logger + private let store: Store + var cancellables = Set() + + let previewAreaViewModel: PreviewAreaViewModel + let dismissButtonViewModel: IconButtonViewModel + var errorInfoViewModel: ErrorInfoViewModel + var startCallButtonViewModel: PrimaryButtonViewModel! + var setupControlBarViewModel: SetupControlBarViewModel! + + init(compositeViewModelFactory: CompositeViewModelFactory, + logger: Logger, + store: Store) { + self.store = store + self.logger = logger + self.previewAreaViewModel = compositeViewModelFactory.makePreviewAreaViewModel(dispatchAction: store.dispatch) + self.dismissButtonViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .leftArrow, + buttonType: .controlButton, + isDisabled: false) { + store.dispatch(action: CallingAction.DismissSetup()) + } + self.errorInfoViewModel = compositeViewModelFactory.makeErrorInfoViewModel() + self.startCallButtonViewModel = compositeViewModelFactory.makePrimaryButtonViewModel( + buttonStyle: .primaryFilled, + buttonLabel: "Join Call", + iconName: .meetNow, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.startCallButtonTapped() + } + self.setupControlBarViewModel = compositeViewModelFactory + .makeSetupControlBarViewModel(dispatchAction: store.dispatch, + localUserState: store.state.localUserState) + + store.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.receive(state) + }.store(in: &cancellables) + } + + func setupAudioPermissions() { + if store.state.permissionState.audioPermission == .notAsked { + store.dispatch(action: PermissionAction.AudioPermissionRequested()) + } + } + + func setupCall() { + store.dispatch(action: CallingAction.SetupCall()) + } + + func startCallButtonTapped() { + store.dispatch(action: CallingViewLaunched()) + } + + func receive(_ state: AppState) { + let localUserState = state.localUserState + let permissionState = state.permissionState + + previewAreaViewModel.update(localUserState: localUserState, + permissionState: permissionState) + setupControlBarViewModel.update(localUserState: localUserState, + permissionState: permissionState) + startCallButtonViewModel.update(isDisabled: permissionState.audioPermission == .denied) + + errorInfoViewModel.update(errorState: state.errorState) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/EnvironmentValuesExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/EnvironmentValuesExtension.swift new file mode 100644 index 000000000..cb29aa674 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/EnvironmentValuesExtension.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +enum ScreenSizeClassType { + case iphonePortraitScreenSize + // iPhone portrait screen, iPad split view (compact width, regular height) + + case iphoneLandscapeScreenSize + // iPhone landscape screen (compact width, compact height), (regular width, compact height) + + case ipadScreenSize + // iPad screen (regular width, regular height) +} + +struct ScreenSizeClassKey: EnvironmentKey { + static let defaultValue: ScreenSizeClassType = .iphonePortraitScreenSize +} + +extension EnvironmentValues { + var screenSizeClass: ScreenSizeClassType { + get { self[ScreenSizeClassKey.self] } + set { self[ScreenSizeClassKey.self] = newValue } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/PreferenceKey.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/PreferenceKey.swift new file mode 100644 index 000000000..f5c73f9fd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/PreferenceKey.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct SupportedOrientationsPreferenceKey: PreferenceKey { + static var defaultValue: UIInterfaceOrientationMask { + UIDevice.current.userInterfaceIdiom == .pad ? .all : .allButUpsideDown + } + + static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { + // Use the most restrictive set from the stack + value.formIntersection(nextValue()) + } +} + +struct ProximitySensorPreferenceKey: PreferenceKey { + static var defaultValue: Bool { + return false + } + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/UIFontExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/UIFontExtension.swift new file mode 100644 index 000000000..fdbd7de70 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/UIFontExtension.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import UIKit + +extension UIFont { + var font: Font { + return Font(self as CTFont) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/ViewExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/ViewExtension.swift new file mode 100644 index 000000000..e4ed73072 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/Utilities/ViewExtension.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +extension View { + @ViewBuilder func hidden(_ shouldHide: Bool) -> some View { + switch shouldHide { + case true: + self.hidden() + case false: + self + } + } + + func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) + } + + func proximitySensorEnabled(_ proximityEnabled: Bool) -> some View { + preference(key: ProximitySensorPreferenceKey.self, value: proximityEnabled) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButton.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButton.swift new file mode 100644 index 000000000..2cdd28b39 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButton.swift @@ -0,0 +1,146 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct IconButton: View { + @ObservedObject var viewModel: IconButtonViewModel + + private let buttonDisabledColor = Color(StyleProvider.color.disableColor) + private var iconImageSize: CGFloat { + switch viewModel.buttonType { + case .dismissButton: + return 16 + default: + return 24 + } + } + var width: CGFloat { + switch viewModel.buttonType { + case .controlButton, + .roundedRectButton: + return 60 + case .infoButton: + return iconImageSize + case .dismissButton: + return 16 + case .cameraSwitchButtonFull, + .cameraSwitchButtonPip: + return 36 + } + } + var height: CGFloat { + switch viewModel.buttonType { + case .controlButton, + .roundedRectButton: + return 60 + case .infoButton: + return iconImageSize + case .dismissButton: + return 16 + case .cameraSwitchButtonFull, + .cameraSwitchButtonPip: + return 36 + } + } + var buttonBackgroundColor: Color { + switch viewModel.buttonType { + case .roundedRectButton: + return Color(StyleProvider.color.hangup) + case .controlButton, + .infoButton, + .dismissButton: + return .clear + case .cameraSwitchButtonFull, + .cameraSwitchButtonPip: + return Color(StyleProvider.color.surfaceLightColor) + } + } + var buttonForegroundColor: Color { + switch viewModel.buttonType { + case .controlButton: + return Color(StyleProvider.color.onSurfaceColor) + case .dismissButton: + return Color(StyleProvider.color.onBackground) + default: + return .white + } + } + + var tappableWidth: CGFloat { + switch viewModel.buttonType { + case .cameraSwitchButtonPip, + .cameraSwitchButtonFull, + .infoButton: + return 44 + default: + return width + } + } + + var tappableHeight: CGFloat { + switch viewModel.buttonType { + case .cameraSwitchButtonPip, + .cameraSwitchButtonFull, + .infoButton: + return 44 + default: + return height + } + } + + var shapeCornerRadius: CGFloat { + switch viewModel.buttonType { + case .cameraSwitchButtonPip, + .cameraSwitchButtonFull: + return 4 + default: + return 8 + } + } + + var roundedCorners: UIRectCorner { + switch viewModel.buttonType { + case .cameraSwitchButtonPip: + return [.bottomLeft] + default: + return [.allCorners] + } + } + + var body: some View { + Group { + Button(action: viewModel.action) { + Icon(name: viewModel.iconName, size: iconImageSize) + .contentShape(Rectangle()) + } + .disabled(viewModel.isDisabled) + .foregroundColor(viewModel.isDisabled ? buttonDisabledColor : buttonForegroundColor) + .frame(width: width, height: height, alignment: .center) + .background(buttonBackgroundColor) + .clipShape(RoundedCornersShape(radius: shapeCornerRadius, corners: roundedCorners)) + } + .frame(width: tappableWidth, + height: tappableHeight, + alignment: .center) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.action() + } + .disabled(viewModel.isDisabled) + } +} + +struct RoundedCornersShape: Shape { + let radius: CGFloat + let corners: UIRectCorner + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButtonViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButtonViewModel.swift new file mode 100644 index 000000000..5152a04d8 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconButtonViewModel.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class IconButtonViewModel: ObservableObject { + enum ButtonType { + case controlButton + case roundedRectButton + case infoButton + case dismissButton + case cameraSwitchButtonPip + case cameraSwitchButtonFull + } + + @Published var iconName: CompositeIcon + @Published var isDisabled: Bool + let buttonType: ButtonType + var action: (() -> Void) + + init(iconName: CompositeIcon, + buttonType: ButtonType = .controlButton, + isDisabled: Bool = false, + action: @escaping (() -> Void) = {}) { + self.iconName = iconName + self.buttonType = buttonType + self.isDisabled = isDisabled + self.action = action + } + + func update(iconName: CompositeIcon) { + if self.iconName != iconName { + self.iconName = iconName + } + } + + func update(isDisabled: Bool) { + if self.isDisabled != isDisabled { + self.isDisabled = isDisabled + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButton.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButton.swift new file mode 100644 index 000000000..3b254f134 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButton.swift @@ -0,0 +1,42 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct IconWithLabelButton: View { + @ObservedObject var viewModel: IconWithLabelButtonViewModel + + private let iconImageSize: CGFloat = 25 + private let verticalSpacing: CGFloat = 8 + private let width: CGFloat = 75 + private let height: CGFloat = 65 + private let buttonDisabledColor = Color(StyleProvider.color.onDisabled) + + var buttonForegroundColor: Color { + switch viewModel.buttonTypeColor { + case .colorThemedWhite: + return Color(StyleProvider.color.onSurfaceColor) + case .white: + return Color(.white) + } + } + + var body: some View { + Button(action: viewModel.action) { + VStack(alignment: .center, spacing: verticalSpacing) { + Icon(name: viewModel.iconName, size: iconImageSize) + if let buttonLabel = viewModel.buttonLabel { + Text(buttonLabel) + .font(Fonts.button2.font) + } + } + } + .animation(nil) + .disabled(viewModel.isDisabled) + .foregroundColor(viewModel.isDisabled ? buttonDisabledColor : buttonForegroundColor) + .frame(width: width, height: height, alignment: .center) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButtonViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButtonViewModel.swift new file mode 100644 index 000000000..b6d9011dd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/IconWithLabelButtonViewModel.swift @@ -0,0 +1,53 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class IconWithLabelButtonViewModel: ObservableObject { + enum ButtonTypeColor { + case colorThemedWhite + case white + } + + @Published var iconName: CompositeIcon + @Published var buttonTypeColor: ButtonTypeColor + @Published var buttonLabel: String + @Published var isDisabled: Bool + var action: (() -> Void) + + init(iconName: CompositeIcon, + buttonTypeColor: ButtonTypeColor, + buttonLabel: String, + isDisabled: Bool = false, + action: @escaping (() -> Void) = {}) { + self.iconName = iconName + self.buttonTypeColor = buttonTypeColor + self.buttonLabel = buttonLabel + self.isDisabled = isDisabled + self.action = action + } + + func update(iconName: CompositeIcon, buttonLabel: String) { + if self.iconName != iconName { + self.iconName = iconName + } + if self.buttonLabel != buttonLabel { + self.buttonLabel = buttonLabel + } + } + + func update(isDisabled: Bool) { + if self.isDisabled != isDisabled { + self.isDisabled = isDisabled + } + } + + func update(buttonTypeColor: ButtonTypeColor) { + if self.buttonTypeColor != buttonTypeColor { + self.buttonTypeColor = buttonTypeColor + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButton.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButton.swift new file mode 100644 index 000000000..2085fca12 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButton.swift @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct PrimaryButton: View { + @ObservedObject var viewModel: PrimaryButtonViewModel + + private let height: CGFloat = 52 + + var body: some View { + CompositeButton(buttonStyle: viewModel.buttonStyle, + buttonLabel: viewModel.buttonLabel, + iconName: viewModel.iconName) + .onTapGesture(perform: viewModel.action) + .frame(height: height) + .disabled(viewModel.isDisabled) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButtonViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButtonViewModel.swift new file mode 100644 index 000000000..50b7da962 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Button/PrimaryButtonViewModel.swift @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import FluentUI +import Combine + +class PrimaryButtonViewModel: ObservableObject { + @Published var isDisabled: Bool + let buttonStyle: FluentUI.ButtonStyle + let buttonLabel: String + let iconName: CompositeIcon? + var action: (() -> Void) + + init(buttonStyle: FluentUI.ButtonStyle, + buttonLabel: String, + iconName: CompositeIcon? = nil, + isDisabled: Bool = false, + action: @escaping (() -> Void) = {}) { + self.buttonStyle = buttonStyle + self.buttonLabel = buttonLabel + self.iconName = iconName + self.isDisabled = isDisabled + self.action = action + } + + func update(isDisabled: Bool) { + if self.isDisabled != isDisabled { + self.isDisabled = isDisabled + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/AudioDeviceListViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/AudioDeviceListViewModel.swift new file mode 100644 index 000000000..733b6e508 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/AudioDeviceListViewModel.swift @@ -0,0 +1,67 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class AudioDeviceListViewModel: ObservableObject { + @Published var audioDeviceStatus: LocalUserState.AudioDeviceSelectionStatus + var audioDeviceList: [PopupMenuViewModel] = [] + + private let dispatch: ActionDispatch + + init(dispatchAction: @escaping ActionDispatch, localUserState: LocalUserState) { + self.dispatch = dispatchAction + self.audioDeviceStatus = localUserState.audioState.device + } + + func update(audioDeviceStatus: LocalUserState.AudioDeviceSelectionStatus) { + if audioDeviceStatus != self.audioDeviceStatus || audioDeviceList.isEmpty, + [.receiverSelected, .speakerSelected].contains(audioDeviceStatus) { + self.audioDeviceStatus = audioDeviceStatus + audioDeviceList = getAvailableAudioDevices(audioDeviceStatus: audioDeviceStatus) + } + } + + private func getAvailableAudioDevices(audioDeviceStatus: LocalUserState.AudioDeviceSelectionStatus) + -> [PopupMenuViewModel] { + var audioDeviceOptions = [PopupMenuViewModel]() + + for audioDeviceType in AudioDeviceType.allCases { + let action = LocalUserAction.AudioDeviceChangeRequested(device: audioDeviceType) + let audioDeviceOption = PopupMenuViewModel( + icon: getAudioDeviceIcon(audioDeviceType), + title: audioDeviceType.name, + isSelected: isAudioDeviceSelected(audioDeviceType, selectedDevice: audioDeviceStatus), + onSelected: { [weak self] in self?.dispatch(action) }) + audioDeviceOptions.append(audioDeviceOption) + } + + return audioDeviceOptions + } + + private func getAudioDeviceIcon(_ audioDeviceType: AudioDeviceType) -> CompositeIcon { + let icon: CompositeIcon + switch audioDeviceType { + case .receiver: + icon = .speakerRegular + case .speaker: + icon = .speakerFilled + } + return icon + } + + private func isAudioDeviceSelected(_ audioDeviceType: AudioDeviceType, + selectedDevice: LocalUserState.AudioDeviceSelectionStatus) -> Bool { + let isSelected: Bool + switch selectedDevice { + case .receiverSelected where audioDeviceType == .receiver, + .speakerSelected where audioDeviceType == .speaker: + isSelected = true + default: + isSelected = false + } + return isSelected + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/PopupMenuViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/PopupMenuViewModel.swift new file mode 100644 index 000000000..fd2ae3fc4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/PopupMenuViewModel.swift @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class PopupMenuViewModel { + let icon: CompositeIcon + let title: String + let isSelected: Bool + let onSelected: (() -> Void) + + init(icon: CompositeIcon, + title: String, + isSelected: Bool, + onSelected: @escaping (() -> Void)) { + self.icon = icon + self.title = title + self.isSelected = isSelected + self.onSelected = onSelected + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/SourceViewSpace.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/SourceViewSpace.swift new file mode 100644 index 000000000..783a38331 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Drawer/SourceViewSpace.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct SourceViewSpace: View { + let sourceView: UIView + + var body: some View { + GeometryReader { geometry -> Color in + sourceView.translatesAutoresizingMaskIntoConstraints = false + sourceView.frame = geometry.frame(in: CoordinateSpace.global) + sourceView.bounds = geometry.frame(in: CoordinateSpace.local) + return .clear + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoView.swift new file mode 100644 index 000000000..d80c9daaa --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoView.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +struct ErrorInfoView: View { + @ObservedObject var viewModel: ErrorInfoViewModel + + private let cornerRaidus: CGFloat = 4 + + var body: some View { + if viewModel.isDisplayed { + HStack { + Text(viewModel.message) + .padding([.top, .leading, .bottom]) + .font(Fonts.footnote.font) + .foregroundColor(Color(StyleProvider.color.onWarning)) + Spacer() + Button(action: dismissAction) { + Text("Dismiss") + .font(Fonts.button1.font) + .foregroundColor(Color(StyleProvider.color.onWarning)) + } + .padding([.top, .bottom, .trailing]) + } + .background(Color(StyleProvider.color.warning)) + .cornerRadius(cornerRaidus) + } + } + + func dismissAction() { + viewModel.isDisplayed = false + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoViewModel.swift new file mode 100644 index 000000000..7c8277bc9 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/Error/ErrorInfoViewModel.swift @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class ErrorInfoViewModel: ObservableObject { + @Published var isDisplayed: Bool = false + @Published var message: String = "" + + private var previousErrorType: String = "" + + func update(errorState: ErrorState) { + guard errorState.errorCode != "", + errorState.errorCode != previousErrorType else { + return + } + + isDisplayed = true + previousErrorType = errorState.errorCode + switch errorState.errorCode { + case CallCompositeErrorCode.callJoin: + message = "Unable to join the call due to an error." + case CallCompositeErrorCode.callEnd: + message = "You were removed from the call due to an error." + default: + message = "There was an error." + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/LockPhoneOrientation.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/LockPhoneOrientation.swift new file mode 100644 index 000000000..22a65cc1b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/LockPhoneOrientation.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +struct LockPhoneOrientation: ViewModifier { + @Environment(\.screenSizeClass) var screenSizeClass: ScreenSizeClassType + + func body(content: Content) -> some View { + content + .supportedOrientations(orientationMask) + } + + var orientationMask: UIInterfaceOrientationMask { + switch screenSizeClass { + case .iphonePortraitScreenSize: + return .portrait + case .iphoneLandscapeScreenSize: + return .landscape + default: + return SupportedOrientationsPreferenceKey.defaultValue + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/PopupModalView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/PopupModalView.swift new file mode 100644 index 000000000..f10506e3f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/PopupModalView.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +import SwiftUI + +struct PopupModalView: ViewModifier { + let popup: T + let isPresented: Bool + let alignment: Alignment + + init(isPresented: Bool, alignment: Alignment = .center, @ViewBuilder content: () -> T) { + self.isPresented = isPresented + self.alignment = alignment + popup = content() + } + + func body(content: Content) -> some View { + content + .overlay(popupContent()) + } + + @ViewBuilder private func popupContent() -> some View { + GeometryReader { geometry in + if isPresented { + popup + .frame(width: geometry.size.width, + height: geometry.size.height, + alignment: alignment) + } + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoView.swift new file mode 100644 index 000000000..691aa696a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoView.swift @@ -0,0 +1,119 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import FluentUI + +enum LocalVideoViewType { + case preview + case localVideoPip + case localVideofull + + var cameraSwitchButtonAlignment: Alignment { + switch self { + case .localVideoPip: + return .topTrailing + case .localVideofull: + return .bottomTrailing + case .preview: + return .trailing + } + } + + var avatarSize: MSFAvatarSize { + switch self { + case .localVideofull, + .preview: + return .xxlarge + case .localVideoPip: + return .large + } + } + + var showDisplayNameTitleView: Bool { + switch self { + case .localVideoPip, + .preview: + return false + case .localVideofull: + return true + } + } + + var hasGradient: Bool { + switch self { + case .localVideoPip, + .localVideofull: + return false + case .preview: + return true + } + } + +} + +struct LocalVideoView: View { + @ObservedObject var viewModel: LocalVideoViewModel + let viewManager: VideoViewManager + let viewType: LocalVideoViewType + @Environment(\.screenSizeClass) var screenSizeClass: ScreenSizeClassType + + var body: some View { + Group { + GeometryReader { geometry in + if let localVideoStreamId = viewModel.localVideoStreamId, + let rendererView = viewManager.getLocalVideoRendererView(localVideoStreamId) { + ZStack(alignment: viewType.cameraSwitchButtonAlignment) { + VideoRendererView(rendererView: rendererView) + .scaledToFill() + .frame(width: geometry.size.width, + height: geometry.size.height) + if viewType.hasGradient { + GradientView() + } + cameraSwitchButton + } + } else { + VStack(alignment: .center, spacing: 5) { + CompositeAvatar(displayName: $viewModel.displayName, + isSpeaking: false, + avatarSize: viewType.avatarSize) + if viewType.showDisplayNameTitleView { + Spacer().frame(height: 10) + ParticipantTitleView(displayName: $viewModel.displayName, + isMuted: $viewModel.isMuted, + titleFont: Fonts.caption1.font, + mutedIconSize: 16) + } else if screenSizeClass == .iphonePortraitScreenSize { + Spacer() + .frame(height: 20) + } + } + .frame(width: geometry.size.width, + height: geometry.size.height) + } + } + }.onReceive(viewModel.$localVideoStreamId) { + viewManager.updateDisplayedLocalVideoStream($0) + } + } + + var cameraSwitchButton: some View { + let cameraSwitchButtonPaddingPip: CGFloat = -4 + let cameraSwitchButtonPaddingFull: CGFloat = 4 + return Group { + switch viewType { + case .localVideoPip: + IconButton(viewModel: viewModel.cameraSwitchButtonPipViewModel) + .padding(cameraSwitchButtonPaddingPip) + case .localVideofull: + IconButton(viewModel: viewModel.cameraSwitchButtonFullViewModel) + .padding(cameraSwitchButtonPaddingFull) + default: + EmptyView() + } + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoViewModel.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoViewModel.swift new file mode 100644 index 000000000..6330385bc --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/LocalVideoViewModel.swift @@ -0,0 +1,65 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +class LocalVideoViewModel: ObservableObject { + private let logger: Logger + @Published var localVideoStreamId: String? + @Published var displayName: String? + @Published var isMuted: Bool = false + + var cameraSwitchButtonPipViewModel: IconButtonViewModel! + var cameraSwitchButtonFullViewModel: IconButtonViewModel! + + private let dispatch: ActionDispatch + + init(compositeViewModelFactory: CompositeViewModelFactory, + logger: Logger, + dispatchAction: @escaping ActionDispatch) { + self.dispatch = dispatchAction + self.logger = logger + self.cameraSwitchButtonPipViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .cameraSwitch, + buttonType: .cameraSwitchButtonPip, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.toggleCameraSwitchTapped() + } + self.cameraSwitchButtonFullViewModel = compositeViewModelFactory.makeIconButtonViewModel( + iconName: .cameraSwitch, + buttonType: .cameraSwitchButtonFull, + isDisabled: false) { [weak self] in + guard let self = self else { + return + } + self.toggleCameraSwitchTapped() + } + } + + func toggleCameraSwitchTapped() { + let action = LocalUserAction.CameraSwitchTriggered() + dispatch(action) + } + + func update(localUserState: LocalUserState) { + if localVideoStreamId != localUserState.localVideoStreamIdentifier { + localVideoStreamId = localUserState.localVideoStreamIdentifier + } + if displayName != localUserState.displayName { + displayName = localUserState.displayName + } + self.cameraSwitchButtonPipViewModel.isDisabled = localUserState.cameraState.device == .switching + self.cameraSwitchButtonFullViewModel.isDisabled = localUserState.cameraState.device == .switching + + let showMuted = localUserState.audioState.operation != .on + if self.isMuted != showMuted { + self.isMuted = showMuted + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/VideoRenderView.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/VideoRenderView.swift new file mode 100644 index 000000000..5fc599854 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/SwiftUI/ViewComponents/VideoView/VideoRenderView.swift @@ -0,0 +1,20 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI +import Combine + +struct VideoRendererView: UIViewRepresentable { + let rendererView: UIView + + func makeUIView(context: Context) -> UIView { + return rendererView + } + + func updateUIView(_ uiView: UIView, context: Context) { + + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Presentation/VideoViewManager.swift b/AzureCommunicationUI/AzureCommunicationUI/Presentation/VideoViewManager.swift new file mode 100644 index 000000000..888347ffb --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Presentation/VideoViewManager.swift @@ -0,0 +1,140 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling + +struct RemoteParticipantVideoViewId { + let userIdentifier: String + let videoStreamIdentifier: String +} + +class VideoViewManager { + struct VideoStreamCache { + var renderer: VideoStreamRenderer + var rendererView: RendererView + } + private let logger: Logger + private var displayedRemoteParticipantsRendererView = MappedSequence() + + private var localRendererViews = MappedSequence() + + private let callingSDKWrapper: CallingSDKWrapper + + init(callingSDKWrapper: CallingSDKWrapper, + logger: Logger) { + self.callingSDKWrapper = callingSDKWrapper + self.logger = logger + } + + deinit { + disposeViews() + } + + func updateDisplayedRemoteVideoStream(_ videoViewIdArray: [RemoteParticipantVideoViewId]) { + let displayedKeys = videoViewIdArray.map { + return generateCacheKey(userIdentifier: $0.userIdentifier, videoStreamId: $0.videoStreamIdentifier) + } + + displayedRemoteParticipantsRendererView.makeKeyIterator().forEach { [weak self] key in + if !displayedKeys.contains(key) { + self?.disposeRemoteParticipantVideoRendererView(key) + } + } + } + + func updateDisplayedLocalVideoStream(_ identifier: String?) { + localRendererViews.makeKeyIterator().forEach { [weak self] key in + if identifier != key { + self?.disposeLocalVideoRendererCache(key) + } + } + } + + func getLocalVideoRendererView(_ videoStreamId: String) -> UIView? { + if let localRenderCache = localRendererViews.value(forKey: videoStreamId) { + return localRenderCache.rendererView + } + + guard let videoStream = callingSDKWrapper.getLocalVideoStream(videoStreamId) else { + return nil + } + + do { + let newRenderer: VideoStreamRenderer = try VideoStreamRenderer(localVideoStream: videoStream) + let newRendererView: RendererView = try newRenderer.createView( + withOptions: CreateViewOptions(scalingMode: .crop)) + + let cache = VideoStreamCache(renderer: newRenderer, + rendererView: newRendererView) + localRendererViews.append(forKey: videoStreamId, + value: cache) + return newRendererView + } catch let error { + logger.error("Failed to render remote video, reason:\(error.localizedDescription)") + return nil + } + + } + + func getRemoteParticipantVideoRendererView(_ videoViewId: RemoteParticipantVideoViewId) -> UIView? { + let videoStreamId = videoViewId.videoStreamIdentifier + let userIdentifier = videoViewId.userIdentifier + let cacheKey = generateCacheKey(userIdentifier: videoViewId.userIdentifier, + videoStreamId: videoStreamId) + if let videoStreamCache = displayedRemoteParticipantsRendererView.value(forKey: cacheKey) { + return videoStreamCache.rendererView + } + + guard let participant = callingSDKWrapper.getRemoteParticipant(userIdentifier), + let videoStream = participant.videoStreams.first(where: { stream in + return String(stream.id) == videoStreamId + }) else { + return nil + } + + do { + let options = CreateViewOptions(scalingMode: videoStream.mediaStreamType == .screenSharing ? .fit : .crop) + let newRenderer: VideoStreamRenderer = try VideoStreamRenderer(remoteVideoStream: videoStream) + let newRendererView: RendererView = try newRenderer.createView(withOptions: options) + + let cache = VideoStreamCache(renderer: newRenderer, + rendererView: newRendererView) + displayedRemoteParticipantsRendererView.append(forKey: cacheKey, + value: cache) + + return newRendererView + } catch let error { + logger.error("Failed to render remote video, reason:\(error.localizedDescription)") + return nil + } + + } + + private func disposeViews() { + displayedRemoteParticipantsRendererView.makeKeyIterator().forEach { key in + self.disposeRemoteParticipantVideoRendererView(key) + } + localRendererViews.makeKeyIterator().forEach { key in + self.disposeLocalVideoRendererCache(key) + } + } + + private func disposeRemoteParticipantVideoRendererView(_ cacheId: String) { + if let renderer = displayedRemoteParticipantsRendererView.removeValue(forKey: cacheId) { + renderer.renderer.dispose() + } + } + + private func disposeLocalVideoRendererCache(_ identifier: String) { + if let renderer = localRendererViews.removeValue(forKey: identifier) { + renderer.renderer.dispose() + } + } + + private func generateCacheKey(userIdentifier: String, videoStreamId: String) -> String { + return ("\(userIdentifier):\(videoStreamId)") + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/Action.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/Action.swift new file mode 100644 index 000000000..fed9e4efd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/Action.swift @@ -0,0 +1,9 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +protocol Action { } diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/CallingAction.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/CallingAction.swift new file mode 100644 index 000000000..deb329481 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/CallingAction.swift @@ -0,0 +1,49 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct CallingAction { + struct CallStartRequested: Action {} + + struct CallEndRequested: Action {} + + struct StateUpdated: Action { + let status: CallingStatus + } + + struct SetupCall: Action {} + + struct DismissSetup: Action {} + + struct RecordingStateUpdated: Action { + let isRecordingActive: Bool + } + + struct TranscriptionStateUpdated: Action { + let isTranscriptionActive: Bool + } +} + +struct ParticipantListUpdated: Action { + let participantsInfoList: [ParticipantInfoModel] +} + +struct ErrorAction: Action { + struct FatalErrorUpdated: Action { + let error: ErrorEvent + let errorCode: String + } + + struct CallStateErrorUpdated: Action { + let error: ErrorEvent + let errorCode: String + } +} + +struct CompositeExitAction: Action {} + +struct CallingViewLaunched: Action { +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LifecycleAction.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LifecycleAction.swift new file mode 100644 index 000000000..7e818d551 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LifecycleAction.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct LifecycleAction { + struct ForegroundEntered: Action {} + struct BackgroundEntered: Action {} + + // Additional action completed +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LocalUserAction.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LocalUserAction.swift new file mode 100644 index 000000000..dc43fad0e --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/LocalUserAction.swift @@ -0,0 +1,64 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +struct LocalUserAction { + struct CameraPreviewOnTriggered: Action {} + struct CameraOnTriggered: Action {} + struct CameraOnSucceeded: Action { + var videoStreamIdentifier: String + } + struct CameraOnFailed: Action { + var error: Error + } + + struct CameraOffTriggered: Action {} + struct CameraOffSucceeded: Action {} + struct CameraOffFailed: Action { + var error: Error + } + + struct CameraPausedSucceeded: Action {} + struct CameraPausedFailed: Action { + var error: Error + } + + struct CameraSwitchTriggered: Action {} + struct CameraSwitchSucceeded: Action { + var cameraDevice: CameraDevice + } + struct CameraSwitchFailed: Action { + var error: Error + } + + struct MicrophoneOnTriggered: Action {} + struct MicrophoneOnFailed: Action { + var error: Error + } + + struct MicrophoneOffTriggered: Action {} + struct MicrophoneOffFailed: Action { + var error: Error + } + + struct MicrophoneMuteStateUpdated: Action { + let isMuted: Bool + } + + struct MicrophonePreviewOn: Action {} + struct MicrophonePreviewOff: Action {} + + struct AudioDeviceChangeRequested: Action { + var device: AudioDeviceType + } + struct AudioDeviceChangeSucceeded: Action { + var device: AudioDeviceType + } + struct AudioDeviceChangeFailed: Action { + var error: Error + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/PermissionAction.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/PermissionAction.swift new file mode 100644 index 000000000..ee5b56bc0 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Action/PermissionAction.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +struct PermissionAction { + struct AudioPermissionRequested: Action {} + struct AudioPermissionGranted: Action {} + struct AudioPermissionDenied: Action {} + struct AudioPermissionNotAsked: Action {} + + struct CameraPermissionRequested: Action {} + struct CameraPermissionGranted: Action {} + struct CameraPermissionDenied: Action {} + struct CameraPermissionNotAsked: Action {} + + static func generateAction(permission: AppPermission, state: AppPermission.Status) -> Action { + switch permission { + case .audioPermission: + switch state { + case .granted: + return AudioPermissionGranted() + case .denied: + return AudioPermissionDenied() + case .notAsked: + return AudioPermissionNotAsked() + default: + return AudioPermissionDenied() + } + case .cameraPermission: + switch state { + case .granted: + return CameraPermissionGranted() + case .denied: + return CameraPermissionDenied() + case .notAsked: + return CameraPermissionNotAsked() + default: + return CameraPermissionDenied() + } + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddleware.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddleware.swift new file mode 100644 index 000000000..3a2fe68b7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddleware.swift @@ -0,0 +1,52 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine + +struct CallingMiddleware: Middleware { + private let actionHandler: CallingMiddlewareHandling + + init(callingMiddlewareHandler: CallingMiddlewareHandling) { + self.actionHandler = callingMiddlewareHandler + } + + func apply(dispatch: @escaping ActionDispatch, + getState: @escaping () -> ReduxState?) -> (@escaping ActionDispatch) -> ActionDispatch { + return { next in + return { action in + switch action { + case _ as CallingAction.SetupCall: + actionHandler.setupCall(state: getState(), dispatch: dispatch) + case _ as CallingAction.CallStartRequested: + actionHandler.startCall(state: getState(), dispatch: dispatch) + case _ as CallingAction.CallEndRequested: + actionHandler.endCall(state: getState(), dispatch: dispatch) + case _ as LocalUserAction.CameraPreviewOnTriggered: + actionHandler.requestCameraPreviewOn(state: getState(), dispatch: dispatch) + case _ as LocalUserAction.CameraOnTriggered: + actionHandler.requestCameraOn(state: getState(), dispatch: dispatch) + case _ as LocalUserAction.CameraOffTriggered: + actionHandler.requestCameraOff(state: getState(), dispatch: dispatch) + case _ as LocalUserAction.CameraSwitchTriggered: + actionHandler.requestCameraSwitch(state: getState(), dispatch: dispatch) + case _ as LocalUserAction.MicrophoneOffTriggered: + actionHandler.requestMicrophoneMute(state: getState(), dispatch: dispatch) + case _ as LocalUserAction.MicrophoneOnTriggered: + actionHandler.requestMicrophoneUnmute(state: getState(), dispatch: dispatch) + case _ as LifecycleAction.BackgroundEntered: + actionHandler.enterBackground(state: getState(), dispatch: dispatch) + case _ as LifecycleAction.ForegroundEntered: + actionHandler.enterForeground(state: getState(), dispatch: dispatch) + case _ as PermissionAction.CameraPermissionGranted: + actionHandler.onCameraPermissionIsSet(state: getState(), dispatch: dispatch) + default: + break + } + return next(action) + } + } + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareErrorHandler.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareErrorHandler.swift new file mode 100644 index 000000000..0c7c23d4d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareErrorHandler.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +extension CallingMiddlewareHandler { + func handle(error: Error, errorCode: String, dispatch: @escaping ActionDispatch) { + let compositeError = ErrorEvent(code: errorCode, error: error) + + let action = ErrorAction.FatalErrorUpdated(error: compositeError, + errorCode: errorCode) + dispatch(action) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareHandler.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareHandler.swift new file mode 100644 index 000000000..f2c32c034 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/CallingMiddlewareHandler.swift @@ -0,0 +1,304 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine +import Foundation + +protocol CallingMiddlewareHandling { + func setupCall(state: ReduxState?, dispatch: @escaping ActionDispatch) + func startCall(state: ReduxState?, dispatch: @escaping ActionDispatch) + func endCall(state: ReduxState?, dispatch: @escaping ActionDispatch) + func enterBackground(state: ReduxState?, dispatch: @escaping ActionDispatch) + func enterForeground(state: ReduxState?, dispatch: @escaping ActionDispatch) + func requestCameraPreviewOn(state: ReduxState?, dispatch: @escaping ActionDispatch) + func requestCameraOn(state: ReduxState?, dispatch: @escaping ActionDispatch) + func requestCameraOff(state: ReduxState?, dispatch: @escaping ActionDispatch) + func requestCameraSwitch(state: ReduxState?, dispatch: @escaping ActionDispatch) + func requestMicrophoneMute(state: ReduxState?, dispatch: @escaping ActionDispatch) + func requestMicrophoneUnmute(state: ReduxState?, dispatch: @escaping ActionDispatch) + func onCameraPermissionIsSet(state: ReduxState?, dispatch: @escaping ActionDispatch) +} + +class CallingMiddlewareHandler: CallingMiddlewareHandling { + private let callingService: CallingService + private let logger: Logger + private let cancelBag = CancelBag() + private let subscription = CancelBag() + + init(callingService: CallingService, logger: Logger) { + self.callingService = callingService + self.logger = logger + } + + func setupCall(state: ReduxState?, dispatch: @escaping ActionDispatch) { + guard let state = state as? AppState else { + return + } + callingService.setupCall() + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { + return + } + + switch completion { + case .failure(let error): + self.handle(error: error, errorCode: CallCompositeErrorCode.callJoin, dispatch: dispatch) + case .finished: + if state.permissionState.cameraPermission == .granted, + state.localUserState.cameraState.operation == .off { + dispatch(LocalUserAction.CameraPreviewOnTriggered()) + } + } + }, receiveValue: { _ in }) + .store(in: cancelBag) + } + + func startCall(state: ReduxState?, dispatch: @escaping ActionDispatch) { + guard let state = state as? AppState else { + return + } + callingService.startCall(isCameraPreferred: state.localUserState.cameraState.operation == .on, + isAudioPreferred: state.localUserState.audioState.operation == .on) + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { + return + } + + switch completion { + case .failure(let error): + self.handle(error: error, errorCode: CallCompositeErrorCode.callJoin, dispatch: dispatch) + case .finished: + break + } + }, receiveValue: { _ in + self.subscription(dispatch: dispatch) + }).store(in: cancelBag) + } + + func endCall(state: ReduxState?, dispatch: @escaping ActionDispatch) { + callingService.endCall() + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { + return + } + + switch completion { + case .failure(let error): + self.handle(error: error, errorCode: CallCompositeErrorCode.callEnd, dispatch: dispatch) + case .finished: + break + } + }, receiveValue: { _ in }) + .store(in: cancelBag) + } + + func enterBackground(state: ReduxState?, dispatch: @escaping ActionDispatch) { + if let state = state as? AppState { + if state.callingState.status == .connected, + state.localUserState.cameraState.operation == .on { + callingService.stopLocalVideoStream() + .map { + LocalUserAction.CameraPausedSucceeded() + }.sink(receiveCompletion: {completion in + switch completion { + case .failure(let error): + dispatch(LocalUserAction.CameraPausedFailed(error: error)) + case .finished: + break + } + }, receiveValue: { newAction in + dispatch(newAction) + }).store(in: cancelBag) + } + } + } + + func enterForeground(state: ReduxState?, dispatch: @escaping ActionDispatch) { + if let state = state as? AppState { + if state.callingState.status == .connected, + state.localUserState.cameraState.operation == .paused { + requestCameraOn(state: state, dispatch: dispatch) + } + } + } + + func requestCameraPreviewOn(state: ReduxState?, dispatch: @escaping ActionDispatch) { + guard let state = state as? AppState else { + return + } + + if state.permissionState.cameraPermission == .notAsked { + dispatch(PermissionAction.CameraPermissionRequested()) + } else { + callingService.requestCameraPreviewOn().map { videoStream in + LocalUserAction.CameraOnSucceeded(videoStreamIdentifier: videoStream) + }.sink(receiveCompletion: { completion in + switch completion { + case .finished: + break + case .failure(let error): + dispatch(LocalUserAction.CameraOnFailed(error: error)) + } + }, receiveValue: { newAction in + dispatch(newAction) + }).store(in: cancelBag) + } + } + + func requestCameraOn(state: ReduxState?, dispatch: @escaping ActionDispatch) { + guard let state = state as? AppState else { + return + } + if state.permissionState.cameraPermission == .notAsked { + dispatch(PermissionAction.CameraPermissionRequested()) + } else { + callingService.startLocalVideoStream() + .map { videoStream in + LocalUserAction.CameraOnSucceeded(videoStreamIdentifier: videoStream) + }.sink(receiveCompletion: {completion in + switch completion { + case .failure(let error): + dispatch(LocalUserAction.CameraOnFailed(error: error)) + case .finished: + break + } + }, receiveValue: { newAction in + dispatch(newAction) + }).store(in: cancelBag) + } + } + + func requestCameraOff(state: ReduxState?, dispatch: @escaping ActionDispatch) { + callingService.stopLocalVideoStream() + .map { + LocalUserAction.CameraOffSucceeded() + }.sink(receiveCompletion: {completion in + switch completion { + case .failure(let error): + dispatch(LocalUserAction.CameraOffFailed(error: error)) + case .finished: + break + } + }, receiveValue: { newAction in + dispatch(newAction) + }).store(in: cancelBag) + } + + func requestCameraSwitch(state: ReduxState?, dispatch: @escaping ActionDispatch) { + callingService.switchCamera() + .map { cameraDevice in + LocalUserAction.CameraSwitchSucceeded(cameraDevice: cameraDevice) + }.sink(receiveCompletion: {completion in + switch completion { + case .failure(let error): + dispatch(LocalUserAction.CameraSwitchFailed(error: error)) + case .finished: + break + } + }, receiveValue: { newAction in + dispatch(newAction) + }).store(in: cancelBag) + } + + func requestMicrophoneMute(state: ReduxState?, dispatch: @escaping ActionDispatch) { + callingService.muteLocalMic() + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + dispatch(LocalUserAction.MicrophoneOffFailed(error: error)) + case .finished: + break + } + }, receiveValue: {}).store(in: cancelBag) + } + + func requestMicrophoneUnmute(state: ReduxState?, dispatch: @escaping ActionDispatch) { + callingService.unmuteLocalMic() + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + dispatch(LocalUserAction.MicrophoneOnFailed(error: error)) + case .finished: + break + } + }, receiveValue: {}).store(in: cancelBag) + } + + func onCameraPermissionIsSet(state: ReduxState?, dispatch: @escaping ActionDispatch) { + if let state = state as? AppState { + switch state.localUserState.cameraState.transmission { + case .local: + dispatch(LocalUserAction.CameraPreviewOnTriggered()) + case .remote: + dispatch(LocalUserAction.CameraOnTriggered()) + } + } + } +} + +extension CallingMiddlewareHandler { + private func subscription(dispatch: @escaping ActionDispatch) { + logger.debug("Subscribe to calling service subjects") + callingService.participantsInfoListSubject + .throttle(for: 1.25, scheduler: DispatchQueue.main, latest: true) + .sink { list in + let action = ParticipantListUpdated(participantsInfoList: list) + dispatch(action) + }.store(in: subscription) + + callingService.callInfoSubject + .sink { [weak self] callInfoModel in + let errorCode = callInfoModel.errorCode + let status = callInfoModel.status + + self?.logger.debug("Dispatch State Update: \(status)") + if errorCode != "" { + self?.logger.debug("Dispatch Error Code Update: \(errorCode)") + let action: Action + let error = ErrorEvent(code: errorCode, error: nil) + if errorCode == CallCompositeErrorCode.tokenExpired { + action = ErrorAction.FatalErrorUpdated(error: error, errorCode: errorCode) + } else { + action = ErrorAction.CallStateErrorUpdated(error: error, errorCode: errorCode) + } + + dispatch(action) + self?.logger.debug("Subscription cancel error path") + self?.subscription.cancel() + } + + let action = CallingAction.StateUpdated(status: status) + dispatch(action) + + if status == .disconnected, + errorCode == "" { + dispatch(CompositeExitAction()) + self?.logger.debug("Subscription cancel happy path") + self?.subscription.cancel() + } + }.store(in: subscription) + + callingService.isRecordingActiveSubject + .removeDuplicates() + .sink { isRecordingActive in + let action = CallingAction.RecordingStateUpdated(isRecordingActive: isRecordingActive) + dispatch(action) + }.store(in: subscription) + + callingService.isTranscriptionActiveSubject + .removeDuplicates() + .sink { isTranscriptionActive in + let action = CallingAction.TranscriptionStateUpdated(isTranscriptionActive: isTranscriptionActive) + dispatch(action) + }.store(in: subscription) + + callingService.isLocalUserMutedSubject + .removeDuplicates() + .sink { isLocalUserMuted in + let action = LocalUserAction.MicrophoneMuteStateUpdated(isMuted: isLocalUserMuted) + dispatch(action) + }.store(in: subscription) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/Middleware.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/Middleware.swift new file mode 100644 index 000000000..d6b0c0642 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Middleware/Middleware.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine + +typealias ActionDispatch = (Action) -> Void + +protocol Middleware { + func apply(dispatch: @escaping ActionDispatch, + getState: @escaping () -> ReduxState?) -> (@escaping ActionDispatch) -> ActionDispatch +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/AppStateReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/AppStateReducer.swift new file mode 100644 index 000000000..b7c1a9319 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/AppStateReducer.swift @@ -0,0 +1,81 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine + +struct AppStateReducer: Reducer { + let permissionReducer: Reducer + let localUserReducer: Reducer + let lifeCycleReducer: Reducer + let callingReducer: Reducer + let navigationReducer: Reducer + let errorReducer: Reducer + + init(permissionReducer: Reducer, + localUserReducer: Reducer, + lifeCycleReducer: Reducer, + callingReducer: Reducer, + navigationReducer: Reducer, + errorReducer: Reducer) { + self.permissionReducer = permissionReducer + self.localUserReducer = localUserReducer + self.lifeCycleReducer = lifeCycleReducer + self.callingReducer = callingReducer + self.navigationReducer = navigationReducer + self.errorReducer = errorReducer + } + + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let state = state as? AppState else { + return state + } + + var permissionState = state.permissionState + var localUserState = state.localUserState + var lifeCycleState = state.lifeCycleState + var callingState = state.callingState + var remoteParticipantState = state.remoteParticipantsState + var navigationState = state.navigationState + var errorState = state.errorState + + if let newPermissionState = permissionReducer.reduce(state.permissionState, action) as? PermissionState { + permissionState = newPermissionState + } + + if let newLocalUserState = localUserReducer.reduce(state.localUserState, action) as? LocalUserState { + localUserState = newLocalUserState + } + + if let newLifeCycleState = lifeCycleReducer.reduce(state.lifeCycleState, action) as? LifeCycleState { + lifeCycleState = newLifeCycleState + } + + if let newCallingState = callingReducer.reduce(state.callingState, action) as? CallingState { + callingState = newCallingState + } + + if let newNaviState = navigationReducer.reduce(state.navigationState, action) as? NavigationState { + navigationState = newNaviState + } + + if let newErrorState = errorReducer.reduce(state.errorState, action) as? ErrorState { + errorState = newErrorState + } + + switch action { + case let action as ParticipantListUpdated: + remoteParticipantState = RemoteParticipantsState(participantInfoList: action.participantsInfoList) + default: + break + } + return AppState(callingState: callingState, + permissionState: permissionState, + localUserState: localUserState, + lifeCycleState: lifeCycleState, + navigationState: navigationState, + remoteParticipantsState: remoteParticipantState, + errorState: errorState) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/CallingReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/CallingReducer.swift new file mode 100644 index 000000000..903c0e2b1 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/CallingReducer.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine + +struct CallingReducer: Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let callingState = state as? CallingState else { + return state + } + + var coreStatus = callingState.status + var isRecordingActive = callingState.isRecordingActive + var isTranscriptionActive = callingState.isTranscriptionActive + switch action { + case let action as CallingAction.StateUpdated: + coreStatus = action.status + case let action as CallingAction.RecordingStateUpdated: + isRecordingActive = action.isRecordingActive + case let action as CallingAction.TranscriptionStateUpdated: + isTranscriptionActive = action.isTranscriptionActive + default: + return state + } + return CallingState(status: coreStatus, + isRecordingActive: isRecordingActive, + isTranscriptionActive: isTranscriptionActive) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/ErrorReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/ErrorReducer.swift new file mode 100644 index 000000000..b90a568b4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/ErrorReducer.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine + +struct ErrorReducer: Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let state = state as? ErrorState else { + return state + } + + var error = state.error + var errorCode = state.errorCode + var errorCategory = state.errorCategory + + switch action { + case let action as ErrorAction.FatalErrorUpdated: + error = action.error + errorCode = action.errorCode + errorCategory = .fatal + case let action as ErrorAction.CallStateErrorUpdated: + error = action.error + errorCode = action.errorCode + errorCategory = .callState + default: + return state + } + + return ErrorState(error: error, + errorCode: errorCode, + errorCategory: errorCategory) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LifeCycleReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LifeCycleReducer.swift new file mode 100644 index 000000000..7826b9d51 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LifeCycleReducer.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct LifeCycleReducer: Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let appLifeCycleCurrentState = state as? LifeCycleState else { + return state + } + var currentStatus = appLifeCycleCurrentState.currentStatus + switch action { + case _ as LifecycleAction.ForegroundEntered: + currentStatus = .foreground + case _ as LifecycleAction.BackgroundEntered: + currentStatus = .background + default: + return appLifeCycleCurrentState + } + return LifeCycleState(currentStatus: currentStatus) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LocalUserReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LocalUserReducer.swift new file mode 100644 index 000000000..36709380f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/LocalUserReducer.swift @@ -0,0 +1,82 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine +import Foundation + +struct LocalUserReducer: Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let localUserState = state as? LocalUserState else { + return state + } + var cameraStatus = localUserState.cameraState.operation + var cameraDeviceStatus = localUserState.cameraState.device + var cameraTransmissionStatus = localUserState.cameraState.transmission + var microphoneStatus = localUserState.audioState.operation + var audioDeviceStatus = localUserState.audioState.device + let displayName = localUserState.displayName + var localVideoStreamIdentifier = localUserState.localVideoStreamIdentifier + switch action { + case _ as LocalUserAction.CameraPreviewOnTriggered: + cameraTransmissionStatus = .local + case _ as LocalUserAction.CameraOnTriggered: + cameraTransmissionStatus = .remote + cameraStatus = .pending + case _ as LocalUserAction.CameraOffTriggered: + cameraStatus = .pending + case let action as LocalUserAction.CameraOnSucceeded: + localVideoStreamIdentifier = action.videoStreamIdentifier + cameraStatus = .on + case let action as LocalUserAction.CameraOnFailed: + cameraStatus = .error(action.error) + case _ as LocalUserAction.CameraOffSucceeded: + localVideoStreamIdentifier = nil + cameraStatus = .off + case let action as LocalUserAction.CameraOffFailed: + cameraStatus = .error(action.error) + case _ as LocalUserAction.CameraPausedSucceeded: + cameraStatus = .paused + case let action as LocalUserAction.CameraPausedFailed: + cameraStatus = .error(action.error) + case _ as LocalUserAction.CameraSwitchTriggered: + cameraDeviceStatus = .switching + case let action as LocalUserAction.CameraSwitchSucceeded: + cameraDeviceStatus = action.cameraDevice == .front ? .front : .back + case let action as LocalUserAction.CameraSwitchFailed: + cameraDeviceStatus = .error(action.error) + case _ as LocalUserAction.MicrophoneOnTriggered, + _ as LocalUserAction.MicrophoneOffTriggered: + microphoneStatus = .pending + case _ as LocalUserAction.MicrophonePreviewOn: + microphoneStatus = .on + case let action as LocalUserAction.MicrophoneOnFailed: + microphoneStatus = .error(action.error) + case _ as LocalUserAction.MicrophonePreviewOff: + microphoneStatus = .off + case let action as LocalUserAction.MicrophoneMuteStateUpdated: + microphoneStatus = action.isMuted ? .off : .on + case let action as LocalUserAction.MicrophoneOffFailed: + microphoneStatus = .error(action.error) + case let action as LocalUserAction.AudioDeviceChangeRequested: + audioDeviceStatus = action.device == .speaker ? .speakerRequested : .receiverRequested + case let action as LocalUserAction.AudioDeviceChangeSucceeded: + audioDeviceStatus = action.device == .speaker ? .speakerSelected : .receiverSelected + case let action as LocalUserAction.AudioDeviceChangeFailed: + audioDeviceStatus = .error(action.error) + default: + return localUserState + } + + let cameraState = LocalUserState.CameraState(operation: cameraStatus, + device: cameraDeviceStatus, + transmission: cameraTransmissionStatus) + let audioState = LocalUserState.AudioState(operation: microphoneStatus, + device: audioDeviceStatus) + return LocalUserState(cameraState: cameraState, + audioState: audioState, + displayName: displayName, + localVideoStreamIdentifier: localVideoStreamIdentifier) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/NavigationReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/NavigationReducer.swift new file mode 100644 index 000000000..1dddc7b85 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/NavigationReducer.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine + +struct NavigationReducer: Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let state = state as? NavigationState else { + return state + } + var navigationStatus = state.status + switch action { + case _ as CallingViewLaunched: + navigationStatus = .inCall + case _ as CallingAction.DismissSetup, + _ as CompositeExitAction: + navigationStatus = .exit + case _ as ErrorAction.CallStateErrorUpdated: + navigationStatus = .setup + default: + return state + } + return NavigationState(status: navigationStatus) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/PermissionReducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/PermissionReducer.swift new file mode 100644 index 000000000..38a0e8004 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/PermissionReducer.swift @@ -0,0 +1,38 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +struct PermissionReducer: Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + guard let permissionState = state as? PermissionState else { + return state + } + var cameraPermission = permissionState.cameraPermission + var audioPermission = permissionState.audioPermission + switch action { + case _ as PermissionAction.AudioPermissionRequested: + audioPermission = .requesting + case _ as PermissionAction.AudioPermissionGranted: + audioPermission = .granted + case _ as PermissionAction.AudioPermissionDenied: + audioPermission = .denied + case _ as PermissionAction.AudioPermissionNotAsked: + audioPermission = .notAsked + case _ as PermissionAction.CameraPermissionRequested: + cameraPermission = .requesting + case _ as PermissionAction.CameraPermissionGranted: + cameraPermission = .granted + case _ as PermissionAction.CameraPermissionDenied: + cameraPermission = .denied + case _ as PermissionAction.CameraPermissionNotAsked: + cameraPermission = .notAsked + default: + return permissionState + } + return PermissionState(audioPermission: audioPermission, + cameraPermission: cameraPermission) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/Reducer.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/Reducer.swift new file mode 100644 index 000000000..91efc5b9b --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Reducer/Reducer.swift @@ -0,0 +1,11 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine +import Foundation + +protocol Reducer { + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppLifeCycleState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppLifeCycleState.swift new file mode 100644 index 000000000..42571e38f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppLifeCycleState.swift @@ -0,0 +1,20 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class LifeCycleState: ReduxState { + enum AppStatus { + case foreground + case background + } + + let currentStatus: AppStatus + + init(currentStatus: AppStatus = .foreground) { + self.currentStatus = currentStatus + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppState.swift new file mode 100644 index 000000000..a39ff4339 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/AppState.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class AppState: ReduxState { + let callingState: CallingState + let permissionState: PermissionState + let localUserState: LocalUserState + let lifeCycleState: LifeCycleState + let remoteParticipantsState: RemoteParticipantsState + let navigationState: NavigationState + let errorState: ErrorState + + init(callingState: CallingState = .init(), + permissionState: PermissionState = .init(), + localUserState: LocalUserState = .init(), + lifeCycleState: LifeCycleState = .init(), + navigationState: NavigationState = .init(), + remoteParticipantsState: RemoteParticipantsState = .init(), + errorState: ErrorState = .init()) { + self.callingState = callingState + self.permissionState = permissionState + self.localUserState = localUserState + self.lifeCycleState = lifeCycleState + self.navigationState = navigationState + self.remoteParticipantsState = remoteParticipantsState + self.errorState = errorState + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/CallingState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/CallingState.swift new file mode 100644 index 000000000..1f02f4d67 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/CallingState.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +enum CallingStatus: Int { + case none + case earlyMedia + case connecting + case ringing + case connected + case localHold + case disconnecting + case disconnected + case inLobby + case remoteHold +} + +class CallingState: ReduxState, Equatable { + let status: CallingStatus + let isRecordingActive: Bool + let isTranscriptionActive: Bool + + init(status: CallingStatus = .none, + isRecordingActive: Bool = false, + isTranscriptionActive: Bool = false) { + self.status = status + self.isRecordingActive = isRecordingActive + self.isTranscriptionActive = isTranscriptionActive + } + + static func == (lhs: CallingState, rhs: CallingState) -> Bool { + return (lhs.status == rhs.status + && lhs.isRecordingActive == rhs.isRecordingActive + && lhs.isTranscriptionActive == rhs.isTranscriptionActive) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/ErrorState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/ErrorState.swift new file mode 100644 index 000000000..a77259f66 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/ErrorState.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +enum ErrorCategory { + case fatal + case callState + case none +} + +class ErrorState: ReduxState, Equatable { + let error: ErrorEvent? + let errorCode: String + let errorCategory: ErrorCategory + + public init(error: ErrorEvent? = nil, + errorCode: String = "", + errorCategory: ErrorCategory = .none) { + self.error = error + self.errorCode = errorCode + self.errorCategory = errorCategory + } + + static func == (lhs: ErrorState, rhs: ErrorState) -> Bool { + return (lhs.errorCode == rhs.errorCode) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/LocalUserState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/LocalUserState.swift new file mode 100644 index 000000000..ea7d84fcc --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/LocalUserState.swift @@ -0,0 +1,137 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class LocalUserState: ReduxState { + enum CameraOperationalStatus: Equatable { + case on + case off + case paused + case pending + case error(Error) + + static func == (lhs: LocalUserState.CameraOperationalStatus, + rhs: LocalUserState.CameraOperationalStatus) -> Bool { + switch (lhs, rhs) { + case (.on, .on), + (.off, .off), + (.paused, paused), + (.pending, .pending), + (.error, .error): + return true + default: + return false + } + } + } + + enum CameraDeviceSelectionStatus: Equatable { + case front + case back + case switching + case error(Error) + + static func == (lhs: LocalUserState.CameraDeviceSelectionStatus, + rhs: LocalUserState.CameraDeviceSelectionStatus) -> Bool { + switch (lhs, rhs) { + case (.front, .front), + (.back, .back), + (.switching, switching), + (.error, .error): + return true + default: + return false + } + } + } + + enum CameraTransmissionStatus: Equatable { + case local + case remote + + static func == (lhs: LocalUserState.CameraTransmissionStatus, + rhs: LocalUserState.CameraTransmissionStatus) -> Bool { + switch (lhs, rhs) { + case (.local, .local), + (.remote, .remote): + return true + default: + return false + } + } + } + + enum AudioOperationalStatus: Equatable { + case on + case off + case pending + case error(Error) + + static func == (lhs: LocalUserState.AudioOperationalStatus, + rhs: LocalUserState.AudioOperationalStatus) -> Bool { + switch (lhs, rhs) { + case (.on, .on), + (.off, .off), + (.pending, .pending), + (.error, .error): + return true + default: + return false + } + } + } + + enum AudioDeviceSelectionStatus: Equatable { + case speakerSelected + case speakerRequested + case receiverSelected + case receiverRequested + case error(Error) + + static func == (lhs: LocalUserState.AudioDeviceSelectionStatus, + rhs: LocalUserState.AudioDeviceSelectionStatus) -> Bool { + switch (lhs, rhs) { + case (.speakerSelected, .speakerSelected), + (.speakerRequested, .speakerRequested), + (.receiverSelected, receiverSelected), + (.receiverRequested, .receiverRequested), + (.error, .error): + return true + default: + return false + } + } + } + + struct CameraState { + let operation: CameraOperationalStatus + let device: CameraDeviceSelectionStatus + let transmission: CameraTransmissionStatus + } + + struct AudioState { + let operation: AudioOperationalStatus + let device: AudioDeviceSelectionStatus + } + + let cameraState: CameraState + let audioState: AudioState + let displayName: String? + let localVideoStreamIdentifier: String? + + init(cameraState: CameraState = CameraState(operation: .off, + device: .front, + transmission: .local), + audioState: AudioState = AudioState(operation: .off, + device: .receiverSelected), + displayName: String? = nil, + localVideoStreamIdentifier: String? = nil) { + self.cameraState = cameraState + self.audioState = audioState + self.displayName = displayName + self.localVideoStreamIdentifier = localVideoStreamIdentifier + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/NavigationState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/NavigationState.swift new file mode 100644 index 000000000..1df04a285 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/NavigationState.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +enum NavigationStatus { + case setup + case inCall + case exit +} + +class NavigationState: ReduxState, Equatable { + + let status: NavigationStatus + + public init(status: NavigationStatus = .setup) { + self.status = status + } + + static func == (lhs: NavigationState, rhs: NavigationState) -> Bool { + return lhs.status == rhs.status + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/PermissionState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/PermissionState.swift new file mode 100644 index 000000000..8b7394545 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/PermissionState.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +enum AppPermission { + case audioPermission + case cameraPermission + + enum Status: String, Equatable { + case unknown + case notAsked + case requesting + case granted + case denied + } +} + +class PermissionState: ReduxState { + + let audioPermission: AppPermission.Status + let cameraPermission: AppPermission.Status + + init(audioPermission: AppPermission.Status = .unknown, cameraPermission: AppPermission.Status = .unknown) { + self.audioPermission = audioPermission + self.cameraPermission = cameraPermission + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/ReduxState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/ReduxState.swift new file mode 100644 index 000000000..061293fb3 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/ReduxState.swift @@ -0,0 +1,9 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling + +protocol ReduxState { } diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/State/RemoteParticipantsState.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/RemoteParticipantsState.swift new file mode 100644 index 000000000..6a6dc2454 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/State/RemoteParticipantsState.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +class RemoteParticipantsState: ReduxState { + let participantInfoList: [ParticipantInfoModel] + let lastUpdateTimeStamp: Date + + init(participantInfoList: [ParticipantInfoModel] = [], + lastUpdateTimeStamp: Date = Date()) { + self.participantInfoList = participantInfoList + self.lastUpdateTimeStamp = lastUpdateTimeStamp + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Redux/Store.swift b/AzureCommunicationUI/AzureCommunicationUI/Redux/Store.swift new file mode 100644 index 000000000..0351b95fc --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Redux/Store.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Combine +import Foundation + +class Store: ObservableObject { + + @Published public var state: T + + private var dispatchFunction: ActionDispatch! + private let reducer: Reducer + private let actionDispatchQueue = DispatchQueue(label: "ActionDispatchQueue") + + init(reducer: Reducer, + middlewares: [Middleware], + state: T) { + self.reducer = reducer + self.state = state + self.dispatchFunction = middlewares + .reversed() + .reduce({ [unowned self] action in + self._dispatch(action: action) + }, { nextDispatch, middleware in + let dispatch: (Action) -> Void = { [weak self] in self?.dispatch(action: $0) } + let getState = { [weak self] in self?.state } + return middleware.apply(dispatch: dispatch, getState: getState)(nextDispatch) + }) + } + + func dispatch(action: Action) { + actionDispatchQueue.async { + self.dispatchFunction(action) + } + } + + private func _dispatch(action: Action) { + guard let newState = reducer.reduce(state, action) as? T else { + return + } + state = newState + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKEventsHandler.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKEventsHandler.swift new file mode 100644 index 000000000..9f3534991 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKEventsHandler.swift @@ -0,0 +1,194 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine +import AzureCommunicationCalling + +protocol CallingSDKEventsHandling: CallDelegate { + func assign(_ recordingCallFeature: RecordingCallFeature) + func assign(_ transcriptionCallFeature: TranscriptionCallFeature) + + var participantsInfoListSubject: CurrentValueSubject<[ParticipantInfoModel], Never> { get } + var callInfoSubject: PassthroughSubject { get } + var isRecordingActiveSubject: PassthroughSubject { get } + var isTranscriptionActiveSubject: PassthroughSubject { get } + var isLocalUserMutedSubject: PassthroughSubject { get } +} + +class CallingSDKEventsHandler: NSObject, CallingSDKEventsHandling { + var participantsInfoListSubject: CurrentValueSubject<[ParticipantInfoModel], Never> = .init([]) + var callInfoSubject = PassthroughSubject() + var isRecordingActiveSubject = PassthroughSubject() + var isTranscriptionActiveSubject = PassthroughSubject() + var isLocalUserMutedSubject = PassthroughSubject() + + private let logger: Logger + private var remoteParticipantEventAdapter = RemoteParticipantsEventsAdapter() + private var recordingCallFeature: RecordingCallFeature? + private var transcriptionCallFeature: TranscriptionCallFeature? + private var remoteParticipants = MappedSequence() + private var previousCallingStatus: CallingStatus = .none + + init(logger: Logger) { + self.logger = logger + super.init() + setupRemoteParticipantEventsAdapter() + } + + func assign(_ recordingCallFeature: RecordingCallFeature) { + self.recordingCallFeature = recordingCallFeature + recordingCallFeature.delegate = self + } + + func assign(_ transcriptionCallFeature: TranscriptionCallFeature) { + self.transcriptionCallFeature = transcriptionCallFeature + transcriptionCallFeature.delegate = self + } + + private func setupRemoteParticipantEventsAdapter() { + remoteParticipantEventAdapter.onIsMutedChanged = { [weak self] remoteParticipant in + guard let self = self, + let userIdentifier = remoteParticipant.identifier.stringValue else { + return + } + self.updateRemoteParticipant(userIdentifier: userIdentifier, updateSpeakingStamp: false) + } + + remoteParticipantEventAdapter.onIsSpeakingChanged = { [weak self] remoteParticipant in + guard let self = self, + let userIdentifier = remoteParticipant.identifier.stringValue else { + return + } + let updateSpeakingStamp = remoteParticipant.isSpeaking ? true : false + self.updateRemoteParticipant(userIdentifier: userIdentifier, updateSpeakingStamp: updateSpeakingStamp) + } + + remoteParticipantEventAdapter.onVideoStreamsUpdated = { [weak self] remoteParticipant in + guard let self = self, + let userIdentifier = remoteParticipant.identifier.stringValue else { + return + } + self.updateRemoteParticipant(userIdentifier: userIdentifier, updateSpeakingStamp: false) + } + } + + private func removeRemoteParticipants(_ remoteParticipants: [RemoteParticipant]) { + for participant in remoteParticipants { + if let userIdentifier = participant.identifier.stringValue { + self.remoteParticipants.removeValue(forKey: userIdentifier)?.delegate = nil + + } + } + removeRemoteParticipantsInfoModel(remoteParticipants) + } + + private func removeRemoteParticipantsInfoModel(_ remoteParticipants: [RemoteParticipant]) { + var remoteParticipantsInfoList = participantsInfoListSubject.value + remoteParticipantsInfoList = + remoteParticipantsInfoList.filter { infoModel in + !remoteParticipants.contains(where: { + $0.identifier.stringValue == infoModel.userIdentifier + }) + } + participantsInfoListSubject.send(remoteParticipantsInfoList) + } + + private func addRemoteParticipants(_ remoteParticipants: [RemoteParticipant]) { + for participant in remoteParticipants { + if let userIdentifier = participant.identifier.stringValue { + participant.delegate = remoteParticipantEventAdapter + self.remoteParticipants.append(forKey: userIdentifier, value: participant) + } + } + addRemoteParticipantsInfoModel(remoteParticipants) + } + + private func addRemoteParticipantsInfoModel(_ remoteParticipants: [RemoteParticipant]) { + var remoteParticipantsInfoList = participantsInfoListSubject.value + remoteParticipants.forEach { + let infoModel = $0.toParticipantInfoModel(recentSpeakingStamp: Date(timeIntervalSince1970: 0)) + remoteParticipantsInfoList.append(infoModel) + } + participantsInfoListSubject.send(remoteParticipantsInfoList) + } + + private func updateRemoteParticipantInfoList() { + var remoteParticipantsInfoList = [ParticipantInfoModel]() + remoteParticipants.forEach { + let infoModel = $0.toParticipantInfoModel(recentSpeakingStamp: Date(timeIntervalSince1970: 0)) + remoteParticipantsInfoList.append(infoModel) + } + participantsInfoListSubject.send(remoteParticipantsInfoList) + } + + private func updateRemoteParticipant(userIdentifier: String, + updateSpeakingStamp: Bool) { + var remoteParticipantsInfoList = participantsInfoListSubject.value + if let index = remoteParticipantsInfoList.firstIndex(where: { + $0.userIdentifier == userIdentifier + }), + let remoteParticipant = remoteParticipants.value(forKey: userIdentifier) { + let speakingStamp = remoteParticipantsInfoList[index].recentSpeakingStamp + let timeStamp = updateSpeakingStamp ? Date() : speakingStamp + let newInfoModel = remoteParticipant.toParticipantInfoModel(recentSpeakingStamp: timeStamp) + remoteParticipantsInfoList[index] = newInfoModel + } + + participantsInfoListSubject.send(remoteParticipantsInfoList) + } +} + +extension CallingSDKEventsHandler: CallDelegate, + RecordingCallFeatureDelegate, + TranscriptionCallFeatureDelegate { + func call(_ call: Call, didUpdateRemoteParticipant args: ParticipantsUpdatedEventArgs) { + removeRemoteParticipants(args.removedParticipants) + addRemoteParticipants(args.addedParticipants) + } + + func call(_ call: Call, didChangeState args: PropertyChangedEventArgs) { + let currentStatus = call.state.toCallingStatus() + let errorCode = determineErrorType(previousStatus: self.previousCallingStatus, + callEndReason: call.callEndReason.code) + + let callInfoModel = CallInfoModel(status: currentStatus, errorCode: errorCode) + callInfoSubject.send(callInfoModel) + self.previousCallingStatus = currentStatus + } + + func recordingCallFeature(_ recordingCallFeature: RecordingCallFeature, + didChangeRecordingState args: PropertyChangedEventArgs) { + let newRecordingActive = recordingCallFeature.isRecordingActive + isRecordingActiveSubject.send(newRecordingActive) + } + + func transcriptionCallFeature(_ transcriptionCallFeature: TranscriptionCallFeature, + didChangeTranscriptionState args: PropertyChangedEventArgs) { + let newTranscriptionActive = transcriptionCallFeature.isTranscriptionActive + isTranscriptionActiveSubject.send(newTranscriptionActive) + } + + func call(_ call: Call, didChangeMuteState args: PropertyChangedEventArgs) { + isLocalUserMutedSubject.send(call.isMuted) + } + + private func determineErrorType(previousStatus: CallingStatus, callEndReason: Int32) -> String { + if callEndReason > 0 { + if callEndReason == 401 { + return CallCompositeErrorCode.tokenExpired + } else { + if previousStatus == .connected { + return CallCompositeErrorCode.callEnd + } else { + return CallCompositeErrorCode.callJoin + } + } + + } + + return "" + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKWrapper.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKWrapper.swift new file mode 100644 index 000000000..9309e0c62 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingSDKWrapper.swift @@ -0,0 +1,378 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine +import AzureCommunicationCalling + +enum CameraDevice { + case front + case back +} + +protocol CallingSDKWrapper { + func getRemoteParticipant(_ identifier: String) -> RemoteParticipant? + func startPreviewVideoStream() -> AnyPublisher + func getLocalVideoStream(_ identifier: String) -> LocalVideoStream? + func setupCall() -> AnyPublisher + func startCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> AnyPublisher + func endCall() -> AnyPublisher + func startCallLocalVideoStream() -> AnyPublisher + func stopLocalVideoStream() -> AnyPublisher + func switchCamera() -> AnyPublisher + func muteLocalMic() -> AnyPublisher + func unmuteLocalMic() -> AnyPublisher + + var callingEventsHandler: CallingSDKEventsHandling { get } +} + +class ACSCallingSDKWrapper: NSObject, CallingSDKWrapper { + let callingEventsHandler: CallingSDKEventsHandling + + private let logger: Logger + private let callConfiguration: CallConfiguration + private var callClient: CallClient? + private var callAgent: CallAgent? + private var call: Call? + private var deviceManager: DeviceManager? + private var localVideoStream: LocalVideoStream? + + private var newVideoDeviceAddedHandler: ((VideoDeviceInfo) -> Void)? + + init(logger: Logger, + callingEventsHandler: CallingSDKEventsHandling, + callConfiguration: CallConfiguration) { + self.logger = logger + self.callingEventsHandler = callingEventsHandler + self.callConfiguration = callConfiguration + super.init() + } + + deinit { + logger.debug("CallingSDKWrapper deallocated") + } + + func setupCall() -> AnyPublisher { + setupCallClientAndDeviceManager().eraseToAnyPublisher() + } + + func startCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> AnyPublisher { + self.logger.debug( "Starting call") + return setupCallAgent() + .flatMap { [weak self] _ -> AnyPublisher in + guard let self = self else { + let error = CompositeError.invalidSDKWrapper + return Fail(error: error).eraseToAnyPublisher() + } + return self.joinCall(isCameraPreferred: isCameraPreferred, + isAudioPreferred: isAudioPreferred).eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func joinCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> Future { + Future { promise in + self.logger.debug( "Joining call") + let joinCallOptions = JoinCallOptions() + + if isCameraPreferred, + let localVideoStream = self.localVideoStream { + let localVideoStreamArray = [localVideoStream] + let videoOptions = VideoOptions(localVideoStreams: localVideoStreamArray) + joinCallOptions.videoOptions = videoOptions + } + + joinCallOptions.audioOptions = AudioOptions() + joinCallOptions.audioOptions?.muted = !isAudioPreferred + + var joinLocator: JoinMeetingLocator! + if self.callConfiguration.compositeCallType == .groupCall { + joinLocator = GroupCallLocator(groupId: self.callConfiguration.groupId!) + } else { + joinLocator = TeamsMeetingLinkLocator(meetingLink: self.callConfiguration.meetingLink!) + } + + self.callAgent?.join(with: joinLocator, joinCallOptions: joinCallOptions) { [weak self] (call, error) in + guard let self = self, + let call = call else { + self?.logger.error( "Join call failed") + return promise(.failure(CompositeError.invalidSDKWrapper)) + } + + if let error = error { + self.logger.error( "Join call failed with error") + return promise(.failure(error)) + } + call.delegate = self.callingEventsHandler + self.call = call + self.setupCallRecordingAndTranscriptionFeature() + + return promise(.success(())) + } + } + } + + func endCall() -> AnyPublisher { + Future { promise in + self.call?.hangUp(options: HangUpOptions()) { (error) in + if let error = error { + self.logger.error( "It was not possible to hangup the call.") + return promise(.failure(error)) + } + self.logger.debug("Call ended successfully") + promise(.success(())) + } + }.eraseToAnyPublisher() + } + + func getRemoteParticipant(_ identifier: String) -> RemoteParticipant? { + guard let call = call else { + return nil + } + + let remote = call.remoteParticipants.first(where: { + $0.identifier.stringValue == identifier + }) + + return remote + + } + + func getLocalVideoStream(_ identifier: String) -> LocalVideoStream? { + guard getLocalVideoStreamIdentifier() == identifier else { + return nil + } + return localVideoStream + } + + func startCallLocalVideoStream() -> AnyPublisher { + return getValidLocalVideoStream() + .flatMap { videoStream in + self.startCallVideoStream(videoStream) + }.eraseToAnyPublisher() + } + + func stopLocalVideoStream() -> AnyPublisher { + Future { promise in + guard let call = self.call, + let videoStream = self.localVideoStream else { + self.logger.debug("Local video stopped successfully without call") + promise(.success(())) + return + } + call.stopVideo(stream: videoStream) { [weak self] (error) in + if error != nil { + self?.logger.error( "Local video failed to stop. \(error!)") + promise(.failure(error!)) + return + } + self?.logger.debug("Local video stopped successfully") + promise(.success(())) + } + + }.eraseToAnyPublisher() + } + + func switchCamera() -> AnyPublisher { + guard let videoStream = self.localVideoStream else { + let error = CompositeError.invalidLocalVideoStream + self.logger.error( "\(error)") + return Fail(error: error).eraseToAnyPublisher() + } + + let currentCamera = videoStream.source + let flippedFacing: CameraFacing = currentCamera.cameraFacing == .front ? .back : .front + + return getVideoDeviceInfo(flippedFacing) + .flatMap { [weak self] deviceInfo -> AnyPublisher in + guard let self = self else { + let error = CompositeError.invalidSDKWrapper + return Fail(error: error).eraseToAnyPublisher() + } + return self.change(videoStream, source: deviceInfo).eraseToAnyPublisher() + }.map { + flippedFacing.toCameraDevice() + }.eraseToAnyPublisher() + } + + func startPreviewVideoStream() -> AnyPublisher { + return self.getValidLocalVideoStream() + .map({ [weak self] _ in + return self?.getLocalVideoStreamIdentifier() ?? "" + }).eraseToAnyPublisher() + } + + func muteLocalMic() -> AnyPublisher { + Future { promise in + self.call?.mute { [weak self] (error) in + if error != nil { + self?.logger.error( "ERROR: It was not possible to mute. \(error!)") + return promise(.failure(error!)) + } + self?.logger.debug("Mute successful") + promise(.success(())) + } + }.eraseToAnyPublisher() + } + + func unmuteLocalMic() -> AnyPublisher { + Future { promise in + self.call?.unmute { [weak self] (error) in + if let error = error { + self?.logger.error( "ERROR: It was not possible to unmute. \(error)") + return promise(.failure(error)) + } + self?.logger.debug("Unmute successful") + promise(.success(())) + } + }.eraseToAnyPublisher() + } +} + +extension ACSCallingSDKWrapper { + private func setupCallClientAndDeviceManager() -> Future { + Future { promise in + self.callClient = self.makeCallClient() + self.callClient!.getDeviceManager(completionHandler: { [weak self] (deviceManager, error) in + guard let self = self else { + return + } + if let error = error { + self.logger.error("Failed to get device manager instance") + return promise(.failure(error)) + } + self.deviceManager = deviceManager + self.deviceManager?.delegate = self + return promise(.success(())) + }) + } + } + + private func setupCallAgent() -> Future { + Future { promise in + guard self.callAgent == nil else { + self.logger.debug( "Reusing call agent") + return promise(.success(())) + } + let options = CallAgentOptions() + if let displayName = self.callConfiguration.displayName { + options.displayName = displayName + } + + self.callClient?.createCallAgent(userCredential: self.callConfiguration.communicationTokenCredential, + options: options) { [weak self] (agent, error) in + guard let self = self else { + return promise(.failure(CompositeError.invalidSDKWrapper)) + } + + if let error = error { + self.logger.error( "It was not possible to create a call agent.") + return promise(.failure(error)) + } + + self.logger.debug("Call agent successfully created.") + self.callAgent = agent + return promise(.success(())) + } + } + } + + private func makeCallClient() -> CallClient { + let clientOptions = CallClientOptions() + let appendingTag = self.callConfiguration.diagnosticConfig.tags + let diagnostics = clientOptions.diagnostics ?? DiagnosticOptions() + diagnostics.tags.append(contentsOf: appendingTag) + clientOptions.diagnostics = diagnostics + return CallClient(options: clientOptions) + } + + private func startCallVideoStream(_ videoStream: LocalVideoStream) -> Future { + Future { promise in + let localVideoStreamId = self.getLocalVideoStreamIdentifier() ?? "" + guard let call = self.call else { + let error = CompositeError.invalidLocalVideoStream + self.logger.error( "Start call video stream failed") + return promise(.failure(error)) + } + call.startVideo(stream: videoStream) { error in + if let error = error { + self.logger.error( "Local video failed to start. \(error)") + return promise(.failure(error)) + } + self.logger.debug("Local video started successfully") + return promise(.success(localVideoStreamId)) + } + } + } + + private func change(_ videoStream: LocalVideoStream, source: VideoDeviceInfo) -> Future { + Future { promise in + DispatchQueue.main.async { + videoStream.switchSource(camera: source) { [weak self] (error) in + if error != nil { + self?.logger.error( "Local video failed to switch camera. \(error!)") + promise(.failure(error!)) + return + } + self?.logger.debug("Local video switched camera successfully") + promise(.success(())) + } + } + } + } + + private func setupCallRecordingAndTranscriptionFeature() { + guard let call = call else { + return + } + let recordingCallFeature = call.feature(Features.recording) + let transcriptionCallFeature = call.feature(Features.transcription) + self.callingEventsHandler.assign(recordingCallFeature) + self.callingEventsHandler.assign(transcriptionCallFeature) + } + + private func getLocalVideoStreamIdentifier() -> String? { + guard localVideoStream != nil else { + return nil + } + return "builtinCameraVideoStream" + } +} + +extension ACSCallingSDKWrapper: DeviceManagerDelegate { + func deviceManager(_ deviceManager: DeviceManager, didUpdateCameras args: VideoDevicesUpdatedEventArgs) { + for newDevice in args.addedVideoDevices { + newVideoDeviceAddedHandler?(newDevice) + } + } + + private func getVideoDeviceInfo(_ cameraFacing: CameraFacing) -> Future { + Future { promise in + if let camera = self.deviceManager?.cameras.first(where: { $0.cameraFacing == cameraFacing }) { + self.newVideoDeviceAddedHandler = nil + return promise(.success(camera)) + } + self.newVideoDeviceAddedHandler = { deviceInfo in + if deviceInfo.cameraFacing == cameraFacing { + return promise(.success(deviceInfo)) + } + } + } + } + + private func getValidLocalVideoStream() -> AnyPublisher { + if let localVideoStream = self.localVideoStream { + return Future { promise in + promise(.success(localVideoStream)) + }.eraseToAnyPublisher() + } + + return getVideoDeviceInfo(.front) + .map({[weak self] videoDeviceInfo in + let videoStream = LocalVideoStream(camera: videoDeviceInfo) + self?.localVideoStream = videoStream + return videoStream + }).eraseToAnyPublisher() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingService.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingService.swift new file mode 100644 index 000000000..ffff77ebb --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/CallingService.swift @@ -0,0 +1,90 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine + +protocol CallingService { + var participantsInfoListSubject: CurrentValueSubject<[ParticipantInfoModel], Never> { get } + var callInfoSubject: PassthroughSubject { get } + var isRecordingActiveSubject: PassthroughSubject { get } + var isTranscriptionActiveSubject: PassthroughSubject { get } + var isLocalUserMutedSubject: PassthroughSubject { get } + + func setupCall() -> AnyPublisher + func startCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> AnyPublisher + func endCall() -> AnyPublisher + + func requestCameraPreviewOn() -> AnyPublisher + + func startLocalVideoStream() -> AnyPublisher + func stopLocalVideoStream() -> AnyPublisher + func switchCamera() -> AnyPublisher + + func muteLocalMic() -> AnyPublisher + func unmuteLocalMic() -> AnyPublisher +} + +class ACSCallingService: NSObject, CallingService { + + private let logger: Logger + private let callingSDKWrapper: CallingSDKWrapper + + var isRecordingActiveSubject: PassthroughSubject + var isTranscriptionActiveSubject: PassthroughSubject + + var isLocalUserMutedSubject: PassthroughSubject + var participantsInfoListSubject: CurrentValueSubject<[ParticipantInfoModel], Never> + var callInfoSubject: PassthroughSubject + + init(logger: Logger, + callingSDKWrapper: CallingSDKWrapper ) { + self.logger = logger + self.callingSDKWrapper = callingSDKWrapper + isRecordingActiveSubject = callingSDKWrapper.callingEventsHandler.isRecordingActiveSubject + isTranscriptionActiveSubject = callingSDKWrapper.callingEventsHandler.isTranscriptionActiveSubject + isLocalUserMutedSubject = callingSDKWrapper.callingEventsHandler.isLocalUserMutedSubject + participantsInfoListSubject = callingSDKWrapper.callingEventsHandler.participantsInfoListSubject + callInfoSubject = callingSDKWrapper.callingEventsHandler.callInfoSubject + } + + func setupCall() -> AnyPublisher { + return callingSDKWrapper.setupCall() + } + + func startCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> AnyPublisher { + return callingSDKWrapper.startCall( + isCameraPreferred: isCameraPreferred, + isAudioPreferred: isAudioPreferred) + } + + func endCall() -> AnyPublisher { + return callingSDKWrapper.endCall() + } + + func requestCameraPreviewOn() -> AnyPublisher { + return callingSDKWrapper.startPreviewVideoStream() + } + + func startLocalVideoStream() -> AnyPublisher { + return callingSDKWrapper.startCallLocalVideoStream() + } + + func stopLocalVideoStream() -> AnyPublisher { + return callingSDKWrapper.stopLocalVideoStream() + } + + func switchCamera() -> AnyPublisher { + return callingSDKWrapper.switchCamera() + } + + func muteLocalMic() -> AnyPublisher { + return callingSDKWrapper.muteLocalMic() + } + + func unmuteLocalMic() -> AnyPublisher { + return callingSDKWrapper.unmuteLocalMic() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCallingStateExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCallingStateExtension.swift new file mode 100644 index 000000000..e59337252 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCallingStateExtension.swift @@ -0,0 +1,36 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling + +extension CallState { + func toCallingStatus() -> CallingStatus { + switch self { + case .none: + return .none + case .earlyMedia: + return .earlyMedia + case .connecting: + return .connecting + case .ringing: + return .ringing + case .connected: + return .connected + case .localHold: + return .localHold + case .disconnecting: + return .disconnecting + case .disconnected: + return .disconnected + case .inLobby: + return .inLobby + case .remoteHold: + return .remoteHold + default: + return .none + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCameraFacingExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCameraFacingExtension.swift new file mode 100644 index 000000000..dd21cb9e3 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/ACSCameraFacingExtension.swift @@ -0,0 +1,20 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling + +extension CameraFacing { + func toCameraDevice() -> CameraDevice { + switch self { + case .front: + return .front + case .back: + return .back + default: + return .front + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/CommunicationIdentifierExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/CommunicationIdentifierExtension.swift new file mode 100644 index 000000000..a27801c78 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/CommunicationIdentifierExtension.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import AzureCommunicationCalling + +extension CommunicationIdentifier { + var stringValue: String? { + switch self { + case is CommunicationUserIdentifier: + return (self as? CommunicationUserIdentifier)?.identifier + case is UnknownIdentifier: + return (self as? UnknownIdentifier)?.identifier + case is PhoneNumberIdentifier: + return (self as? PhoneNumberIdentifier)?.phoneNumber + case is MicrosoftTeamsUserIdentifier: + return (self as? MicrosoftTeamsUserIdentifier)?.userId + default: + return nil + } + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/RemoteParticipantExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/RemoteParticipantExtension.swift new file mode 100644 index 000000000..25bb77639 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/Extension/RemoteParticipantExtension.swift @@ -0,0 +1,42 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling + +extension RemoteParticipant { + func toParticipantInfoModel(recentSpeakingStamp: Date) -> ParticipantInfoModel { + let videoInfoModels: [VideoStreamInfoModel] = self.videoStreams.compactMap { videoStream in + VideoStreamInfoModel( + videoStreamIdentifier: String(videoStream.id), + mediaStreamType: videoStream.mediaStreamType.converted()) + } + + let cameraVideoStreamModel = videoInfoModels.first(where: {$0.mediaStreamType == .cameraVideo}) + let screenShareVideoStreamModel = videoInfoModels.first(where: {$0.mediaStreamType == .screenSharing}) + + return ParticipantInfoModel(displayName: displayName, + isSpeaking: isSpeaking, + isMuted: isMuted, + isRemoteUser: true, + userIdentifier: self.identifier.stringValue ?? "", + recentSpeakingStamp: recentSpeakingStamp, + screenShareVideoStreamModel: screenShareVideoStreamModel, + cameraVideoStreamModel: cameraVideoStreamModel) + } +} + +extension MediaStreamType { + func converted() -> VideoStreamInfoModel.MediaStreamType { + switch self { + case .screenSharing: + return VideoStreamInfoModel.MediaStreamType.screenSharing + case .video: + return VideoStreamInfoModel.MediaStreamType.cameraVideo + @unknown default: + return VideoStreamInfoModel.MediaStreamType.cameraVideo + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/RemoteParticipantsEventsAdapter.swift b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/RemoteParticipantsEventsAdapter.swift new file mode 100644 index 000000000..45dd0c099 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Service/Calling/RemoteParticipantsEventsAdapter.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import AzureCommunicationCalling + +class RemoteParticipantsEventsAdapter: NSObject, RemoteParticipantDelegate { + var onVideoStreamsUpdated: ((RemoteParticipant) -> Void) = {_ in } + var onIsSpeakingChanged: ((RemoteParticipant) -> Void) = {_ in } + var onIsMutedChanged: ((RemoteParticipant) -> Void) = {_ in } + + func remoteParticipant(_ remoteParticipant: RemoteParticipant, + didUpdateVideoStreams args: RemoteVideoStreamsEventArgs) { + onVideoStreamsUpdated(remoteParticipant) + } + + func remoteParticipant(_ remoteParticipant: RemoteParticipant, + didChangeSpeakingState args: PropertyChangedEventArgs) { + onIsSpeakingChanged(remoteParticipant) + } + + func remoteParticipant(_ remoteParticipant: RemoteParticipant, + didChangeMuteState args: PropertyChangedEventArgs) { + onIsMutedChanged(remoteParticipant) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/ArrayExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/ArrayExtension.swift new file mode 100644 index 000000000..ad4b66d03 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/ArrayExtension.swift @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +extension Array { + func chunkedAndReversed(into size: Int) -> [[Element]] { + var chunkedArray = [[Element]]() + guard self.count > 0 else { + return chunkedArray + } + + for index in 0...self.count { + if index % size == 0, index != 0 { + chunkedArray.append(Array(self[(index - size)..() + + func cancel() { + subscriptions.removeAll() + } + + func collect(@Builder _ cancellables: () -> [AnyCancellable]) { + subscriptions.formUnion(cancellables()) + } + + @resultBuilder + struct Builder { + static func buildBlock(_ cancellables: AnyCancellable...) -> [AnyCancellable] { + return cancellables + } + } +} + +extension AnyCancellable { + + func store(in cancelBag: CancelBag) { + cancelBag.subscriptions.insert(self) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/ColorExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/ColorExtension.swift new file mode 100644 index 000000000..4c9de71dd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/ColorExtension.swift @@ -0,0 +1,51 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit + +enum CompositeColor: String { + case primary = "ACSPrimaryColor" + case background = "backgroundColor" + case gridLayoutBackground = "gridLayoutBackgroundColor" + case disabled = "disabledColor" + case error = "errorColor" + case onBackground = "onBackgroundColor" + case onDisabled = "onDisabledColor" + case onError = "onErrorColor" + case onPrimary = "onPrimaryColor" + case onSuccess = "onSuccessColor" + case onSurface = "onSurfaceColor" + case mute = "mute" + case onWarning = "onWarningColor" + case success = "successColor" + case warning = "warningColor" + case surface = "surfaceColor" + case surfaceDarkColor = "surfaceDarkColor" + case surfaceLightColor = "surfaceLightColor" + case drawerColor = "drawerColor" + case popoverColor = "popoverColor" + case gradientColor = "gradientColor" + case hangup = "hangupColor" + case overlay = "overlayColor" +} + +extension UIColor { + static func compositeColor(_ name: CompositeColor) -> UIColor { + + return getAssetsColor(named: name.rawValue) + + } + + private static func getAssetsColor(named: String) -> UIColor { + if let assetsColor = UIColor(named: "Color/" + named, + in: Bundle(for: CallComposite.self), + compatibleWith: nil) { + return assetsColor + } else { + preconditionFailure("invalid asset color") + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/CompsiteError.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/CompsiteError.swift new file mode 100644 index 000000000..7c3153084 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/CompsiteError.swift @@ -0,0 +1,14 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +enum CompositeError: String, LocalizedError, Equatable { + + case invalidSDKWrapper = "InvalidSDKWrapper" + case invalidLocalVideoStream = "InvalidLocalVideoStream" + + var localizedDescription: String { return NSLocalizedString(self.rawValue, comment: "") } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/DeviceExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/DeviceExtension.swift new file mode 100644 index 000000000..19a4f07ba --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/DeviceExtension.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit + +extension UIDevice { + + /// Returns `true` if the device has a home bar in landscape + var hasHomeBar: Bool { + guard #available(iOS 11.0, *), let window = + UIApplication.shared.windows.filter({$0.isKeyWindow}).first + else { + return false + } + + return window.safeAreaInsets.bottom > 0 + } + + func toggleProximityMonitoringStatus(isEnabled: Bool) { + UIDevice.current.isProximityMonitoringEnabled = isEnabled + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/MappedSequence.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/MappedSequence.swift new file mode 100644 index 000000000..99dfbb446 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/MappedSequence.swift @@ -0,0 +1,134 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +class MappedSequence: Sequence { + private class Node { + var key: S + var value: T + weak var prev: Node? + weak var next: Node? + + init(key: S, value: T) { + self.key = key + self.value = value + } + } + + var count: Int { + return keyNodeMap.count + } + + private var keyNodeMap: [S: Node] = [:] + private var first: Node? + private var last: Node? + + func makeIterator() -> AnyIterator { + var current: Node? = first + return AnyIterator { () -> T? in + let value = current?.value + current = current?.next + return value + } + } + + func makeKeyIterator() -> AnyIterator { + var current: Node? = first + return AnyIterator { () -> S? in + let value = current?.key + current = current?.next + return value + } + } + + func prepend(forKey: S, value: T) { + if keyNodeMap[forKey] != nil { + return + } + + let node = Node(key: forKey, value: value) + keyNodeMap[forKey] = node + + if first == nil { + first = node + last = node + } else { + first?.prev = node + node.next = first + first = node + } + } + + func append(forKey: S, value: T) { + if keyNodeMap[forKey] != nil { + return + } + + let node = Node(key: forKey, value: value) + keyNodeMap[forKey] = node + + if first == nil { + first = node + last = node + } else { + last?.next = node + node.prev = last + last = node + } + } + + func toArray() -> [T] { + var array = [T]() + self.forEach {array.append($0)} + return array + } + + @discardableResult func removeValue(forKey: S) -> T? { + var value: T? + + if let nodeToRemove = keyNodeMap[forKey] { + + if keyNodeMap.count == 1 { + first = nil + last = nil + } else if nodeToRemove === first { + first = nodeToRemove.next + first?.prev = nil + } else if nodeToRemove === last { + last = nodeToRemove.prev + last?.next = nil + } else { + nodeToRemove.prev?.next = nodeToRemove.next + nodeToRemove.next?.prev = nodeToRemove.prev + } + + value = nodeToRemove.value + + nodeToRemove.prev = nil + nodeToRemove.next = nil + keyNodeMap.removeValue(forKey: forKey) + } + + return value + } + + @discardableResult func removeLast() -> T? { + var value: T? + + if let lastKey = last?.key { + value = self.removeValue(forKey: lastKey) + } + + return value + } + + func value(forKey: S) -> T? { + if let node = keyNodeMap[forKey] { + return node.value + } + + return nil + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/UIViewControllerExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/UIViewControllerExtension.swift new file mode 100644 index 000000000..bd2766892 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/UIViewControllerExtension.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit + +extension UIViewController { + + func dismissSelf(completion: (() -> Void)? = nil) { + if let presentingVc = presentingViewController { + view.endEditing(true) + presentingVc.dismiss(animated: true, completion: completion) + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUI/Utilities/UIWindowExtension.swift b/AzureCommunicationUI/AzureCommunicationUI/Utilities/UIWindowExtension.swift new file mode 100644 index 000000000..0ff8cd2de --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUI/Utilities/UIWindowExtension.swift @@ -0,0 +1,57 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +extension UIWindow { + + static var keyWindow: UIWindow? { + return UIApplication.shared.windows.filter {$0.isKeyWindow}.first + } + + var topViewController: UIViewController? { + if var topViewController = self.rootViewController { + while let presentedViewController = topViewController.presentedViewController { + topViewController = presentedViewController + } + + return topViewController + } else { + return nil + } + } + + func hasViewController(ofKind kind: AnyClass) -> Bool { + if let rootViewController = self.rootViewController { + return UIWindow.hasViewController(ofKind: kind, fromViewController: rootViewController) + } else { + return false + } + } + + static func hasViewController(ofKind kind: AnyClass, + fromViewController viewController: UIViewController) -> Bool { + guard !viewController.isKind(of: kind) else { + return true + } + + var hasViewController = false + if !viewController.children.isEmpty { + viewController.children.forEach { + if UIWindow.hasViewController(ofKind: kind, fromViewController: $0) { + hasViewController = true + } + } + } + + if let presented = viewController.presentedViewController, + UIWindow.hasViewController(ofKind: kind, fromViewController: presented) { + hasViewController = true + } + + return hasViewController + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.pbxproj b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..03b497c60 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.pbxproj @@ -0,0 +1,502 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 3507F9550C99D777E493AA92 /* Pods_AzureCommunicationUIDemoApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 747FFD0380FFBF53EC331032 /* Pods_AzureCommunicationUIDemoApp.framework */; }; + 9885A0E0274C6F470015E37F /* AzureCommunicationUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9885A0DF274C6F470015E37F /* AzureCommunicationUI.framework */; }; + 9885A0E1274C6F470015E37F /* AzureCommunicationUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9885A0DF274C6F470015E37F /* AzureCommunicationUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + A85DDCB12728B12D001297B4 /* EnvConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85DDCAF2728B12D001297B4 /* EnvConfig.swift */; }; + A85DDCB32728B140001297B4 /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85DDCB22728B140001297B4 /* ThemeConfig.swift */; }; + A85DDCB82728B23B001297B4 /* UIKitDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85DDCB42728B23A001297B4 /* UIKitDemoViewController.swift */; }; + A85DDCB92728B23B001297B4 /* SwiftUIDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85DDCB52728B23B001297B4 /* SwiftUIDemoView.swift */; }; + A87CEABC2729ECA600F3AE16 /* EntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A87CEABB2729ECA600F3AE16 /* EntryViewController.swift */; }; + A8849E532732FFA10049FB1A /* AuthenticationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8849E522732FFA10049FB1A /* AuthenticationHelper.swift */; }; + A8AC66FE2735A41600632D0B /* CustomControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8AC66FD2735A41600632D0B /* CustomControls.swift */; }; + A8C69FB42728AE1A00143DB7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C69FB32728AE1A00143DB7 /* AppDelegate.swift */; }; + A8C69FB62728AE1A00143DB7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C69FB52728AE1A00143DB7 /* SceneDelegate.swift */; }; + A8C69FBB2728AE1A00143DB7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A8C69FB92728AE1A00143DB7 /* Main.storyboard */; }; + A8C69FBD2728AE1C00143DB7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A8C69FBC2728AE1C00143DB7 /* Assets.xcassets */; }; + A8C69FC02728AE1C00143DB7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A8C69FBE2728AE1C00143DB7 /* LaunchScreen.storyboard */; }; + A8FB819C272A082B00AA0930 /* DemoInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8FB819B272A082B00AA0930 /* DemoInputTypes.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + A8EE2C0D2728AF3500DF7362 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9885A0E1274C6F470015E37F /* AzureCommunicationUI.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0883E4B2C59B676B35210362 /* Pods-AzureCommunicationUIDemoApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AzureCommunicationUIDemoApp.debug.xcconfig"; path = "Target Support Files/Pods-AzureCommunicationUIDemoApp/Pods-AzureCommunicationUIDemoApp.debug.xcconfig"; sourceTree = ""; }; + 1FEC635827588B5600EA746B /* AppConfig.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = AppConfig.xcconfig; sourceTree = ""; }; + 747FFD0380FFBF53EC331032 /* Pods_AzureCommunicationUIDemoApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AzureCommunicationUIDemoApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9885A0DF274C6F470015E37F /* AzureCommunicationUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AzureCommunicationUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A85DDCAF2728B12D001297B4 /* EnvConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvConfig.swift; sourceTree = ""; }; + A85DDCB22728B140001297B4 /* ThemeConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; + A85DDCB42728B23A001297B4 /* UIKitDemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitDemoViewController.swift; sourceTree = ""; }; + A85DDCB52728B23B001297B4 /* SwiftUIDemoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIDemoView.swift; sourceTree = ""; }; + A87CEABB2729ECA600F3AE16 /* EntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryViewController.swift; sourceTree = ""; }; + A8849E522732FFA10049FB1A /* AuthenticationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationHelper.swift; sourceTree = ""; }; + A8AC66FD2735A41600632D0B /* CustomControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomControls.swift; sourceTree = ""; }; + A8C69FB02728AE1A00143DB7 /* AzureCommunicationUIDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AzureCommunicationUIDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A8C69FB32728AE1A00143DB7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A8C69FB52728AE1A00143DB7 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + A8C69FBA2728AE1A00143DB7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + A8C69FBC2728AE1C00143DB7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A8C69FBF2728AE1C00143DB7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + A8C69FC12728AE1C00143DB7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A8FB819B272A082B00AA0930 /* DemoInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoInputTypes.swift; sourceTree = ""; }; + C8502DED9B8B08581B1F2DE1 /* Pods-AzureCommunicationUIDemoApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AzureCommunicationUIDemoApp.release.xcconfig"; path = "Target Support Files/Pods-AzureCommunicationUIDemoApp/Pods-AzureCommunicationUIDemoApp.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A8C69FAD2728AE1A00143DB7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9885A0E0274C6F470015E37F /* AzureCommunicationUI.framework in Frameworks */, + 3507F9550C99D777E493AA92 /* Pods_AzureCommunicationUIDemoApp.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 43E518C93ADEBB986A8388BE /* Pods */ = { + isa = PBXGroup; + children = ( + 0883E4B2C59B676B35210362 /* Pods-AzureCommunicationUIDemoApp.debug.xcconfig */, + C8502DED9B8B08581B1F2DE1 /* Pods-AzureCommunicationUIDemoApp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + A8C69FA72728AE1A00143DB7 = { + isa = PBXGroup; + children = ( + A8C69FB22728AE1A00143DB7 /* AzureCommunicationUIDemoApp */, + A8C69FB12728AE1A00143DB7 /* Products */, + 43E518C93ADEBB986A8388BE /* Pods */, + F74ACD1C99B1428891E02585 /* Frameworks */, + ); + sourceTree = ""; + }; + A8C69FB12728AE1A00143DB7 /* Products */ = { + isa = PBXGroup; + children = ( + A8C69FB02728AE1A00143DB7 /* AzureCommunicationUIDemoApp.app */, + ); + name = Products; + sourceTree = ""; + }; + A8C69FB22728AE1A00143DB7 /* AzureCommunicationUIDemoApp */ = { + isa = PBXGroup; + children = ( + A8FB819D272A0B3500AA0930 /* Views */, + A8849E522732FFA10049FB1A /* AuthenticationHelper.swift */, + A8FB819B272A082B00AA0930 /* DemoInputTypes.swift */, + A85DDCB22728B140001297B4 /* ThemeConfig.swift */, + A85DDCAF2728B12D001297B4 /* EnvConfig.swift */, + 1FEC635827588B5600EA746B /* AppConfig.xcconfig */, + A8C69FB32728AE1A00143DB7 /* AppDelegate.swift */, + A8C69FB52728AE1A00143DB7 /* SceneDelegate.swift */, + A8C69FC12728AE1C00143DB7 /* Info.plist */, + A8C69FBC2728AE1C00143DB7 /* Assets.xcassets */, + ); + path = AzureCommunicationUIDemoApp; + sourceTree = ""; + }; + A8FB819D272A0B3500AA0930 /* Views */ = { + isa = PBXGroup; + children = ( + A87CEABB2729ECA600F3AE16 /* EntryViewController.swift */, + A85DDCB52728B23B001297B4 /* SwiftUIDemoView.swift */, + A85DDCB42728B23A001297B4 /* UIKitDemoViewController.swift */, + A8AC66FD2735A41600632D0B /* CustomControls.swift */, + A8C69FB92728AE1A00143DB7 /* Main.storyboard */, + A8C69FBE2728AE1C00143DB7 /* LaunchScreen.storyboard */, + ); + path = Views; + sourceTree = ""; + }; + F74ACD1C99B1428891E02585 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9885A0DF274C6F470015E37F /* AzureCommunicationUI.framework */, + 747FFD0380FFBF53EC331032 /* Pods_AzureCommunicationUIDemoApp.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A8C69FAF2728AE1A00143DB7 /* AzureCommunicationUIDemoApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A8C69FC42728AE1C00143DB7 /* Build configuration list for PBXNativeTarget "AzureCommunicationUIDemoApp" */; + buildPhases = ( + 21973AE741F3B55B42A2E0CE /* [CP] Check Pods Manifest.lock */, + A8C69FAC2728AE1A00143DB7 /* Sources */, + A8C69FAD2728AE1A00143DB7 /* Frameworks */, + A8C69FAE2728AE1A00143DB7 /* Resources */, + 4CA6E42B5D6299A63290DA44 /* [CP] Embed Pods Frameworks */, + A8EE2C0D2728AF3500DF7362 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AzureCommunicationUIDemoApp; + productName = AzureCommunicationUIDemoApp; + productReference = A8C69FB02728AE1A00143DB7 /* AzureCommunicationUIDemoApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A8C69FA82728AE1A00143DB7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1310; + LastUpgradeCheck = 1310; + TargetAttributes = { + A8C69FAF2728AE1A00143DB7 = { + CreatedOnToolsVersion = 13.1; + }; + }; + }; + buildConfigurationList = A8C69FAB2728AE1A00143DB7 /* Build configuration list for PBXProject "AzureCommunicationUIDemoApp" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A8C69FA72728AE1A00143DB7; + productRefGroup = A8C69FB12728AE1A00143DB7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A8C69FAF2728AE1A00143DB7 /* AzureCommunicationUIDemoApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A8C69FAE2728AE1A00143DB7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8C69FC02728AE1C00143DB7 /* LaunchScreen.storyboard in Resources */, + A8C69FBD2728AE1C00143DB7 /* Assets.xcassets in Resources */, + A8C69FBB2728AE1A00143DB7 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 21973AE741F3B55B42A2E0CE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-AzureCommunicationUIDemoApp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 4CA6E42B5D6299A63290DA44 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-AzureCommunicationUIDemoApp/Pods-AzureCommunicationUIDemoApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-AzureCommunicationUIDemoApp/Pods-AzureCommunicationUIDemoApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AzureCommunicationUIDemoApp/Pods-AzureCommunicationUIDemoApp-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A8C69FAC2728AE1A00143DB7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8C69FB42728AE1A00143DB7 /* AppDelegate.swift in Sources */, + A8FB819C272A082B00AA0930 /* DemoInputTypes.swift in Sources */, + A8AC66FE2735A41600632D0B /* CustomControls.swift in Sources */, + A85DDCB92728B23B001297B4 /* SwiftUIDemoView.swift in Sources */, + A85DDCB12728B12D001297B4 /* EnvConfig.swift in Sources */, + A85DDCB32728B140001297B4 /* ThemeConfig.swift in Sources */, + A85DDCB82728B23B001297B4 /* UIKitDemoViewController.swift in Sources */, + A8C69FB62728AE1A00143DB7 /* SceneDelegate.swift in Sources */, + A87CEABC2729ECA600F3AE16 /* EntryViewController.swift in Sources */, + A8849E532732FFA10049FB1A /* AuthenticationHelper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + A8C69FB92728AE1A00143DB7 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + A8C69FBA2728AE1A00143DB7 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + A8C69FBE2728AE1C00143DB7 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + A8C69FBF2728AE1C00143DB7 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + A8C69FC22728AE1C00143DB7 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1FEC635827588B5600EA746B /* AppConfig.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A8C69FC32728AE1C00143DB7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_BITCODE = NO; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A8C69FC52728AE1C00143DB7 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0883E4B2C59B676B35210362 /* Pods-AzureCommunicationUIDemoApp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UBF8T346G9; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AzureCommunicationUIDemoApp/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "This is required for calling."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "This is required for calling."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.azure.ios.communication.ui.meetings.AzureCommunicationUIDemoApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A8C69FC62728AE1C00143DB7 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C8502DED9B8B08581B1F2DE1 /* Pods-AzureCommunicationUIDemoApp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UBF8T346G9; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AzureCommunicationUIDemoApp/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "This is required for calling."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "This is required for calling."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.azure.ios.communication.ui.meetings.AzureCommunicationUIDemoApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A8C69FAB2728AE1A00143DB7 /* Build configuration list for PBXProject "AzureCommunicationUIDemoApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8C69FC22728AE1C00143DB7 /* Debug */, + A8C69FC32728AE1C00143DB7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A8C69FC42728AE1C00143DB7 /* Build configuration list for PBXNativeTarget "AzureCommunicationUIDemoApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8C69FC52728AE1C00143DB7 /* Debug */, + A8C69FC62728AE1C00143DB7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A8C69FA82728AE1A00143DB7 /* Project object */; +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUIDemoApp.xcscheme b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUIDemoApp.xcscheme new file mode 100644 index 000000000..a7a97e278 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp.xcodeproj/xcshareddata/xcschemes/AzureCommunicationUIDemoApp.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/AppConfig.xcconfig b/AzureCommunicationUI/AzureCommunicationUIDemoApp/AppConfig.xcconfig new file mode 100644 index 000000000..f0367cfc4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/AppConfig.xcconfig @@ -0,0 +1,7 @@ +// +//  Copyright (c) Microsoft Corporation. All rights reserved. +//  Licensed under the MIT License. +// +#include? "EnvConfig.xcconfig" + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/AppDelegate.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/AppDelegate.swift new file mode 100644 index 000000000..e2efce84d --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/AppDelegate.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AccentColor.colorset/Contents.json b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..9221b9bb1 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/Contents.json b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/AuthenticationHelper.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/AuthenticationHelper.swift new file mode 100644 index 000000000..a99eb5d28 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/AuthenticationHelper.swift @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling + +class AuthenticationHelper { + static func getCommunicationToken(tokenUrl: URL) -> TokenRefresher { + struct TokenResponse: Decodable { + let token: String + } + return { completionHandler in + var urlRequest = URLRequest(url: tokenUrl, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10) + urlRequest.httpMethod = "GET" + URLSession.shared.dataTask(with: urlRequest) { (data, _, error) in + if let error = error { + print(error) + } else if let data = data { + do { + let res = try JSONDecoder().decode(TokenResponse.self, from: data) + print(res.token) + completionHandler(res.token, nil) + } catch let error { + print(error) + } + } + }.resume() + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/DemoInputTypes.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/DemoInputTypes.swift new file mode 100644 index 000000000..aec931ba4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/DemoInputTypes.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationUI + +enum MeetingType: Int { + case groupCall + case teamsMeeting +} + +enum ACSTokenType: Int { + case tokenUrl + case token +} + +enum DemoError: Error { + case invalidToken + + func getErrorCode() -> String { + switch self { + case .invalidToken: + return CallCompositeErrorCode.tokenExpired + } + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/EnvConfig.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/EnvConfig.swift new file mode 100644 index 000000000..e9d3290bc --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/EnvConfig.swift @@ -0,0 +1,23 @@ +// ---------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// ---------------------------------------------------------------- + +import Foundation + +enum EnvConfig: String { + case acsToken + case acsTokenUrl + case displayName + case groupCallId + case teamsMeetingLink + + func value() -> String { + guard let infoDict = Bundle.main.infoDictionary, + let value = infoDict[self.rawValue] as? String else { + return "" + } + return value + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Info.plist b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Info.plist new file mode 100644 index 000000000..1c3f39d07 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Info.plist @@ -0,0 +1,43 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIBackgroundModes + + audio + + NSMicrophoneUsageDescription + This is required for calling. + NSCameraUsageDescription + This is required for calling. + acsToken + ${acsToken} + acsTokenUrl + ${acsTokenUrl} + displayName + ${displayName} + groupCallId + ${groupCallId} + teamsMeetingLink + ${teamsMeetingLink} + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/README.md b/AzureCommunicationUI/AzureCommunicationUIDemoApp/README.md new file mode 100644 index 000000000..aa0db9a49 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/README.md @@ -0,0 +1,38 @@ +# UI Mobile Library Demo App + +The sample app is a native iOS application developed using both SwiftUI and UIKit frameworks. It uses the Azure Communication UI library to empower the user experience. + +## Getting Started + +### Prerequisites + +- An Azure account with an active subscription. [Create an account for free](https://azure.microsoft.com/free/?WT.mc_id=A261C142F). +- A Mac running [Xcode](https://go.microsoft.com/fwLink/p/?LinkID=266532), along with a valid developer certificate installed into your Keychain. [CocoaPods](https://cocoapods.org/) must also be installed to fetch dependencies. +- A deployed Communication Services resource. Create a [Communication Services resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource). +- Azure Communication Services Token. [See example](https://docs.microsoft.com/en-us/azure/communication-services/quickstarts/identity/quick-create-identity) +- (Optional) Create Azure Communication Services Token service URL. [See example](https://docs.microsoft.com/azure/communication-services/tutorials/trusted-service-tutorial). + +### Before running the sample for the first time + +1. After cloning the [Repo](https://github.com/Azure/azure-communication-ui-library-ios) in your local environment, `cd` to the `AzureCommunicationUI` folder in the root of the project directory. +2. Run `pod install`, this generates a `.xcworkspace` file. +3. (Optional) `cd` to the `AzureCommunicationUIDemoApp` folder inside the project directory +4. (Optional) Run `touch EnvConfig.xcconfig` via the Command Line. +5. (Optional) Add constants from following list to `EnvConfig.xcconfig` as the sample app's local configurations. + - `acsToken`: a generated Azure Communication Services token + - `acsTokenUrl`: the URL to request Azure Communication Services token + - `displayName`: your preferred display name + - `groupCallId`: this a type of UUID used to start and join a meeting + - `teamsMeetingLink`: the URL to a Teams meeting + + ![EnvConfig](/docs/images/EnvConfig.png) + + Note: The `EnvConfig.xcconfig` file is created strictly for developers convenience. Those configurations can be input once the `AzureCommunicationUIDemoApp` is running. + +### Run Sample + +1. Open `AzureCommunicationUI.xcworkspace` file generated in the above step. +2. Select `AzureCommunicationUIDemoApp` scheme and target at any iOS simulator. +3. Hit `Run` or `⌘+R` to start running. + + ![SelectSimulator](/docs/images/SelectSimulator.png) diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/SceneDelegate.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/SceneDelegate.swift new file mode 100644 index 000000000..4c4798148 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/SceneDelegate.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/ThemeConfig.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/ThemeConfig.swift new file mode 100644 index 000000000..88c5e1b00 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/ThemeConfig.swift @@ -0,0 +1,19 @@ +// ---------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// ---------------------------------------------------------------- + +import UIKit +import AzureCommunicationUI + +struct TeamsBrandConfig: ThemeConfiguration { + func primaryColor() -> UIColor? { + return UIColor(named: "TeamsColor") + } +} + +struct Theming: ThemeConfiguration { +// var primaryColor: UIColor { +// return UIColor.red +// } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/LaunchScreen.storyboard b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..7872ae92f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/Main.storyboard b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/Main.storyboard new file mode 100644 index 000000000..6f5bedd56 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/Base.lproj/Main.storyboard @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/CustomControls.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/CustomControls.swift new file mode 100644 index 000000000..d5fc8ec45 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/CustomControls.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI + +struct DemoButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 20) + .padding(.vertical, 10) + .foregroundColor(isEnabled ? .white : .disabledWhite) + .background(isEnabled ? Color.blue : Color.disabledBlue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +extension Color { + static let disabledBlue = Color(red: 188 / 255.0, green: 224 / 255.0, blue: 253 / 255.0) + static let disabledWhite = Color(white: 1, opacity: 179 / 255.0) +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/EntryViewController.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/EntryViewController.swift new file mode 100644 index 000000000..67f9b8c55 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/EntryViewController.swift @@ -0,0 +1,82 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import UIKit +import SwiftUI +import CoreGraphics + +class EntryViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + private func setupUI() { + let margin: CGFloat = 32.0 + let margins = view.safeAreaLayoutGuide + + view.backgroundColor = .systemBackground + + let titleLabel = UILabel() + titleLabel.text = "UI Library Sample" + titleLabel.sizeToFit() + titleLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleLabel) + + let startSwiftUIButton = UIButton() + startSwiftUIButton.backgroundColor = .systemBlue + startSwiftUIButton.contentEdgeInsets = UIEdgeInsets.init(top: 10, left: 20, bottom: 10, right: 20) + startSwiftUIButton.layer.cornerRadius = 8 + startSwiftUIButton.setTitle("Swift UI", for: .normal) + startSwiftUIButton.sizeToFit() + startSwiftUIButton.translatesAutoresizingMaskIntoConstraints = false + startSwiftUIButton.addTarget(self, action: #selector(onSwiftUIPressed), for: .touchUpInside) + view.addSubview(startSwiftUIButton) + + let startUiKitButton = UIButton() + startUiKitButton.backgroundColor = .systemBlue + startUiKitButton.contentEdgeInsets = UIEdgeInsets.init(top: 10, left: 20, bottom: 10, right: 20) + startUiKitButton.layer.cornerRadius = 8 + startUiKitButton.setTitle("UI Kit", for: .normal) + startUiKitButton.sizeToFit() + startUiKitButton.translatesAutoresizingMaskIntoConstraints = false + startUiKitButton.addTarget(self, action: #selector(onUIKitPressed), for: .touchUpInside) + view.addSubview(startUiKitButton) + + let stackView = UIStackView(arrangedSubviews: [startSwiftUIButton, + startUiKitButton]) + stackView.spacing = margin + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stackView) + + let constraints = [ + titleLabel.centerXAnchor.constraint(equalTo: margins.centerXAnchor), + titleLabel.topAnchor.constraint(equalTo: margins.topAnchor, constant: margin), + + stackView.centerXAnchor.constraint(equalTo: margins.centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: margins.centerYAnchor), + stackView.leadingAnchor.constraint(equalTo: margins.leadingAnchor, constant: margin), + stackView.trailingAnchor.constraint(equalTo: margins.trailingAnchor, constant: -margin) + ] + NSLayoutConstraint.activate(constraints) + } + + @objc func onSwiftUIPressed() { + let swiftUIDemoViewHostingController = UIHostingController(rootView: SwiftUIDemoView()) + swiftUIDemoViewHostingController.modalPresentationStyle = .fullScreen + present(swiftUIDemoViewHostingController, animated: true, completion: nil) + } + + @objc func onUIKitPressed() { + let uiKitDemoViewController = UIKitDemoViewController() + uiKitDemoViewController.modalPresentationStyle = .fullScreen + present(uiKitDemoViewController, animated: true, completion: nil) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/SwiftUIDemoView.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/SwiftUIDemoView.swift new file mode 100644 index 000000000..90f438dbd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/SwiftUIDemoView.swift @@ -0,0 +1,209 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI +import AzureCommunicationUI +import AzureCommunicationCalling + +struct SwiftUIDemoView: View { + @State var acsToken: String = EnvConfig.acsToken.value() + @State var acsTokenUrl: String = EnvConfig.acsTokenUrl.value() + @State var displayName: String = EnvConfig.displayName.value() + @State var groupCallId: String = EnvConfig.groupCallId.value() + @State var teamsMeetingLink: String = EnvConfig.teamsMeetingLink.value() + @State var selectedAcsTokenType: ACSTokenType = .token + @State var selectedMeetingType: MeetingType = .groupCall + @State var isErrorDisplayed: Bool = false + @State var errorMessage: String = "" + + let verticalPadding: CGFloat = 5 + let horizontalPadding: CGFloat = 10 + + var body: some View { + VStack { + Text("UI Library - SwiftUI Sample") + Spacer() + acsTokenSelector + displayNameTextField + meetingSelector + startExperienceButton + Spacer() + } + .padding() + .alert(isPresented: $isErrorDisplayed) { + Alert( + title: Text("Error"), + message: Text(errorMessage), + dismissButton: .default(Text("Dismiss"))) + } + } + + var acsTokenSelector: some View { + Group { + Picker("Token Type", selection: $selectedAcsTokenType) { + Text("Token URL").tag(ACSTokenType.tokenUrl) + Text("Token").tag(ACSTokenType.token) + }.pickerStyle(.segmented) + switch selectedAcsTokenType { + case .tokenUrl: + TextField("ACS Token URL", text: $acsTokenUrl) + .disableAutocorrection(true) + .autocapitalization(.none) + .textFieldStyle(.roundedBorder) + case .token: + TextField("ACS Token", text: $acsToken) + .disableAutocorrection(true) + .autocapitalization(.none) + .textFieldStyle(.roundedBorder) + } + } + .padding(.vertical, verticalPadding) + .padding(.horizontal, horizontalPadding) + } + + var displayNameTextField: some View { + TextField("Display Name", text: $displayName) + .disableAutocorrection(true) + .padding(.vertical, verticalPadding) + .padding(.horizontal, horizontalPadding) + .textFieldStyle(.roundedBorder) + } + + var meetingSelector: some View { + Group { + Picker("Call Type", selection: $selectedMeetingType) { + Text("Group Call").tag(MeetingType.groupCall) + Text("Teams Meeting").tag(MeetingType.teamsMeeting) + }.pickerStyle(.segmented) + switch selectedMeetingType { + case .groupCall: + TextField( + "Group Call Id", + text: $groupCallId) + .autocapitalization(.none) + .disableAutocorrection(true) + .textFieldStyle(.roundedBorder) + case .teamsMeeting: + TextField( + "Team Meeting", + text: $teamsMeetingLink) + .autocapitalization(.none) + .disableAutocorrection(true) + .textFieldStyle(.roundedBorder) + } + } + .padding(.vertical, verticalPadding) + .padding(.horizontal, horizontalPadding) + } + + var startExperienceButton: some View { + Button("Start Experience") { + startCallComposite() + } + .buttonStyle(DemoButtonStyle()) + .disabled(isStartExperienceDisabled) + } + + var isStartExperienceDisabled: Bool { + if (selectedAcsTokenType == .token && acsToken.isEmpty) + || selectedAcsTokenType == .tokenUrl && acsTokenUrl.isEmpty { + return true + } + + if (selectedMeetingType == .groupCall && groupCallId.isEmpty) + || selectedMeetingType == .teamsMeeting && teamsMeetingLink.isEmpty { + return true + } + + return false + } +} + +struct SwiftUIDemoView_Previews: PreviewProvider { + static var previews: some View { + SwiftUIDemoView() + } +} + +extension SwiftUIDemoView { + func startCallComposite() { + let link = getMeetingLink() + + let callCompositeOptions = CallCompositeOptions(themeConfiguration: Theming()) + let callComposite = CallComposite(withOptions: callCompositeOptions) + callComposite.setTarget(didFail: didFail) + + if let communicationTokenCredential = try? getTokenCredential() { + switch selectedMeetingType { + case .groupCall: + let uuid = UUID(uuidString: link) ?? UUID() + if displayName.isEmpty { + callComposite.launch(with: GroupCallOptions(communicationTokenCredential: communicationTokenCredential, + groupId: uuid)) + } else { + callComposite.launch(with: GroupCallOptions(communicationTokenCredential: communicationTokenCredential, + groupId: uuid, + displayName: displayName)) + } + case .teamsMeeting: + if displayName.isEmpty { + callComposite.launch(with: TeamsMeetingOptions(communicationTokenCredential: communicationTokenCredential, + meetingLink: link)) + } else { + callComposite.launch(with: TeamsMeetingOptions(communicationTokenCredential: communicationTokenCredential, + meetingLink: link, + displayName: displayName)) + } + } + } else { + showError(for: DemoError.invalidToken.getErrorCode()) + return + } + } + + private func getTokenCredential() throws -> CommunicationTokenCredential { + switch selectedAcsTokenType { + case .token: + if let communicationTokenCredential = try? CommunicationTokenCredential(token: acsToken) { + return communicationTokenCredential + } else { + throw DemoError.invalidToken + } + case .tokenUrl: + if let url = URL(string: acsTokenUrl) { + let communicationTokenRefreshOptions = CommunicationTokenRefreshOptions(initialToken: nil, refreshProactively: true, tokenRefresher: AuthenticationHelper.getCommunicationToken(tokenUrl: url)) + if let communicationTokenCredential = try? CommunicationTokenCredential(withOptions: communicationTokenRefreshOptions) { + return communicationTokenCredential + } + } + throw DemoError.invalidToken + } + } + + private func getMeetingLink() -> String { + switch selectedMeetingType { + case .groupCall: + return groupCallId + case .teamsMeeting: + return teamsMeetingLink + } + } + + private func showError(for errorCode: String) { + switch errorCode { + case CallCompositeErrorCode.tokenExpired: + errorMessage = "Token is invalid" + default: + errorMessage = "Unknown error" + } + isErrorDisplayed = true + } + + func didFail(_ error: ErrorEvent) { + print("SwiftUIDemoView::getEventsHandler::didFail \(error)") + print("SwiftUIDemoView error.code \(error.code)") + showError(for: error.code) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/UIKitDemoViewController.swift b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/UIKitDemoViewController.swift new file mode 100644 index 000000000..1b70128d1 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUIDemoApp/Views/UIKitDemoViewController.swift @@ -0,0 +1,421 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit +import SwiftUI +import AzureCommunicationUI +import AzureCommunicationCalling + +class UIKitDemoViewController: UIViewController { + + struct Constants { + static let viewVerticalSpacing: CGFloat = 8.0 + static let stackViewInterItemSpacingPortrait: CGFloat = 18.0 + static let stackViewInterItemSpacingLandscape: CGFloat = 12.0 + static let buttonHorizontalInset: CGFloat = 20.0 + static let buttonVerticalInset: CGFloat = 10.0 + } + + private var selectedAcsTokenType: ACSTokenType = .token + private var acsTokenUrlTextField: UITextField! + private var acsTokenTextField: UITextField! + private var selectedMeetingType: MeetingType = .groupCall + private var displayNameTextField: UITextField! + private var groupCallTextField: UITextField! + private var teamsMeetingTextField: UITextField! + private var startExperienceButton: UIButton! + + private var stackView: UIStackView! + private var titleLabel: UILabel! + private var titleLabelConstraint: NSLayoutConstraint! + + // The space needed to fill the top part of the stack view, + // in order to make the stackview content centered + private var spaceToFullInStackView: CGFloat? + private var userIsEditing: Bool = false + private var isKeyboardShowing: Bool = false + + private lazy var contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var scrollView: UIScrollView = { + let view = UIScrollView() + view.translatesAutoresizingMaskIntoConstraints = false + view.showsVerticalScrollIndicator = false + view.showsHorizontalScrollIndicator = false + return view + }() + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateUIBasedOnUserInterfaceStyle() + + if UIDevice.current.orientation.isPortrait { + stackView.spacing = Constants.stackViewInterItemSpacingPortrait + titleLabelConstraint.constant = 32 + } else if UIDevice.current.orientation.isLandscape { + stackView.spacing = Constants.stackViewInterItemSpacingLandscape + titleLabelConstraint.constant = 16.0 + } + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + registerNotifications() + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + guard !userIsEditing else { return } + scrollView.setNeedsLayout() + scrollView.layoutIfNeeded() + let emptySpace = stackView.customSpacing(after: stackView.arrangedSubviews.first!) + let spaceToFill = (scrollView.frame.height - (stackView.frame.height - emptySpace)) / 2 + stackView.setCustomSpacing(spaceToFill + Constants.viewVerticalSpacing, after: stackView.arrangedSubviews.first!) + } + + func didFail(_ error: ErrorEvent) { + print("UIkitDemoView::getEventsHandler::didFail \(error)") + print("UIkitDemoView error.code \(error.code)") + } + + func startExperience(with link: String) { + let callCompositeOptions = CallCompositeOptions(themeConfiguration: TeamsBrandConfig()) + + let callComposite = CallComposite(withOptions: callCompositeOptions) + + callComposite.setTarget(didFail: didFail) + + if let communicationTokenCredential = try? getTokenCredential() { + switch selectedMeetingType { + case .groupCall: + let uuid = UUID(uuidString: link) ?? UUID() + let parameters = GroupCallOptions(communicationTokenCredential: communicationTokenCredential, + groupId: uuid, + displayName: getDisplayName()) + callComposite.launch(with: parameters) + case .teamsMeeting: + let parameters = TeamsMeetingOptions(communicationTokenCredential: communicationTokenCredential, + meetingLink: link, + displayName: getDisplayName()) + callComposite.launch(with: parameters) + } + } else { + showError(for: DemoError.invalidToken.getErrorCode()) + return + } + } + + private func getTokenCredential() throws -> CommunicationTokenCredential { + switch selectedAcsTokenType { + case .token: + if let communicationTokenCredential = try? CommunicationTokenCredential(token: acsTokenTextField.text!) { + return communicationTokenCredential + } else { + throw DemoError.invalidToken + } + case .tokenUrl: + if let url = URL(string: acsTokenUrlTextField.text!) { + let communicationTokenRefreshOptions = CommunicationTokenRefreshOptions(initialToken: nil, refreshProactively: true, tokenRefresher: AuthenticationHelper.getCommunicationToken(tokenUrl: url)) + if let communicationTokenCredential = try? CommunicationTokenCredential(withOptions: communicationTokenRefreshOptions) { + return communicationTokenCredential + } + } + throw DemoError.invalidToken + } + } + + private func getDisplayName() -> String { + displayNameTextField.text ?? "" + } + + private func getMeetingLink() -> String { + switch selectedMeetingType { + case .groupCall: + return groupCallTextField.text ?? "" + case .teamsMeeting: + return teamsMeetingTextField.text ?? "" + } + } + + private func showError(for errorCode: String) { + var errorMessage = "" + switch errorCode { + case CallCompositeErrorCode.tokenExpired: + errorMessage = "Token is invalid" + default: + errorMessage = "Unknown error" + } + let errorAlert = UIAlertController(title: "Error", message: errorMessage, preferredStyle: .alert) + errorAlert.addAction(UIAlertAction(title: "Dismiss", style: .cancel, handler: nil)) + present(errorAlert, + animated: true, + completion: nil) + } + + private func registerNotifications() { + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + } + + private func updateUIBasedOnUserInterfaceStyle() { + if UITraitCollection.current.userInterfaceStyle == .dark { + view.backgroundColor = .black + } + else { + view.backgroundColor = .white + } + } + + @objc func onAcsTokenTypeValueChanged(_ sender: UISegmentedControl!) { + selectedAcsTokenType = ACSTokenType(rawValue: sender.selectedSegmentIndex)! + updateAcsTokenTypeFields() + } + @objc func onMeetingTypeValueChanged(_ sender: UISegmentedControl!) { + selectedMeetingType = MeetingType(rawValue: sender.selectedSegmentIndex)! + updateMeetingTypeFields() + } + + @objc func keyboardWillShow(notification: NSNotification) { + isKeyboardShowing = true + adjustScrollView() + } + + @objc func keyboardWillHide(notification: NSNotification) { + userIsEditing = false + isKeyboardShowing = false + adjustScrollView() + } + + @objc func textFieldEditingDidChange() { + startExperienceButton.isEnabled = !isStartExperienceDisabled + updateStartExperieceButton() + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + userIsEditing = true + return true + } + + @objc func onStartExperienceBtnPressed() { + let link = self.getMeetingLink() + self.startExperience(with: link) + } + + private func updateAcsTokenTypeFields() { + switch selectedAcsTokenType { + case .tokenUrl: + acsTokenUrlTextField.isHidden = false + acsTokenTextField.isHidden = true + case .token: + acsTokenUrlTextField.isHidden = true + acsTokenTextField.isHidden = false + } + } + + private func updateMeetingTypeFields() { + switch selectedMeetingType { + case .groupCall: + groupCallTextField.isHidden = false + teamsMeetingTextField.isHidden = true + case .teamsMeeting: + groupCallTextField.isHidden = true + teamsMeetingTextField.isHidden = false + } + } + + private func updateStartExperieceButton() { + if isStartExperienceDisabled { + startExperienceButton.backgroundColor = .systemGray3 + } else { + startExperienceButton.backgroundColor = .systemBlue + } + } + + private var isStartExperienceDisabled: Bool { + if (selectedAcsTokenType == .token && acsTokenTextField.text!.isEmpty) + || (selectedAcsTokenType == .tokenUrl && acsTokenUrlTextField.text!.isEmpty) + || (selectedMeetingType == .groupCall && groupCallTextField.text!.isEmpty) + || (selectedMeetingType == .teamsMeeting && teamsMeetingTextField.text!.isEmpty) { + return true + } + + return false + } + + private func setupUI() { + updateUIBasedOnUserInterfaceStyle() + let safeArea = view.safeAreaLayoutGuide + + titleLabel = UILabel() + titleLabel.text = "UI Library - UIKit Sample" + titleLabel.sizeToFit() + titleLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleLabel) + titleLabelConstraint = titleLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: Constants.stackViewInterItemSpacingPortrait) + titleLabelConstraint.isActive = true + titleLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor).isActive = true + + let acsTokenTypeSegmentedControl = UISegmentedControl(items: ["Token URL", "Token"]) + acsTokenTypeSegmentedControl.selectedSegmentIndex = selectedAcsTokenType.rawValue + acsTokenTypeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + acsTokenTypeSegmentedControl.addTarget(self, action: #selector(onAcsTokenTypeValueChanged(_:)), for: .valueChanged) + + acsTokenUrlTextField = UITextField() + acsTokenUrlTextField.placeholder = "ACS Token URL" + acsTokenUrlTextField.text = EnvConfig.acsTokenUrl.value() + acsTokenUrlTextField.delegate = self + acsTokenUrlTextField.sizeToFit() + acsTokenUrlTextField.translatesAutoresizingMaskIntoConstraints = false + acsTokenUrlTextField.borderStyle = .roundedRect + acsTokenUrlTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) + + acsTokenTextField = UITextField() + acsTokenTextField.placeholder = "ACS Token" + acsTokenTextField.text = EnvConfig.acsToken.value() + acsTokenTextField.delegate = self + acsTokenTextField.sizeToFit() + acsTokenTextField.translatesAutoresizingMaskIntoConstraints = false + acsTokenTextField.borderStyle = .roundedRect + acsTokenTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) + + displayNameTextField = UITextField() + displayNameTextField.placeholder = "Display Name" + displayNameTextField.text = EnvConfig.displayName.value() + displayNameTextField.translatesAutoresizingMaskIntoConstraints = false + displayNameTextField.delegate = self + displayNameTextField.borderStyle = .roundedRect + displayNameTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) + + let meetingTypeSegmentedControl = UISegmentedControl(items: ["Group Call", "Teams Meeting"]) + meetingTypeSegmentedControl.selectedSegmentIndex = MeetingType.groupCall.rawValue + meetingTypeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + meetingTypeSegmentedControl.addTarget(self, action: #selector(onMeetingTypeValueChanged(_:)), for: .valueChanged) + + groupCallTextField = UITextField() + groupCallTextField.placeholder = "Group Call Id" + groupCallTextField.text = EnvConfig.groupCallId.value() + groupCallTextField.delegate = self + groupCallTextField.sizeToFit() + groupCallTextField.translatesAutoresizingMaskIntoConstraints = false + groupCallTextField.borderStyle = .roundedRect + groupCallTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) + + teamsMeetingTextField = UITextField() + teamsMeetingTextField.placeholder = "Teams Meeting Link" + teamsMeetingTextField.text = EnvConfig.teamsMeetingLink.value() + teamsMeetingTextField.delegate = self + teamsMeetingTextField.sizeToFit() + teamsMeetingTextField.translatesAutoresizingMaskIntoConstraints = false + teamsMeetingTextField.borderStyle = .roundedRect + teamsMeetingTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) + + startExperienceButton = UIButton() + startExperienceButton.backgroundColor = .systemBlue + startExperienceButton.setTitleColor(UIColor.white, for: .normal) + startExperienceButton.setTitleColor(UIColor.systemGray6, for: .disabled) + startExperienceButton.contentEdgeInsets = UIEdgeInsets.init(top: Constants.buttonVerticalInset, + left: Constants.buttonHorizontalInset, + bottom: Constants.buttonVerticalInset, + right: Constants.buttonHorizontalInset) + startExperienceButton.layer.cornerRadius = 8 + startExperienceButton.setTitle("Start Experience", for: .normal) + startExperienceButton.sizeToFit() + startExperienceButton.translatesAutoresizingMaskIntoConstraints = false + startExperienceButton.addTarget(self, action: #selector(onStartExperienceBtnPressed), for: .touchUpInside) + + + // horizontal stack view for the startExperienceButton + + let hSpacer1 = UIView() + hSpacer1.translatesAutoresizingMaskIntoConstraints = false + hSpacer1.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let hSpacer2 = UIView() + hSpacer2.translatesAutoresizingMaskIntoConstraints = false + hSpacer2.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let hStack = UIStackView(arrangedSubviews: [hSpacer1, startExperienceButton, hSpacer2]) + hStack.axis = .horizontal + hStack.alignment = .fill + hStack.distribution = .fill + hStack.translatesAutoresizingMaskIntoConstraints = false + + let spaceView1 = UIView() + spaceView1.translatesAutoresizingMaskIntoConstraints = false + spaceView1.heightAnchor.constraint(equalToConstant: 0).isActive = true + + stackView = UIStackView(arrangedSubviews: [spaceView1, acsTokenTypeSegmentedControl, + acsTokenUrlTextField, + acsTokenTextField, + displayNameTextField, + meetingTypeSegmentedControl, + groupCallTextField, + teamsMeetingTextField, hStack]) + stackView.spacing = Constants.stackViewInterItemSpacingPortrait + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.setCustomSpacing(0, after: stackView.arrangedSubviews.first!) + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, + constant: Constants.viewVerticalSpacing).isActive = true + scrollView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor).isActive = true + scrollView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor).isActive = true + scrollView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor).isActive = true + + contentView.addSubview(stackView) + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true + + stackView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, + constant: Constants.stackViewInterItemSpacingPortrait).isActive = true + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, + constant: -Constants.stackViewInterItemSpacingPortrait).isActive = true + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true + + hSpacer2.widthAnchor.constraint(equalTo: hSpacer1.widthAnchor).isActive = true + + updateAcsTokenTypeFields() + updateMeetingTypeFields() + startExperienceButton.isEnabled = !isStartExperienceDisabled + updateStartExperieceButton() + } + + private func adjustScrollView() { + if UIDevice.current.userInterfaceIdiom == .phone || UIDevice.current.orientation.isLandscape { + if self.isKeyboardShowing { + let offset: CGFloat = (UIDevice.current.orientation.isPortrait + || UIDevice.current.orientation == .unknown) ? 200 : 250 + let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: offset, right: 0) + scrollView.contentInset = contentInsets + scrollView.scrollIndicatorInsets = contentInsets + scrollView.setContentOffset(CGPoint(x: 0, y: offset) , animated: true) + } else { + scrollView.contentInset = .zero + scrollView.scrollIndicatorInsets = .zero + } + } + } +} + +extension UIKitDemoViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return false + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/ActionMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/ActionMocking.swift new file mode 100644 index 000000000..9f1edff21 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/ActionMocking.swift @@ -0,0 +1,10 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +struct ActionMocking: Action { +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/BannerTextViewModelMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/BannerTextViewModelMocking.swift new file mode 100644 index 000000000..a6677ef9c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/BannerTextViewModelMocking.swift @@ -0,0 +1,22 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine +import AzureCommunicationCalling +@testable import AzureCommunicationUI + +class BannerTextViewModelMocking: BannerTextViewModel { + var updateBannerInfoType: ((BannerInfoType?) -> Void)? + var bannerType: BannerInfoType? + + init(updateBannerInfoType: ((BannerInfoType?) -> Void)? = nil) { + self.updateBannerInfoType = updateBannerInfoType + } + + override func update(bannerInfoType: BannerInfoType?) { + updateBannerInfoType?(bannerInfoType) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Builder/ParticipantInfoModelBuilder.swift b/AzureCommunicationUI/AzureCommunicationUITests/Builder/ParticipantInfoModelBuilder.swift new file mode 100644 index 000000000..e77942c50 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Builder/ParticipantInfoModelBuilder.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +struct ParticipantInfoModelBuilder { + static func get(participantIdentifier: String = UUID().uuidString, + videoStreamId: String? = "videoStreamId", + screenShareStreamId: String? = nil, + displayName: String = "displayName", + isSpeaking: Bool = false, + isMuted: Bool = true, + recentSpeakingStamp: Date = Date()) -> ParticipantInfoModel { + var videoStreamInfoModel: VideoStreamInfoModel? + var screenShareIdInfoModel: VideoStreamInfoModel? + if let screenShareId = screenShareStreamId { + screenShareIdInfoModel = VideoStreamInfoModel(videoStreamIdentifier: screenShareId, + mediaStreamType: .cameraVideo) + } + + if let cameraId = videoStreamId { + videoStreamInfoModel = VideoStreamInfoModel(videoStreamIdentifier: cameraId, + mediaStreamType: .cameraVideo) + } + + return ParticipantInfoModel(displayName: displayName, + isSpeaking: isSpeaking, + isMuted: isMuted, + isRemoteUser: true, + userIdentifier: participantIdentifier, + recentSpeakingStamp: recentSpeakingStamp, + screenShareVideoStreamModel: screenShareIdInfoModel, + cameraVideoStreamModel: videoStreamInfoModel) + } + + static func getArray(count: Int = 1) -> [ParticipantInfoModel] { + var array = [ParticipantInfoModel]() + for _ in 0.. DiagnosticConfig { + return DiagnosticConfig() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/GroupCallOptionsTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/GroupCallOptionsTests.swift new file mode 100644 index 000000000..e6c92a68e --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/GroupCallOptionsTests.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +import AzureCommunicationCommon +@testable import AzureCommunicationUI + +class GroupCallOptionsTests: XCTestCase { + func test_groupCallOptions_init_when_parametersAreValid_then_returnGroupCallOptionsObject() { + let sampleToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjMyNTAzNjgwMDAwfQ.9i7FNNHHJT8cOzo-yrAUJyBSfJ-tPPk2emcHavOEpWc" + let communicationTokenCredential = try? CommunicationTokenCredential(token: sampleToken) + let displayName = "Display Name" + let groupId = UUID() + + let groupCallOptions = GroupCallOptions(communicationTokenCredential: communicationTokenCredential!, groupId: groupId, displayName: displayName) + + XCTAssertNotNil(groupCallOptions) + XCTAssertEqual(groupCallOptions.displayName, displayName) + XCTAssertEqual(groupCallOptions.groupId.uuidString, groupId.uuidString) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/TeamsMeetingOptionsTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/TeamsMeetingOptionsTests.swift new file mode 100644 index 000000000..e78e11891 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/CallCompositeOptions/TeamsMeetingOptionsTests.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +import AzureCommunicationCommon +@testable import AzureCommunicationUI + +class TeamsMeetingOptionsTests: XCTestCase { + func test_teamsMeetingOptions_init_when_parametersAreValid_then_returnTeamsMeetingOptionsObject() { + let sampleToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjMyNTAzNjgwMDAwfQ.9i7FNNHHJT8cOzo-yrAUJyBSfJ-tPPk2emcHavOEpWc" + let communicationTokenCredential = try? CommunicationTokenCredential(token: sampleToken) + let displayName = "Display Name" + let meetingLink = "asdf" + + let options = TeamsMeetingOptions(communicationTokenCredential: communicationTokenCredential!, meetingLink: meetingLink, displayName: displayName) + + XCTAssertNotNil(options) + XCTAssertEqual(options.displayName, displayName) + XCTAssertEqual(options.meetingLink, meetingLink) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/CallingMiddlewareHandlerMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/CallingMiddlewareHandlerMocking.swift new file mode 100644 index 000000000..9181fcd6f --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/CallingMiddlewareHandlerMocking.swift @@ -0,0 +1,75 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +class CallingMiddlewareHandlerMocking: CallingMiddlewareHandling { + var setupCallWasCalled = false + var startCallWasCalled = false + var endCallWasCalled = false + var enterBackgroundCalled = false + var enterForegroundCalled = false + var cameraPermissionSetCalled = false + var cameraPermissionGrantedCalled = false + var requestCameraPreviewOnCalled = false + var requestCameraOnCalled = false + var requestCameraOffCalled = false + var requestCameraSwitchCalled = false + var requestMicMuteCalled = false + var requestMicUnmuteCalled = false + + func setupCall(state: ReduxState?, dispatch: @escaping ActionDispatch) { + setupCallWasCalled = true + } + + func startCall(state: ReduxState?, dispatch: @escaping ActionDispatch) { + startCallWasCalled = true + } + + func endCall(state: ReduxState?, dispatch: @escaping ActionDispatch) { + endCallWasCalled = true + } + + func enterBackground(state: ReduxState?, dispatch: @escaping ActionDispatch) { + enterBackgroundCalled = true + } + + func enterForeground(state: ReduxState?, dispatch: @escaping ActionDispatch) { + enterForegroundCalled = true + } + + func onCameraPermissionIsSet(state: ReduxState?, dispatch: @escaping ActionDispatch) { + cameraPermissionSetCalled = true + } + + func cameraPermissionGranted(state: ReduxState?, dispatch: @escaping ActionDispatch) { + cameraPermissionGrantedCalled = true + } + + func requestCameraPreviewOn(state: ReduxState?, dispatch: @escaping ActionDispatch) { + requestCameraPreviewOnCalled = true + } + + func requestCameraOn(state: ReduxState?, dispatch: @escaping ActionDispatch) { + requestCameraOnCalled = true + } + + func requestCameraOff(state: ReduxState?, dispatch: @escaping ActionDispatch) { + requestCameraOffCalled = true + } + + func requestCameraSwitch(state: ReduxState?, dispatch: @escaping ActionDispatch) { + requestCameraSwitchCalled = true + } + + func requestMicrophoneMute(state: ReduxState?, dispatch: @escaping ActionDispatch) { + requestMicMuteCalled = true + } + + func requestMicrophoneUnmute(state: ReduxState?, dispatch: @escaping ActionDispatch) { + requestMicUnmuteCalled = true + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/CompositeViewModelFactoryMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/CompositeViewModelFactoryMocking.swift new file mode 100644 index 000000000..a9900d5bb --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/CompositeViewModelFactoryMocking.swift @@ -0,0 +1,121 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import FluentUI +@testable import AzureCommunicationUI + +class CompositeViewModelFactoryMocking: CompositeViewModelFactory { + var logger: Logger + var store: Store + + var bannerTextViewModel: BannerTextViewModel? + + init(logger: Logger, + store: Store) { + self.logger = logger + self.store = store + } + + func getSetupViewModel() -> SetupViewModel { + return SetupViewModel(compositeViewModelFactory: self, logger: logger, store: store) + } + + func getCallingViewModel() -> CallingViewModel { + return CallingViewModel(compositeViewModelFactory: self, logger: logger, store: store) + } + + func makeIconButtonViewModel(iconName: CompositeIcon, + buttonType: IconButtonViewModel.ButtonType, + isDisabled: Bool, + action: @escaping (() -> Void)) -> IconButtonViewModel { + IconButtonViewModel(iconName: iconName, + buttonType: buttonType, + isDisabled: isDisabled, + action: action) + } + + func makeIconWithLabelButtonViewModel(iconName: CompositeIcon, + buttonTypeColor: IconWithLabelButtonViewModel.ButtonTypeColor, + buttonLabel: String, + isDisabled: Bool, + action: @escaping (() -> Void)) -> IconWithLabelButtonViewModel { + IconWithLabelButtonViewModel(iconName: iconName, + buttonTypeColor: buttonTypeColor, + buttonLabel: buttonLabel, + isDisabled: isDisabled, + action: action) + } + + func makeLocalVideoViewModel(dispatchAction: @escaping ActionDispatch) -> LocalVideoViewModel { + LocalVideoViewModel(compositeViewModelFactory: self, logger: logger, dispatchAction: dispatchAction) + } + + func makePrimaryButtonViewModel(buttonStyle: ButtonStyle, + buttonLabel: String, + iconName: CompositeIcon?, + isDisabled: Bool, + action: @escaping (() -> Void)) -> PrimaryButtonViewModel { + PrimaryButtonViewModel(buttonStyle: buttonStyle, + buttonLabel: buttonLabel, + iconName: iconName, + isDisabled: isDisabled, + action: action) + } + func makeAudioDeviceListViewModel(dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) -> AudioDeviceListViewModel { + AudioDeviceListViewModel(dispatchAction: dispatchAction, + localUserState: localUserState) + } + func makeErrorInfoViewModel() -> ErrorInfoViewModel { + ErrorInfoViewModel() + } + + // MARK: CallingViewModels + func makeControlBarViewModel(dispatchAction: @escaping ActionDispatch, + endCallConfirm: @escaping (() -> Void), + localUserState: LocalUserState) -> ControlBarViewModel { + ControlBarViewModel(compositeViewModelFactory: self, + logger: logger, + dispatchAction: dispatchAction, + endCallConfirm: endCallConfirm, + localUserState: localUserState) + } + + func makeInfoHeaderViewModel(localUserState: LocalUserState) -> InfoHeaderViewModel { + InfoHeaderViewModel(compositeViewModelFactory: self, + logger: logger, + localUserState: localUserState) + } + func makeParticipantCellViewModel(participantModel: ParticipantInfoModel) -> ParticipantGridCellViewModel { + ParticipantGridCellViewModel(compositeViewModelFactory: self, participantModel: participantModel) + } + func makeParticipantGridsViewModel() -> ParticipantGridViewModel { + ParticipantGridViewModel(compositeViewModelFactory: self) + } + func makeParticipantsListViewModel(localUserState: LocalUserState) -> ParticipantsListViewModel { + ParticipantsListViewModel(localUserState: localUserState) + } + func makeBannerViewModel() -> BannerViewModel { + BannerViewModel(compositeViewModelFactory: self) + } + func makeBannerTextViewModel() -> BannerTextViewModel { + return bannerTextViewModel ?? + BannerTextViewModel() + } + + // MARK: SetupViewModels + func makePreviewAreaViewModel(dispatchAction: @escaping ActionDispatch) -> PreviewAreaViewModel { + PreviewAreaViewModel(compositeViewModelFactory: self, dispatchAction: dispatchAction) + } + + func makeSetupControlBarViewModel(dispatchAction: @escaping ActionDispatch, + localUserState: LocalUserState) -> SetupControlBarViewModel { + SetupControlBarViewModel(compositeViewModelFactory: self, + logger: logger, + dispatchAction: dispatchAction, + localUserState: localUserState) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/DI/DependencyContainerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/DI/DependencyContainerTests.swift new file mode 100644 index 000000000..48a15f69c --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/DI/DependencyContainerTests.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +import AzureCommunicationCommon +@testable import AzureCommunicationUI + +class DependencyContainerTests: XCTestCase { + func test_dependencyContainer_init_then_defaultDependenciesAreRegistered() { + let dependencyContainer = DependencyContainer() + + XCTAssertNotNil(dependencyContainer.resolve() as Logger) + } + + func test_dependencyContainer_registerExperienceDependencies_thenExperienceDependenciesAreRegistered() { + let dependencyContainer = DependencyContainer() + + let sampleToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjMyNTAzNjgwMDAwfQ.9i7FNNHHJT8cOzo-yrAUJyBSfJ-tPPk2emcHavOEpWc" + let communicationTokenCredential = try? CommunicationTokenCredential(token: sampleToken) + let displayName = "" + let groupId = UUID() + let callConfiguration = CallConfiguration(communicationTokenCredential: communicationTokenCredential!, groupId: groupId, displayName: displayName) + + dependencyContainer.registerDependencies(callConfiguration) + + XCTAssertNotNil(dependencyContainer.resolve() as CallingSDKWrapper) + XCTAssertNotNil(dependencyContainer.resolve() as VideoViewManager) + XCTAssertNotNil(dependencyContainer.resolve() as CallingService) + XCTAssertNotNil(dependencyContainer.resolve() as Store) + XCTAssertNotNil(dependencyContainer.resolve() as NavigationRouter) + XCTAssertNotNil(dependencyContainer.resolve() as CompositeViewModelFactory) + XCTAssertNotNil(dependencyContainer.resolve() as CompositeViewFactory) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/ErrorMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/ErrorMocking.swift new file mode 100644 index 000000000..db4268df1 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/ErrorMocking.swift @@ -0,0 +1,10 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +enum ErrorMocking: Error { + case mockError +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Info.plist b/AzureCommunicationUI/AzureCommunicationUITests/Info.plist new file mode 100644 index 000000000..64d65ca49 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Manager/CompositeErrorManagerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Manager/CompositeErrorManagerTests.swift new file mode 100644 index 000000000..9cde529f4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Manager/CompositeErrorManagerTests.swift @@ -0,0 +1,110 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class CompositeErrorManagerTests: XCTestCase { + var mockStoreFactory = StoreFactoryMocking() + var cancellable: CancelBag! + var compositeManager: CompositeErrorManager! + var mockCallComposite = CallCompositeMocking(withOptions: CallCompositeOptions()) + var mockEventsHandler: CallCompositeEventsHandler? + + var handlerCallExpectation = XCTestExpectation(description: "Delegate expectation") + var expectedError: ErrorEvent? + + override func setUp() { + cancellable = CancelBag() + compositeManager = CompositeErrorManager(store: mockStoreFactory.store, + callCompositeEventsHandler: getEventsHandler()) + } + + func test_errorManager_receiveState_when_noFatalError_navigationExit_then_nodidFailEventCalled_hostingVCDismissCalled() { + handlerCallExpectation.isInverted = true + let newState = getAppState(naviState: NavigationState(status: .exit)) + mockStoreFactory.setState(newState) + + wait(for: [handlerCallExpectation], timeout: 1) + } + + func test_errorManager_receiveState_when_noFatalError_callNotExisted_then_nodidFailEventCalled_hostingVCDismissCalled() { + handlerCallExpectation.isInverted = true + let newState = getAppState(naviState: NavigationState(status: .inCall)) + mockStoreFactory.setState(newState) + + wait(for: [handlerCallExpectation], timeout: 1) + } + + func test_errorManager_receiveState_when_fatalErrorCallJoin_then_receiveDidFail() { + let fatalError = ErrorEvent(code: CallCompositeErrorCode.callJoin, error: nil) + self.expectedError = fatalError + let errorState = ErrorState.init(error: fatalError, errorCode: CallCompositeErrorCode.callJoin, errorCategory: .callState) + let newState = getAppState(errorState: errorState) + + mockStoreFactory.setState(newState) + wait(for: [handlerCallExpectation], timeout: 1) + } + + func test_errorManager_receiveState_when_fatalErrorTokenExpired_then_receiveEmergencyExitAction() { + let fatalError = ErrorEvent(code: CallCompositeErrorCode.tokenExpired, error: nil) + self.expectedError = fatalError + let errorState = ErrorState.init(error: fatalError, errorCode: CallCompositeErrorCode.tokenExpired, errorCategory: .fatal) + let newState = getAppState(errorState: errorState) + let actionExpectation = XCTestExpectation(description: "Dispatch the new emergency exit action") + + mockStoreFactory.setState(newState) + mockStoreFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in + guard let self = self else { + XCTFail("self is nil") + return + } + XCTAssertTrue(self.mockStoreFactory.actions.first is CompositeExitAction) + actionExpectation.fulfill() + }.store(in: cancellable) + + wait(for: [handlerCallExpectation, actionExpectation], timeout: 1) + } +} + +extension CompositeErrorManagerTests { + func getAppState(naviState: NavigationState = NavigationState(status: .setup)) -> AppState { + return AppState(callingState: CallingState(), + permissionState: PermissionState(), + localUserState: LocalUserState(), + lifeCycleState: LifeCycleState(), + navigationState: naviState, + remoteParticipantsState: .init(), + errorState: .init()) + } + + func getAppState(errorState: ErrorState) -> AppState { + return AppState(callingState: CallingState(), + permissionState: PermissionState(), + localUserState: LocalUserState(), + lifeCycleState: LifeCycleState(), + navigationState: NavigationState(status: .setup), + remoteParticipantsState: .init(), + errorState: errorState) + } + + func getEventsHandler() -> CallCompositeEventsHandler { + let handler = CallCompositeEventsHandler() + handler.didFail = { [weak self] callCompositeError in + guard let self = self else { + return + } + + XCTAssertEqual(callCompositeError.code, self.expectedError?.code) + self.handlerCallExpectation.fulfill() + + } + mockEventsHandler = handler + return handler + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingSDKWrapperMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingSDKWrapperMocking.swift new file mode 100644 index 000000000..53f116c5a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingSDKWrapperMocking.swift @@ -0,0 +1,127 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine +import AzureCommunicationCalling +@testable import AzureCommunicationUI + +class CallingSDKWrapperMocking: CallingSDKWrapper { + var error: NSError? + var callingEventsHandler: CallingSDKEventsHandling = CallingSDKEventsHandler(logger: LoggerMocking()) + + func getLocalVideoStream(_ identifier: String) -> LocalVideoStream? { + return nil + } + + func startCallLocalVideoStream() -> AnyPublisher { + return AnyPublisher.init(Result.Publisher((""))) + } + + func stopLocalVideoStream() -> AnyPublisher { + return AnyPublisher.init(Result.Publisher(())) + } + + func switchCamera() -> AnyPublisher { + switchCameraCallCount += 1 + return AnyPublisher.init(Result.Publisher((.front))) + } + + var setupCallCallCount: Int = 0 + var startCallCallCount: Int = 0 + var endCallCallCount: Int = 0 + var switchCameraCallCount: Int = 0 + + var muteLocalMicCalled: Bool = false + var unmuteLocalMicCalled: Bool = false + var startPreviewVideoStreamCalled = false + + var isMuted: Bool? + var isCameraPreferred: Bool? + var isAudioPreferred: Bool? + + func muteLocalMic() -> AnyPublisher { + muteLocalMicCalled = true + isMuted = true + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func unmuteLocalMic() -> AnyPublisher { + unmuteLocalMicCalled = true + isMuted = false + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func getRemoteParticipant(_ identifier: String) -> RemoteParticipant? { + return nil + } + + func startPreviewVideoStream() -> AnyPublisher { + startPreviewVideoStreamCalled = true + return AnyPublisher.init(Result.Publisher((""))) + } + + func setupCall() -> AnyPublisher { + setupCallCallCount += 1 + + return AnyPublisher.init(Result.Publisher(())) + } + + func setupCallWasCalled() -> Bool { + return setupCallCallCount > 0 + } + + func startCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> AnyPublisher { + startCallCallCount += 1 + self.isCameraPreferred = isCameraPreferred + self.isAudioPreferred = isAudioPreferred + + return AnyPublisher.init(Result.Publisher(())) + } + + func startCallWasCalled() -> Bool { + return startCallCallCount > 0 + } + + func endCall() -> AnyPublisher { + endCallCallCount += 1 + + return AnyPublisher.init(Result.Publisher(())) + } + + func endCallWasCalled() -> Bool { + return endCallCallCount > 0 + } + + func muteWasCalled() -> Bool { + return muteLocalMicCalled + } + + func unmuteWasCalled() -> Bool { + return unmuteLocalMicCalled + } + + func videoEnabledWhenJoinCall() -> Bool { + return isCameraPreferred ?? false + } + + func mutedWhenJoinCall() -> Bool { + return !(isAudioPreferred ?? false) + } + + func switchCameraWasCalled() -> Bool { + return switchCameraCallCount > 0 + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingServiceMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingServiceMocking.swift new file mode 100644 index 000000000..bd2c4b7f8 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/CallingServiceMocking.swift @@ -0,0 +1,124 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine +@testable import AzureCommunicationUI + +class CallingServiceMocking: CallingService { + var error: Error? + var videoStreamId: String? + var cameraDevice: CameraDevice = .front + var setupCallCalled: Bool = false + var startCallCalled: Bool = false + var endCallCalled: Bool = false + var localCameraStream: String = "MockCameraStream" + + var startLocalVideoStreamCalled: Bool = false + var stopLocalVideoStreamCalled: Bool = false + var switchCameraCalled: Bool = false + + var muteLocalMicCalled: Bool = false + var unmuteLocalMicCalled: Bool = false + + func startLocalVideoStream() -> AnyPublisher { + startLocalVideoStreamCalled = true + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(self.videoStreamId ?? "")) + }.eraseToAnyPublisher() + } + + func stopLocalVideoStream() -> AnyPublisher { + stopLocalVideoStreamCalled = true + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func switchCamera() -> AnyPublisher { + switchCameraCalled = true + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success((self.cameraDevice))) + }.eraseToAnyPublisher() + } + + func muteLocalMic() -> AnyPublisher { + muteLocalMicCalled = true + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func unmuteLocalMic() -> AnyPublisher { + unmuteLocalMicCalled = true + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + var participantsInfoListSubject = CurrentValueSubject<[ParticipantInfoModel], Never>([]) + var callInfoSubject = PassthroughSubject() + var isRecordingActiveSubject = PassthroughSubject() + var isTranscriptionActiveSubject = PassthroughSubject() + + var isLocalUserMutedSubject = PassthroughSubject() + + func setupCall() -> AnyPublisher { + setupCallCalled = true + + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func startCall(isCameraPreferred: Bool, isAudioPreferred: Bool) -> AnyPublisher { + startCallCalled = true + + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func endCall() -> AnyPublisher { + endCallCalled = true + + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success(())) + }.eraseToAnyPublisher() + } + + func requestCameraPreviewOn() -> AnyPublisher { + return Future { promise in + if let error = self.error { + return promise(.failure(error)) + } + return promise(.success((self.localCameraStream))) + }.eraseToAnyPublisher() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/LoggerMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/LoggerMocking.swift new file mode 100644 index 000000000..f52787ac7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/LoggerMocking.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +class LoggerMocking { + var logCallCount: Int = 0 + + func logWasCalled() -> Bool { + return logCallCount > 0 + } +} + +extension LoggerMocking: Logger { + func debug(_: @autoclosure @escaping () -> String?) { + logCallCount += 1 + + } + + func info(_: @autoclosure @escaping () -> String?) { + logCallCount += 1 + + } + + func warning(_: @autoclosure @escaping () -> String?) { + logCallCount += 1 + + } + + func error(_: @autoclosure @escaping () -> String?) { + logCallCount += 1 + + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/MiddlewareMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/MiddlewareMocking.swift new file mode 100644 index 000000000..d963aba18 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/MiddlewareMocking.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +struct MiddlewareMocking: Middleware { + + let closure: (@escaping ActionDispatch, @escaping () -> ReduxState?) -> (@escaping ActionDispatch) -> ActionDispatch + + init(applyingClosure: @escaping ( + @escaping ActionDispatch, + @escaping () -> ReduxState?) -> (@escaping ActionDispatch) -> ActionDispatch) { + self.closure = applyingClosure + } + + func apply(dispatch: @escaping ActionDispatch, + getState: @escaping () -> ReduxState?) -> (@escaping ActionDispatch) -> ActionDispatch { + return closure(dispatch, getState) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/PermissionsManagerMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/PermissionsManagerMocking.swift new file mode 100644 index 000000000..3470994bf --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/PermissionsManagerMocking.swift @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Combine +@testable import AzureCommunicationUI + +class PermissionsManagerMocking: PermissionsManager { + private var requestWasCalled: Bool = false + private var requestWasCalledWithPermission: AppPermission? + + var didResolveStatus = false + var didRequestPermission = false + + func resolveStatus(for permission: AppPermission) -> AppPermission.Status { + return .granted + } + + func request(_ permission: AppPermission) -> Future { + requestWasCalled = true + requestWasCalledWithPermission = permission + return Future { promise in + promise(Result.success(.granted)) + } + } + + func requestWasCalledWith(permission: AppPermission) -> Bool { + return requestWasCalled && permission == requestWasCalledWithPermission + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/ReducerMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/ReducerMocking.swift new file mode 100644 index 000000000..f37303482 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/ReducerMocking.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +class ReducerMocking: Reducer { + var inputAction: Action? + var inputState: ReduxState? + var outputState: ReduxState? + + func reduce(_ state: ReduxState, _ action: Action) -> ReduxState { + self.inputState = state + self.inputAction = action + + if let outputState = outputState { + return outputState + } + + return state + + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/StateMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/StateMocking.swift new file mode 100644 index 000000000..b1b697cc9 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/StateMocking.swift @@ -0,0 +1,10 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +struct StateMocking: ReduxState { +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/StoreFactoryMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/StoreFactoryMocking.swift new file mode 100644 index 000000000..1a3c95982 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/StoreFactoryMocking.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +@testable import AzureCommunicationUI + +class StoreFactoryMocking { + var store: Store! + var actions = [Action]() + var firstAction: Action? { return actions.first } + var didRecordAction: Bool { return !actions.isEmpty } + + init() { + let middleWare = getMiddleware() + self.store = Store(reducer: ReducerMocking(), middlewares: [middleWare], state: AppState()) + } + + func reset() { + actions = [] + } + + func setState(_ state: AppState) { + store.state = state + } + + func getMiddleware() -> MiddlewareMocking { + return MiddlewareMocking { [weak self] _, _ in + return { next in + return { action in + self?.actions.append(action) + return next(action) + } + } + } + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Mocking/VideoViewManagerMocking.swift b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/VideoViewManagerMocking.swift new file mode 100644 index 000000000..fff7804c5 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Mocking/VideoViewManagerMocking.swift @@ -0,0 +1,12 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import AzureCommunicationCalling +@testable import AzureCommunicationUI + +class VideoViewManagerMocking { + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerInfoTypeTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerInfoTypeTests.swift new file mode 100644 index 000000000..85b8bc332 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerInfoTypeTests.swift @@ -0,0 +1,114 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class BannerInfoTypeTests: XCTestCase { + func test_bannerInfoType_when_recordingAndTranscriptionStarted_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .recordingAndTranscriptionStarted + let expectedTitle = "Recording and transcription have started. " + let expectedBody = "By joining, you are giving consent for this meeting to be transcribed. " + let expectedLinkDisplay = "Privacy policy" + let expectedLink = "https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_recordingStarted_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .recordingStarted + let expectedTitle = "Recording has started. " + let expectedBody = "By joining, you are giving consent for this meeting to be transcribed. " + let expectedLinkDisplay = "Privacy policy" + let expectedLink = "https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_transcriptionStoppedStillRecording_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .transcriptionStoppedStillRecording + let expectedTitle = "Transcription has stopped. " + let expectedBody = "You are now only recording this meeting. " + let expectedLinkDisplay = "Privacy policy" + let expectedLink = "https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_transcriptionStarted_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .transcriptionStarted + let expectedTitle = "Transcription has started. " + let expectedBody = "By joining, you are giving consent for this meeting to be transcribed. " + let expectedLinkDisplay = "Privacy policy" + let expectedLink = "https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_transcriptionStoppedAndSaved_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .transcriptionStoppedAndSaved + let expectedTitle = "Transcription is being saved. " + let expectedBody = "Transcription has stopped. " + let expectedLinkDisplay = "Learn more" + let expectedLink = "https://support.microsoft.com/en-us/office/record-a-meeting-in-teams-34dfbe7f-b07d-4a27-b4c6-de62f1348c24" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_recordingStoppedStillTranscribing_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .recordingStoppedStillTranscribing + let expectedTitle = "Recording has stopped. " + let expectedBody = "You are now only transcribing this meeting. " + let expectedLinkDisplay = "Privacy policy" + let expectedLink = "https://privacy.microsoft.com/en-US/privacystatement#mainnoticetoendusersmodule" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_recordingStopped_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .recordingStopped + let expectedTitle = "Recording is being saved. " + let expectedBody = "Recording has stopped. " + let expectedLinkDisplay = "Learn more" + let expectedLink = "https://support.microsoft.com/en-us/office/record-a-meeting-in-teams-34dfbe7f-b07d-4a27-b4c6-de62f1348c24" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } + + func test_bannerInfoType_when_recordingAndTranscriptionStopped_then_shouldEqualExpectedString() { + let bannerInfoType: BannerInfoType = .recordingAndTranscriptionStopped + let expectedTitle = "Recording and transcription are being saved. " + let expectedBody = "Recording and transcription have stopped. " + let expectedLinkDisplay = "Learn more" + let expectedLink = "https://support.microsoft.com/en-us/office/record-a-meeting-in-teams-34dfbe7f-b07d-4a27-b4c6-de62f1348c24" + + XCTAssertEqual(bannerInfoType.title, expectedTitle) + XCTAssertEqual(bannerInfoType.body, expectedBody) + XCTAssertEqual(bannerInfoType.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerInfoType.link, expectedLink) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerTextViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerTextViewModelTests.swift new file mode 100644 index 000000000..ce22ac3d5 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerTextViewModelTests.swift @@ -0,0 +1,55 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class BannerTextViewModelTests: XCTestCase { + + var bannerTextViewModel: BannerTextViewModel! + + override func setUp() { + bannerTextViewModel = BannerTextViewModel() + } + + func test_bannerTextViewModel_update_when_withBannerInfoType_then_shouldBePublish() { + let expectedTitle = BannerInfoType.recordingAndTranscriptionStarted.title + let expectedBody = BannerInfoType.recordingAndTranscriptionStarted.body + let expectedLinkDisplay = BannerInfoType.recordingAndTranscriptionStarted.linkDisplay + let expectedLink = BannerInfoType.recordingAndTranscriptionStarted.link + let expectation = XCTestExpectation(description: "Should publish bannerTextViewModel") + let cancel = bannerTextViewModel.objectWillChange + .sink(receiveValue: { + expectation.fulfill() + }) + + bannerTextViewModel.update(bannerInfoType: .recordingAndTranscriptionStarted) + + XCTAssertEqual(bannerTextViewModel.title, expectedTitle) + XCTAssertEqual(bannerTextViewModel.body, expectedBody) + XCTAssertEqual(bannerTextViewModel.linkDisplay, expectedLinkDisplay) + XCTAssertEqual(bannerTextViewModel.link, expectedLink) + cancel.cancel() + wait(for: [expectation], timeout: 1) + } + + func test_bannerTextViewModel_update_when_withNil_then_shouldBePublish() { + let expectation = XCTestExpectation(description: "Should publish bannerTextViewModel") + let cancel = bannerTextViewModel.objectWillChange + .sink(receiveValue: { + expectation.fulfill() + }) + + bannerTextViewModel.update(bannerInfoType: nil) + + XCTAssertTrue(bannerTextViewModel.title.isEmpty) + XCTAssertTrue(bannerTextViewModel.body.isEmpty) + XCTAssertTrue(bannerTextViewModel.linkDisplay.isEmpty) + XCTAssertTrue(bannerTextViewModel.link.isEmpty) + cancel.cancel() + wait(for: [expectation], timeout: 1) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerViewModelTests.swift new file mode 100644 index 000000000..ce3846faf --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/BannerViewModelTests.swift @@ -0,0 +1,324 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class BannerViewModelTests: XCTestCase { + + let cancellable = CancelBag() + + func test_bannerViewModel_isBannerDisplayedPublished_when_displayBannerWithRecordingOnAndTranscriptionOn_then_shouldBecomeTrueAndPublish() { + let bannerViewModel = makeSut() + let expectation = XCTestExpectation(description: "Should publish isBannerDisplayed") + bannerViewModel.$isBannerDisplayed + .dropFirst() + .sink(receiveValue: { isBannerDisplayed in + XCTAssertTrue(isBannerDisplayed) + expectation.fulfill() + }).store(in: cancellable) + + let callingState = CallingState(status: .connected, isRecordingActive: true, isTranscriptionActive: true) + bannerViewModel.update(callingState: callingState) + wait(for: [expectation], timeout: 1) + } + + func test_bannerViewModel_update_when_withRecordingActiveTrueAndTranscriptionActiveTrue_shouldUpdateRecordingAndTranscriptionStartedgBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.on, .on), + (.on, .off), + (.on, .stopped), + (.off, .on), + (.off, .off), + (.off, .stopped), + (.stopped, .on), + (.stopped, .off), + (.stopped, .stopped) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = true + let isTranscriptionActiveToUpdate = true + let expectedType = BannerInfoType.recordingAndTranscriptionStarted + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveTrueAndTranscriptionActiveFalse_shouldUpdateRecordingStartedBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.on, .off), + (.off, .off), + (.stopped, .off), + (.stopped, .stopped) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = true + let isTranscriptionActiveToUpdate = false + let expectedType = BannerInfoType.recordingStarted + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveTrueAndTranscriptionActiveFalse_shouldUpdateTranscriptionStoppedStillRecordingBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.on, .on), + (.on, .stopped), + (.off, .on), + (.off, .stopped), + (.stopped, .on) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = true + let isTranscriptionActiveToUpdate = false + let expectedType = BannerInfoType.transcriptionStoppedStillRecording + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveFalseAndTranscriptionActiveTrue_shouldUpdateTranscriptionStartedBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.off, .on), + (.off, .off), + (.off, .stopped), + (.stopped, .stopped) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = false + let isTranscriptionActiveToUpdate = true + let expectedType = BannerInfoType.transcriptionStarted + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveFalseAndTranscriptionActiveFalse_shouldNotUpdateBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.off, .off), + (.stopped, .stopped) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = false + let isTranscriptionActiveToUpdate = false + let expectedType: BannerInfoType? = nil + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveFalseAndTranscriptionActiveFalse_shouldUpdateTranscriptionStoppedAndSavedBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.off, .on), + (.off, .stopped) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = false + let isTranscriptionActiveToUpdate = false + let expectedType = BannerInfoType.transcriptionStoppedAndSaved + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveFalseAndTranscriptionActiveTrue_shouldUpdateRecordingStoppedStillTranscribingBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.on, .on), + (.on, .off), + (.on, .stopped), + (.stopped, .on), + (.stopped, .off) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = false + let isTranscriptionActiveToUpdate = true + let expectedType = BannerInfoType.recordingStoppedStillTranscribing + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveFalseAndTranscriptionActiveFalse_shouldUpdateRecordingStoppedBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.on, .off), + (.stopped, .off) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = false + let isTranscriptionActiveToUpdate = false + let expectedType = BannerInfoType.recordingStopped + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } + + func test_bannerViewModel_update_when_withRecordingActiveFalseAndTranscriptionActiveFalse_shouldUpdateRecordingAndTranscriptionStoppedBanner() { + let parameters: [(BannerViewModel.FeatureStatus, BannerViewModel.FeatureStatus)] = [ + (.on, .on), + (.on, .stopped), + (.stopped, .on), + (.stopped, .stopped) + ] + for (initialRecordingState, initialTranscriptionState) in parameters { + let isRecordingActiveToUpdate = false + let isTranscriptionActiveToUpdate = false + let expectedType = BannerInfoType.recordingAndTranscriptionStopped + + let callStatesArr = createCallStates(recordingState: initialRecordingState, + transcriptionState: initialTranscriptionState) + let mockingBannerViewModel = BannerTextViewModelMocking() + let bannerViewModel = makeSut(callingStateArray: callStatesArr, + mockingBannerViewModel: mockingBannerViewModel) + + let expectationClosure: ((BannerInfoType?) -> Void) = { bannerInfoType in + XCTAssertEqual(bannerInfoType, expectedType) + } + mockingBannerViewModel.updateBannerInfoType = expectationClosure + let callingStateToUpdate = makeCallingState(isRecordingActiveToUpdate, isTranscriptionActiveToUpdate) + bannerViewModel.update(callingState: callingStateToUpdate) + } + } +} + +extension BannerViewModelTests { + + func makeSut() -> BannerViewModel { + let storeFactory = StoreFactoryMocking() + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + return BannerViewModel(compositeViewModelFactory: factoryMocking) + } + + func makeSut(callingStateArray: [CallingState], + mockingBannerViewModel: BannerTextViewModelMocking) -> BannerViewModel { + let storeFactory = StoreFactoryMocking() + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), + store: storeFactory.store) + factoryMocking.bannerTextViewModel = mockingBannerViewModel + let sut = BannerViewModel(compositeViewModelFactory: factoryMocking) + for callState in callingStateArray { + sut.update(callingState: callState) + } + return sut + } + + func createCallStates(recordingState: BannerViewModel.FeatureStatus, + transcriptionState: BannerViewModel.FeatureStatus) -> [CallingState] { + switch (recordingState, transcriptionState) { + case (.on, .on): + return [makeCallingState(true, true)] + case (.on, .off): + return [makeCallingState(true, false)] + case (.on, .stopped): + return [makeCallingState(true, true), makeCallingState(true, false)] + case (.off, .on): + return [makeCallingState(false, true)] + case (.off, .off): + return [] + case (.off, .stopped): + return [makeCallingState(false, true), makeCallingState(false, false)] + case (.stopped, .on): + return [makeCallingState(true, true), makeCallingState(false, true)] + case (.stopped, .off): + return [makeCallingState(true, false), makeCallingState(false, false)] + case (.stopped, .stopped): + return [makeCallingState(true, true), makeCallingState(false, false)] + } + } + + func makeCallingState(_ recording: Bool, _ transcription: Bool) -> CallingState { + return CallingState(status: .connected, + isRecordingActive: recording, + isTranscriptionActive: transcription) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/CallingViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/CallingViewModelTests.swift new file mode 100644 index 000000000..3c2d34cdd --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/CallingViewModelTests.swift @@ -0,0 +1,138 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class CallingViewModelTests: XCTestCase { + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + + var callingViewModel: CallingViewModel! + + private let timeout: TimeInterval = 10.0 + + override func setUp() { + cancellable = CancelBag() + + storeFactory = StoreFactoryMocking() + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + callingViewModel = CallingViewModel(compositeViewModelFactory: factoryMocking, + logger: LoggerMocking(), + store: storeFactory.store) + } + + func test_callingViewModel_getLeaveCallButtonViewModel_shouldReturnPrimaryButtonViewModel() { + let leaveCallButtonViewModel = callingViewModel.getLeaveCallButtonViewModel() + let expectedButtonLabel = "Leave call" + + XCTAssertEqual(leaveCallButtonViewModel.buttonLabel, expectedButtonLabel) + } + + func test_callingViewModel_getCancelButtonViewModel_shouldReturnPrimaryButtonViewModel() { + let cancelButtonViewModel = callingViewModel.getCancelButtonViewModel() + let expectedButtonLabel = "Cancel" + + XCTAssertEqual(cancelButtonViewModel.buttonLabel, expectedButtonLabel) + } + + func test_callingViewModel_displayConfirmLeaveOverlay_when_isConfirmLeaveOverlayDisplayedFalse_shouldBecomeTrue() { + callingViewModel.isConfirmLeaveOverlayDisplayed = false + callingViewModel.displayConfirmLeaveOverlay() + + XCTAssertTrue(callingViewModel.isConfirmLeaveOverlayDisplayed) + } + + func test_callingViewModel_dismissConfirmLeaveOverlay_when_isConfirmLeaveOverlayDisplayedTrue_shouldBecomeFalse() { + callingViewModel.isConfirmLeaveOverlayDisplayed = true + callingViewModel.dismissConfirmLeaveOverlay() + + XCTAssertFalse(callingViewModel.isConfirmLeaveOverlayDisplayed) + } + + func test_callingViewModel_startCall_when_currentCallingStateIsNone_shouldStartCall() { + let expectation = XCTestExpectation(description: "Verify Call Start is Requested") + + callingViewModel.startCall() + + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is CallingAction.CallStartRequested) + + expectation.fulfill() + }.store(in: cancellable) + + wait(for: [expectation], timeout: timeout) + } + + func test_callingViewModel_endCall_when_confirmLeaveOverlayIsDisplayed_shouldEndCall() { + let expectation = XCTestExpectation(description: "Verify Call End is Requested") + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is CallingAction.CallEndRequested) + + expectation.fulfill() + }.store(in: cancellable) + + callingViewModel.isConfirmLeaveOverlayDisplayed = true + callingViewModel.endCall() + XCTAssertFalse(callingViewModel.isConfirmLeaveOverlayDisplayed) + wait(for: [expectation], timeout: timeout) + } + + func test_callingViewModel_update_when_callStatusIsInLobby_then_isLobbyOverlayDisplayed_shouldBecomeTrue() { + let appState = AppState(callingState: CallingState(status: .inLobby)) + callingViewModel.receive(appState) + XCTAssert(callingViewModel.isLobbyOverlayDisplayed) + } + + func test_callingViewModel_update_when_callStatusIsConnected_then_isLobbyOverlayDisplayed_shouldBecomeFalse() { + let appState = AppState(callingState: CallingState(status: .connected)) + callingViewModel.receive(appState) + XCTAssertFalse(callingViewModel.isLobbyOverlayDisplayed) + } + + func test_callingViewModel_update_when_lifeCycleStateIsBackground_callStatusIsInLobby_then_isLobbyOverlayDisplayed_shouldKeepSame() { + let originalState = callingViewModel.isLobbyOverlayDisplayed + let appState = AppState(callingState: CallingState(status: .inLobby), + lifeCycleState: LifeCycleState(currentStatus: .background)) + callingViewModel.receive(appState) + XCTAssertEqual(callingViewModel.isLobbyOverlayDisplayed, originalState) + } + + func test_callingViewModel_update_when_callStatusIsConnected_remoteParticipantNotEmpty_then_isParticipantGridDisplayed_shouldBecomeTrue() { + let mockingParticipantInfoModel = ParticipantInfoModelBuilder.get() + let remoteParticipantState = RemoteParticipantsState(participantInfoList: [mockingParticipantInfoModel], + lastUpdateTimeStamp: Date()) + let appState = AppState(callingState: CallingState(status: .connected), + remoteParticipantsState: remoteParticipantState) + callingViewModel.receive(appState) + XCTAssertEqual(callingViewModel.isParticipantGridDisplayed, true) + } + + func test_callingViewModel_update_when_callStatusIsNotConnected_remoteParticipantNotEmpty_then_isParticipantGridDisplayed_shouldBecomeFalse() { + let mockingParticipantInfoModel = ParticipantInfoModelBuilder.get() + let remoteParticipantState = RemoteParticipantsState(participantInfoList: [mockingParticipantInfoModel], + lastUpdateTimeStamp: Date()) + let appState = AppState(callingState: CallingState(status: .inLobby), + remoteParticipantsState: remoteParticipantState) + callingViewModel.receive(appState) + XCTAssertEqual(callingViewModel.isParticipantGridDisplayed, false) + } + + func test_callingViewModel_update_when_callStatusIsNotConnected_remoteParticipantEmpty_then_isParticipantGridDisplayed_shouldBecomeFalse() { + let remoteParticipantState = RemoteParticipantsState(participantInfoList: [], + lastUpdateTimeStamp: Date()) + let appState = AppState(callingState: CallingState(status: .connected), + remoteParticipantsState: remoteParticipantState) + callingViewModel.receive(appState) + XCTAssertEqual(callingViewModel.isParticipantGridDisplayed, false) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ControlBarViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ControlBarViewModelTests.swift new file mode 100644 index 000000000..b97ccbd80 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ControlBarViewModelTests.swift @@ -0,0 +1,386 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class ControlBarViewModelTests: XCTestCase { + + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + var controlBarViewModel: ControlBarViewModel! + + override func setUp() { + storeFactory = StoreFactoryMocking() + cancellable = CancelBag() + + + func dispatch(action: Action) { + storeFactory.store.dispatch(action: action) + } + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + controlBarViewModel = ControlBarViewModel(compositeViewModelFactory: factoryMocking, + logger: LoggerMocking(), + dispatchAction: dispatch, + endCallConfirm: {}, + localUserState: LocalUserState()) + } + + // MARK: Microphone tests + func test_controlBarViewModel_microphoneButtonTapped_when_micStatusOff_then_shouldMicrophoneOnRequested() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is LocalUserAction.MicrophoneOnTriggered) + expectation.fulfill() + }.store(in: cancellable) + controlBarViewModel.microphoneButtonTapped() + + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_microphoneButtonTapped_when_micStatusOn_then_shouldMicrophoneOffRequested() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is LocalUserAction.MicrophoneOffTriggered) + expectation.fulfill() + }.store(in: cancellable) + controlBarViewModel.microphoneButtonTapped() + + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_micStatusOffAndUpdateWithMicOff_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + expectation.isInverted = true + controlBarViewModel.micButtonViewModel.$iconName + .dropFirst() + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("Mic was .off and update with .off should not publish") + }).store(in: cancellable) + let audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .off) + XCTAssertEqual(controlBarViewModel.micButtonViewModel.iconName, .micOff) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_micStatusOffAndUpdateWithMicSwitching_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.micButtonViewModel.$isDisabled + .dropFirst() + .sink(receiveValue: { isDisabled in + XCTAssertEqual(isDisabled, true) + expectation.fulfill() + }).store(in: cancellable) + let audioState = LocalUserState.AudioState(operation: .pending, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .pending) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), true) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_micStatusOnAndUpdateWithMicSwitching_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.micButtonViewModel.$isDisabled + .dropFirst() + .sink(receiveValue: { isDisabled in + XCTAssertEqual(isDisabled, true) + expectation.fulfill() + }).store(in: cancellable) + + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + + let audioState = LocalUserState.AudioState(operation: .pending, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .pending) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), true) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_micStatusSwitchingAndUpdateWithMicOn_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.micButtonViewModel.$isDisabled + .dropFirst(2) + .sink(receiveValue: { isDisabled in + XCTAssertEqual(isDisabled, false) + expectation.fulfill() + }).store(in: cancellable) + + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .pending, + device: .receiverSelected) + controlBarViewModel.micButtonViewModel.update(isDisabled: controlBarViewModel.isMicDisabled()) + + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .on) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), false) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_micStatusSwitchingAndUpdateWithMicOff_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.micButtonViewModel.$isDisabled + .dropFirst(2) + .sink(receiveValue: { isDisabled in + XCTAssertEqual(isDisabled, false) + expectation.fulfill() + }).store(in: cancellable) + + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .pending, + device: .receiverSelected) + controlBarViewModel.micButtonViewModel.update(isDisabled: controlBarViewModel.isMicDisabled()) + let audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .off) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), false) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_micStatusOffAndUpdateWithMicOn_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.micButtonViewModel.$iconName + .dropFirst() + .sink(receiveValue: { iconName in + XCTAssertEqual(iconName, .micOn) + expectation.fulfill() + }).store(in: cancellable) + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .on) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), false) + XCTAssertEqual(controlBarViewModel.micButtonViewModel.iconName, .micOn) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_micStatusOnAndUpdateWithMicOon_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + expectation.isInverted = true + controlBarViewModel.micButtonViewModel.$iconName + .dropFirst(2) + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("Mic was .on and update with .on should not publish") + }).store(in: cancellable) + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + controlBarViewModel.micButtonViewModel.iconName = .micOn + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .on) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), false) + XCTAssertEqual(controlBarViewModel.micButtonViewModel.iconName, .micOn) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_micStatusOnAndUpdateWithMicOff_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.micButtonViewModel.$iconName + .dropFirst(2) + .sink(receiveValue: { iconName in + XCTAssertEqual(iconName, .micOff) + expectation.fulfill() + }).store(in: cancellable) + controlBarViewModel.audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + controlBarViewModel.micButtonViewModel.iconName = .micOn + let audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.audioState.operation, .off) + XCTAssertEqual(controlBarViewModel.isMicDisabled(), false) + XCTAssertEqual(controlBarViewModel.micButtonViewModel.iconName, .micOff) + wait(for: [expectation], timeout: 1) + } + + // MARK: Camera tests + func test_controlBarViewModel_cameraButtonTapped_when_cameraStatusOff_then_shouldRequestCameraOnTriggered() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is LocalUserAction.CameraOnTriggered) + expectation.fulfill() + }.store(in: cancellable) + controlBarViewModel.cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + controlBarViewModel.cameraButtonTapped() + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_cameraButtonTapped_when_cameraStatusOn_then_shouldRequestCameraOffTriggered() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is LocalUserAction.CameraOffTriggered) + expectation.fulfill() + }.store(in: cancellable) + controlBarViewModel.cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + controlBarViewModel.cameraButtonTapped() + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_when_selectAudioDeviceButtonTapped_then_audioDeviceSelectionDisplayed() { + controlBarViewModel.selectAudioDeviceButtonTapped() + + XCTAssertTrue(controlBarViewModel.isAudioDeviceSelectionDisplayed) + } + + func test_controlBarViewModel_update_when_cameraStatusOffAndUpdateWithCameraOff_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + expectation.isInverted = true + controlBarViewModel.cameraButtonViewModel.$iconName + .dropFirst(2) + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("Camera was .on and update with .on should not publish") + }).store(in: cancellable) + controlBarViewModel.cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + controlBarViewModel.cameraButtonViewModel.iconName = .videoOff + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(cameraState: cameraState, audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.cameraState.operation, .off) + XCTAssertEqual(controlBarViewModel.cameraButtonViewModel.iconName, .videoOff) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_cameraStatusOffAndUpdateWithCameraOn_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.cameraButtonViewModel.$iconName + .dropFirst(2) + .sink(receiveValue: { iconName in + XCTAssertEqual(iconName, .videoOn) + expectation.fulfill() + }).store(in: cancellable) + controlBarViewModel.cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + controlBarViewModel.cameraButtonViewModel.iconName = .videoOff + + let cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(cameraState: cameraState, audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.cameraState.operation, .on) + XCTAssertEqual(controlBarViewModel.cameraButtonViewModel.iconName, .videoOn) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_cameraStatusOnAndUpdateWithCameraOn_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + expectation.isInverted = true + controlBarViewModel.cameraButtonViewModel.$iconName + .dropFirst(2) + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("Camera was .on and update with .on should not publish") + }).store(in: cancellable) + controlBarViewModel.cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + controlBarViewModel.cameraButtonViewModel.iconName = .videoOn + + let cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(cameraState: cameraState, audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.cameraState.operation, .on) + XCTAssertEqual(controlBarViewModel.cameraButtonViewModel.iconName, .videoOn) + wait(for: [expectation], timeout: 1) + } + + func test_controlBarViewModel_update_when_cameraStatusOnAndUpdateWithCameraOff_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + controlBarViewModel.cameraButtonViewModel.$iconName + .dropFirst(2) + .sink(receiveValue: { iconName in + XCTAssertEqual(iconName, .videoOff) + expectation.fulfill() + }).store(in: cancellable) + controlBarViewModel.cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + controlBarViewModel.cameraButtonViewModel.iconName = .videoOn + + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(cameraState: cameraState, audioState: audioState) + let permissionState = PermissionState(audioPermission: .granted, cameraPermission: .granted) + controlBarViewModel.update(localUserState: localUserState, permissionState: permissionState) + XCTAssertEqual(controlBarViewModel.cameraState.operation, .off) + XCTAssertEqual(controlBarViewModel.cameraButtonViewModel.iconName, .videoOff) + wait(for: [expectation], timeout: 1) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/InfoHeaderViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/InfoHeaderViewModelTests.swift new file mode 100644 index 000000000..6e0894a35 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/InfoHeaderViewModelTests.swift @@ -0,0 +1,178 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class InfoHeaderViewModelTests: XCTestCase { + + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + var infoHeaderViewModel: InfoHeaderViewModel! + + override func setUp() { + storeFactory = StoreFactoryMocking() + cancellable = CancelBag() + + func dispatch(action: Action) { + storeFactory.store.dispatch(action: action) + } + + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + infoHeaderViewModel = InfoHeaderViewModel(compositeViewModelFactory: factoryMocking, + logger: LoggerMocking(), + localUserState: LocalUserState()) + } + + func test_infoHeaderViewModel_update_when_participantInfoListCountSame_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Should not publish infoLabel") + expectation.isInverted = true + infoHeaderViewModel.$infoLabel + .dropFirst() + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("participantInfoList count is same and infoLabel should not publish") + }).store(in: cancellable) + + let participantInfoModel: [ParticipantInfoModel] = [] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: Date()) + + infoHeaderViewModel.update(localUserState: storeFactory.store.state.localUserState, + remoteParticipantsState: remoteParticipantsState) + + XCTAssertEqual(infoHeaderViewModel.infoLabel, "Waiting for others to join") + wait(for: [expectation], timeout: 1) + } + + func test_infoHeaderViewModel_update_when_participantInfoListCountChanged_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Should publish infoLabel") + infoHeaderViewModel.$infoLabel + .dropFirst() + .sink(receiveValue: { infoLabel in + XCTAssertEqual(infoLabel, "Call with 1 person") + expectation.fulfill() + }).store(in: cancellable) + + let participantInfoModel = ParticipantInfoModel( + displayName: "Participant 1", + isSpeaking: false, + isMuted: false, + isRemoteUser: true, + userIdentifier: "testUserIdentifier1", + recentSpeakingStamp: Date(), + screenShareVideoStreamModel: nil, + cameraVideoStreamModel: nil) + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: [participantInfoModel], lastUpdateTimeStamp: Date()) + + XCTAssertEqual(infoHeaderViewModel.infoLabel, "Waiting for others to join") + infoHeaderViewModel.update(localUserState: storeFactory.store.state.localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertEqual(infoHeaderViewModel.infoLabel, "Call with 1 person") + + wait(for: [expectation], timeout: 1) + } + + func test_infoHeaderViewModel_update_when_multipleParticipantInfoListCountChanged_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Should publish infoLabel") + infoHeaderViewModel.$infoLabel + .dropFirst() + .sink(receiveValue: { infoLabel in + XCTAssertEqual(infoLabel, "Call with 2 people") + expectation.fulfill() + }).store(in: cancellable) + + var participantList: [ParticipantInfoModel] = [] + let firstParticipantInfoModel = ParticipantInfoModel( + displayName: "Participant 1", + isSpeaking: false, + isMuted: false, + isRemoteUser: true, + userIdentifier: "testUserIdentifier1", + recentSpeakingStamp: Date(), + screenShareVideoStreamModel: nil, + cameraVideoStreamModel: nil) + participantList.append(firstParticipantInfoModel) + + let secondParticipantInfoModel = ParticipantInfoModel( + displayName: "Participant 2", + isSpeaking: false, + isMuted: false, + isRemoteUser: true, + userIdentifier: "testUserIdentifier1", + recentSpeakingStamp: Date(), + screenShareVideoStreamModel: nil, + cameraVideoStreamModel: nil) + participantList.append(secondParticipantInfoModel) + + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantList, lastUpdateTimeStamp: Date()) + + XCTAssertEqual(infoHeaderViewModel.infoLabel, "Waiting for others to join") + infoHeaderViewModel.update(localUserState: storeFactory.store.state.localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertEqual(infoHeaderViewModel.infoLabel, "Call with 2 people") + + wait(for: [expectation], timeout: 1) + } + + func test_infoHeaderViewModel_when_displayParticipantsList_then_participantsListDisplayed() { + infoHeaderViewModel.displayParticipantsList() + + XCTAssertTrue(infoHeaderViewModel.isParticipantsListDisplayed) + } + + func test_infoHeaderViewModel_toggleDisplayInfoHeader_when_isInfoHeaderDisplayedFalse_then_shouldBecomeTrueAndPublish() { + let expectation = XCTestExpectation(description: "Should publish isInfoHeaderDisplayed true") + let cancel = infoHeaderViewModel.$isInfoHeaderDisplayed + .dropFirst(2) + .sink(receiveValue: { isInfoHeaderDisplayed in + XCTAssertTrue(isInfoHeaderDisplayed) + expectation.fulfill() + }) + + infoHeaderViewModel.isInfoHeaderDisplayed = false + XCTAssertFalse(infoHeaderViewModel.isInfoHeaderDisplayed) + infoHeaderViewModel.toggleDisplayInfoHeader() + XCTAssertTrue(infoHeaderViewModel.isInfoHeaderDisplayed) + cancel.cancel() + wait(for: [expectation], timeout: 1) + } + + func test_infoHeaderViewModel_toggleDisplayInfoHeader_when_isInfoHeaderDisplayedFalse_then_isTrueAndWaitForTimerToHide_shouldBecomeFalseAgainAndPublish() { + let expectation = XCTestExpectation(description: "Should publish isInfoHeaderDisplayed true") + infoHeaderViewModel.$isInfoHeaderDisplayed + .dropFirst(3) + .sink(receiveValue: { isInfoHeaderDisplayed in + XCTAssertFalse(isInfoHeaderDisplayed) + expectation.fulfill() + }).store(in: cancellable) + + infoHeaderViewModel.isInfoHeaderDisplayed = false + XCTAssertFalse(infoHeaderViewModel.isInfoHeaderDisplayed) + infoHeaderViewModel.toggleDisplayInfoHeader() + XCTAssertTrue(infoHeaderViewModel.isInfoHeaderDisplayed) + wait(for: [expectation], timeout: 5) + } + + func test_infoHeaderViewModel_toggleDisplayInfoHeader_when_isInfoHeaderDisplayedTrue_then_shouldBecomeFalseAndPublish() { + let expectation = XCTestExpectation(description: "Should publish isInfoHeaderDisplayed false") + let cancel = infoHeaderViewModel.$isInfoHeaderDisplayed + .dropFirst(2) + .sink(receiveValue: { isInfoHeaderDisplayed in + XCTAssertFalse(isInfoHeaderDisplayed) + expectation.fulfill() + }) + + infoHeaderViewModel.isInfoHeaderDisplayed = true + XCTAssertTrue(infoHeaderViewModel.isInfoHeaderDisplayed) + infoHeaderViewModel.toggleDisplayInfoHeader() + XCTAssertFalse(infoHeaderViewModel.isInfoHeaderDisplayed) + cancel.cancel() + wait(for: [expectation], timeout: 1) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantCellViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantCellViewModelTests.swift new file mode 100644 index 000000000..e64198c69 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantCellViewModelTests.swift @@ -0,0 +1,282 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class ParticipantCellViewModelTests: XCTestCase { + var cancellable = CancelBag() + + func test_participantCellViewModel_init_then_getCorrectRendererViewModel() { + let expectedParticipantIdentifier = "expectedParticipantIdentifier" + let expectedVideoStreamId = "expectedVideoStreamId" + let expectedDisplayName = "expectedDisplayName" + let expectedIsSpeaking = false + let expectedIsMuted = true + let sut = makeSUT(participantIdentifier: expectedParticipantIdentifier, + videoStreamId: expectedVideoStreamId, + displayName: expectedDisplayName, + isSpeaking: expectedIsSpeaking, + isMuted: expectedIsMuted) + + XCTAssertEqual(sut.displayName, expectedDisplayName) + XCTAssertEqual(sut.videoStreamId, expectedVideoStreamId) + XCTAssertEqual(sut.isSpeaking, expectedIsSpeaking) + XCTAssertEqual(sut.isMuted, expectedIsMuted) + + XCTAssertEqual(sut.participantIdentifier, expectedParticipantIdentifier) + } + + func test_participantCellViewModel_update_then_updateRendererViewModel() { + let expectedParticipantIdentifier = "expectedParticipantIdentifier" + let expectedVideoStreamId = "expectedVideoStreamId" + let expectedDisplayName = "expectedDisplayName" + let expectedIsSpeaking = false + let sut = makeSUT() + let infoModel = ParticipantInfoModelBuilder.get(participantIdentifier: expectedParticipantIdentifier, + videoStreamId: expectedVideoStreamId, + displayName: expectedDisplayName, + isSpeaking: expectedIsSpeaking) + sut.update(participantModel: infoModel) + + XCTAssertEqual(sut.displayName, expectedDisplayName) + XCTAssertEqual(sut.videoStreamId, expectedVideoStreamId) + XCTAssertEqual(sut.isSpeaking, expectedIsSpeaking) + XCTAssertEqual(sut.participantIdentifier, expectedParticipantIdentifier) + + } + + // Marks: update videoStreamId + func test_participantCellViewModel_update_when_videoStreamIdSame_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Video Stream Should Not Be Published") + expectation.isInverted = true + let videoStreamId = "expectedVideoStreamId" + let sut = makeSUT(videoStreamId: videoStreamId) + + sut.$videoStreamId + .dropFirst() + .sink { _ in + XCTFail() + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(videoStreamId: videoStreamId) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + func test_participantCellViewModel_update_when_videoStreamIdDifferent_then_shouldBePublished() { + let videoStreamId = "expectedVideoStreamId" + + let diffVideoStreamId = "diffVideoStreamId" + let sut = makeSUT(videoStreamId: videoStreamId) + let expectation = XCTestExpectation(description: "subscription exception") + + sut.$videoStreamId + .dropFirst(1) + .sink { value in + XCTAssertEqual(diffVideoStreamId, value) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(videoStreamId: diffVideoStreamId) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + // Marks: update displayName + func test_participantCellViewModel_update_when_displayNameSame_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Same Display Name Should Not Be Published") + expectation.isInverted = true + let sameDisplayName = "sameDisplayName" + let sut = makeSUT(displayName: sameDisplayName) + + sut.$displayName + .dropFirst() + .sink { _ in + XCTFail() + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(displayName: sameDisplayName) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + func test_participantCellViewModel_update_when_displayNameNotSame_then_shouldBePublished() { + let diffDisplayName = "diffDisplayName" + let sut = makeSUT(displayName: "displayName") + let expectation = XCTestExpectation(description: "subscription expection") + + sut.$displayName + .dropFirst() + .sink { value in + XCTAssertEqual(diffDisplayName, value) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(displayName: diffDisplayName) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + // Marks: update isSpeaking + func test_participantCellViewModel_update_when_isSpeakingSame_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Speaking Is Same Then Should Not Be Published") + expectation.isInverted = true + let isSpeaking = false + let sut = makeSUT(isSpeaking: isSpeaking) + + sut.$isSpeaking + .dropFirst() + .sink { _ in + XCTFail() + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(isSpeaking: isSpeaking) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + func test_participantCellViewModel_update_when_isSpeakingNotSame_then_shouldBePublished() { + let isSpeaking = false + let sut = makeSUT(isSpeaking: true) + let expectation = XCTestExpectation(description: "subscription expection") + + sut.$isSpeaking + .dropFirst() + .sink { value in + XCTAssertEqual(isSpeaking, value) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(isSpeaking: isSpeaking) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + // Marks: update isMuted + func test_participantCellViewModel_update_when_isMutedSame_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Muting Is Same Then Should Not Be Published") + expectation.isInverted = true + let isMuted = false + let sut = makeSUT(isMuted: isMuted) + + sut.$isMuted + .dropFirst() + .sink { _ in + XCTFail() + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(isMuted: isMuted) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + func test_participantCellViewModel_update_when_isMutedNotSame_then_shouldBePublished() { + let isMuted = false + let sut = makeSUT(isMuted: true) + let expectation = XCTestExpectation(description: "subscription expection") + + sut.$isMuted + .dropFirst() + .sink { value in + XCTAssertEqual(isMuted, value) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(isMuted: isMuted) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + // MARK: Screen share video stream id + func test_participantCellViewModel_update_when_hasScreenShareVideoStream_then_updateScreenShareVideoStream() { + let sut = makeSUT() + let expectation = XCTestExpectation(description: "subscription exception") + let expectedVideoStream = "screenShare" + sut.$videoStreamId + .dropFirst() + .sink { id in + XCTAssertEqual(expectedVideoStream, id) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(videoStreamId: nil, + screenShareStreamId: expectedVideoStream) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + func test_participantCellViewModel_update_when_hasBothCameraAndScreenShareVideoStream_then_updateScreenShareVideoStream() { + let sut = makeSUT() + let expectation = XCTestExpectation(description: "subscription exception") + let expectedVideoStream = "screenShare" + sut.$videoStreamId + .dropFirst() + .sink { id in + XCTAssertEqual(expectedVideoStream, id) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(videoStreamId: "cameraVideoStreamId", + screenShareStreamId: expectedVideoStream) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + + func test_participantCellViewModel_update_when_hasNoScreenShareVideoStream_then_updateCameraVideoStream() { + let sut = makeSUT() + let expectation = XCTestExpectation(description: "subscription exception") + let expectedVideoStream = "cameraVideoStreamId" + sut.$videoStreamId + .dropFirst() + .sink { id in + XCTAssertEqual(expectedVideoStream, id) + expectation.fulfill() + }.store(in: cancellable) + + let infoModel = ParticipantInfoModelBuilder.get(videoStreamId: expectedVideoStream, + screenShareStreamId: nil) + sut.update(participantModel: infoModel) + + wait(for: [expectation], timeout: 1) + } + +} + +extension ParticipantCellViewModelTests { + func makeSUT(participantIdentifier: String = "participantIdentifier", + videoStreamId: String? = "videoStreamId", + screenShareStreamId: String? = nil, + displayName: String = "displayName", + isSpeaking: Bool = false, + isMuted: Bool = true) -> ParticipantGridCellViewModel { + let infoModel = ParticipantInfoModelBuilder.get(participantIdentifier: participantIdentifier, + videoStreamId: videoStreamId, + screenShareStreamId: screenShareStreamId, + displayName: displayName, + isSpeaking: isSpeaking, + isMuted: isMuted) + let storeFactory = StoreFactoryMocking() + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + return ParticipantGridCellViewModel(compositeViewModelFactory: factoryMocking, + participantModel: infoModel) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantGridsViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantGridsViewModelTests.swift new file mode 100644 index 000000000..0a7988b68 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantGridsViewModelTests.swift @@ -0,0 +1,303 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class ParticipantGridsViewModelTests: XCTestCase { + var cancellable = CancelBag() + + // MARK: Sorting participant + func test_participantGridsViewModel_updateParticipantsState_when_newSevenInfoModels_then_participantViewModelsSortedByRecentSpeakingTimeStamp() { + var inputInfoModelArr = [ParticipantInfoModel]() + for i in 0...6 { + let date = Calendar.current.date( + byAdding: .minute, + value: i, + to: Date())! + inputInfoModelArr.append(ParticipantInfoModelBuilder.get(recentSpeakingStamp: date)) + } + let lastUpdateTimeStamp = Calendar.current.date( + byAdding: .minute, + value: -2, + to: Date())! + let state = RemoteParticipantsState(participantInfoList: inputInfoModelArr, + lastUpdateTimeStamp: lastUpdateTimeStamp) + let sut = makeSUT() + sut.update(remoteParticipantsState: state) + guard let firstUserIdentifier = sut.participantsCellViewModelArr.first?.participantIdentifier, + let expectedId = inputInfoModelArr.last?.userIdentifier else { + XCTFail() + return + } + XCTAssertEqual(firstUserIdentifier, expectedId) + } + + func test_participantGridsViewModel_updateParticipantsState_when_existedTwoInfoModelsTimeStampUpdate_then_noIndexChange() { + let previousDate = Calendar.current.date( + byAdding: .minute, + value: -2, + to: Date())! + let previousDate2 = Calendar.current.date( + byAdding: .minute, + value: -1, + to: Date())! + let uuid1 = UUID().uuidString + let uuid2 = UUID().uuidString + let infoModel1 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid1, + recentSpeakingStamp: previousDate) + let infoModel2 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid2, + recentSpeakingStamp: previousDate2) + let state = RemoteParticipantsState(participantInfoList: [infoModel1, infoModel2], + lastUpdateTimeStamp: previousDate2) + let sut = makeSUT() + sut.update(remoteParticipantsState: state) + let expectedUserId = sut.participantsCellViewModelArr.first?.participantIdentifier + let updatedStampInfoModel1 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid1, + recentSpeakingStamp: Date()) + + let state2 = RemoteParticipantsState(participantInfoList: [updatedStampInfoModel1, infoModel2], + lastUpdateTimeStamp: Date()) + sut.update(remoteParticipantsState: state2) + guard let firstUserIdentifier = sut.participantsCellViewModelArr.first?.participantIdentifier else { + XCTFail() + return + } + XCTAssertEqual(firstUserIdentifier, expectedUserId) + } + + func test_participantGridsViewModel_updateParticipantsState_when_existedTwoInfoModelsSpeakingTimeStampChange_then_viewModelUpdated() { + let previousDate = Calendar.current.date( + byAdding: .minute, + value: -2, + to: Date())! + let previousDate2 = Calendar.current.date( + byAdding: .minute, + value: -1, + to: Date())! + let uuid1 = UUID().uuidString + let uuid2 = UUID().uuidString + let infoModel1 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid1, + isSpeaking: false, recentSpeakingStamp: previousDate) + let infoModel2 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid2, + isSpeaking: false, recentSpeakingStamp: previousDate2) + let state = RemoteParticipantsState(participantInfoList: [infoModel1, infoModel2], + lastUpdateTimeStamp: previousDate2) + let sut = makeSUT() + sut.update(remoteParticipantsState: state) + + let expectedIsSpeaking = true + let updatedStampInfoModel1 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid1, + isSpeaking: expectedIsSpeaking, recentSpeakingStamp: Date()) + let updatedStampInfoModel2 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid2, + isSpeaking: expectedIsSpeaking, recentSpeakingStamp: previousDate2) + + let state2 = RemoteParticipantsState(participantInfoList: [updatedStampInfoModel1, updatedStampInfoModel2], + lastUpdateTimeStamp: Date()) + sut.update(remoteParticipantsState: state2) + guard let firstUserIsSpeaking = sut.participantsCellViewModelArr.first?.isSpeaking else { + XCTFail() + return + } + guard let secondUserIsSpeaking = sut.participantsCellViewModelArr.first?.isSpeaking else { + XCTFail() + return + } + XCTAssertEqual(firstUserIsSpeaking, expectedIsSpeaking) + XCTAssertEqual(secondUserIsSpeaking, expectedIsSpeaking) + } + + func test_participantGridsViewModel_updateParticipantsState_when_screenSharing_then_screenSharingviewModelUpdated() { + let uuid1 = UUID().uuidString + let uuid2 = UUID().uuidString + let expectedVideoStreamId = "screenshareVideoStream" + let screenShareInfoModel = ParticipantInfoModelBuilder.get(participantIdentifier: uuid1, + screenShareStreamId: expectedVideoStreamId) + let infoModel2 = ParticipantInfoModelBuilder.get(participantIdentifier: uuid2) + let state = RemoteParticipantsState(participantInfoList: [screenShareInfoModel, infoModel2], + lastUpdateTimeStamp: Date()) + let sut = makeSUT() + sut.update(remoteParticipantsState: state) + XCTAssertEqual(sut.displayedParticipantInfoModelArr.count, 1) + XCTAssertEqual(sut.displayedParticipantInfoModelArr.first!.userIdentifier, uuid1) + XCTAssertEqual(sut.displayedParticipantInfoModelArr.first!.screenShareVideoStreamModel?.videoStreamIdentifier, expectedVideoStreamId) + } + + // MARK: LastUpdateTimeStamp + func test_participantGridsViewModel_updateParticipantsState_when_viewModelLastUpdateTimeStampDifferent_then_updateRemoteParticipantCellViewModel() { + let firstDate = Calendar.current.date( + byAdding: .minute, + value: -2, + to: Date()) + let firstState = makeRemoteParticipantState(count: 1, date: firstDate!) + let currentDate = Date() + let expectedCount = 2 + let currentState = makeRemoteParticipantState(count: expectedCount, date: currentDate) + let sut = makeSUT() + sut.update(remoteParticipantsState: firstState) + sut.update(remoteParticipantsState: currentState) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + } + + func test_participantGridsViewModel_updateParticipantsState_when_viewModelLastUpdateTimeStampSame_then_noUpdateRemoteParticipantCellViewModel() { + let date = Calendar.current.date( + byAdding: .minute, + value: -1, + to: Date()) + let expectedCount = 1 + + let firstState = makeRemoteParticipantState(count: expectedCount, date: date!) + let currentState = makeRemoteParticipantState(count: 2, date: date!) + let sut = makeSUT() + sut.update(remoteParticipantsState: firstState) + sut.update(remoteParticipantsState: currentState) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + } + + // MARK: GridsViewType + func test_participantGridsViewModel_init_then_gridsCountZero() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 0 + let sut = makeSUT() + + sut.$gridsCount + .sink { value in + XCTAssertEqual(expectedGridCount, value) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: RemoteParticipantsState()) + wait(for: [expectation], timeout: 1) + + } + + func test_participantGridsViewModel_updateParticipantsState_when_oneRemoteParticipant_then_gridsCountSingle_onePaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 1 + let sut = makeSUT() + let expectedCount = 1 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: expectedCount)) + wait(for: [expectation], timeout: 1) + } + + func test_participantGridsViewModel_updateParticipantsState_when_twoRemoteParticipant_then_gridsCountTwo_twoPaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 2 + let sut = makeSUT() + let expectedCount = 2 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: expectedCount)) + wait(for: [expectation], timeout: 1) + } + + func test_participantGridsViewModel_updateParticipantsState_when_threeRemoteParticipant_then_gridsCountThree_threePaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 3 + let sut = makeSUT() + let expectedCount = 3 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: expectedCount)) + wait(for: [expectation], timeout: 1) + } + + func test_participantGridsViewModel_updateParticipantsState_when_fourRemoteParticipant_then_gridsCountFour_fourPaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 4 + let sut = makeSUT() + let expectedCount = 4 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: expectedCount)) + wait(for: [expectation], timeout: 1) + } + + func test_participantGridsViewModel_updateParticipantsState_when_fiveRemoteParticipant_then_gridsCountFive_fivePaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 5 + let sut = makeSUT() + let expectedCount = 5 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: expectedCount)) + wait(for: [expectation], timeout: 1) + } + + func test_participantGridsViewModel_updateParticipantsState_when_sixRemoteParticipant_then_gridsCountSix_sixPaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 6 + let sut = makeSUT() + let expectedCount = 6 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: expectedCount)) + wait(for: [expectation], timeout: 1) + } + + func test_participantGridsViewModel_updateParticipantsState_when_sevenRemoteParticipant_then_gridsCountSix_sixPaticipantsCellViewModel() { + let expectation = XCTestExpectation(description: "subscription expection") + let expectedGridCount = 6 + let sut = makeSUT() + let expectedCount = 6 + sut.$gridsCount + .dropFirst() + .sink { value in + XCTAssertEqual(expectedGridCount, value) + XCTAssertEqual(sut.participantsCellViewModelArr.count, expectedCount) + expectation.fulfill() + }.store(in: cancellable) + sut.update(remoteParticipantsState: makeRemoteParticipantState(count: 7)) + wait(for: [expectation], timeout: 1) + } + +} + +extension ParticipantGridsViewModelTests { + func makeSUT() -> ParticipantGridViewModel { + let storeFactory = StoreFactoryMocking() + + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + return ParticipantGridViewModel(compositeViewModelFactory: factoryMocking) + } + + func makeRemoteParticipantState(count: Int = 1, + date: Date = Date()) -> RemoteParticipantsState { + return RemoteParticipantsState(participantInfoList: ParticipantInfoModelBuilder.getArray(count: count), + lastUpdateTimeStamp: date) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantsListViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantsListViewModelTests.swift new file mode 100644 index 000000000..eca0633b6 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Calling/ParticipantsListViewModelTests.swift @@ -0,0 +1,207 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class ParticipantsListViewModelTests: XCTestCase { + var cancellable: CancelBag! + var participantsListViewModel: ParticipantsListViewModel! + + override func setUp() { + cancellable = CancelBag() + participantsListViewModel = ParticipantsListViewModel(localUserState: LocalUserState()) + } + + // MARK: localParticipantsListCellViewModel test + func test_participantsListViewModel_update_when_localUserStateMicOnAndUpdateWithMicOff_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Should publish localParticipantsListCellViewModel") + participantsListViewModel.$localParticipantsListCellViewModel + .dropFirst(2) + .sink(receiveValue: { participantsListCellViewModel in + XCTAssertTrue(participantsListCellViewModel.isMuted) + expectation.fulfill() + }).store(in: cancellable) + + let audioStateOff = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioStateOff) + let participantInfoModel: [ParticipantInfoModel] = [] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: Date()) + + let audioStateOn = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + participantsListViewModel.localParticipantsListCellViewModel = ParticipantsListCellViewModel( + localUserState: LocalUserState(audioState: audioStateOn)) + XCTAssertFalse(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertTrue(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + wait(for: [expectation], timeout: 1) + } + + func test_participantsListViewModel_update_when_localUserStateMicOnAndUpdateWithMicOn_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Should not publish localParticipantsListCellViewModel") + expectation.isInverted = true + participantsListViewModel.$localParticipantsListCellViewModel + .dropFirst(2) + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("micStatus is same and localParticipantsListCellViewModel should not publish") + }).store(in: cancellable) + + let audioStateOn = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioStateOn) + let participantInfoModel: [ParticipantInfoModel] = [] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: Date()) + + participantsListViewModel.localParticipantsListCellViewModel = ParticipantsListCellViewModel( + localUserState: LocalUserState(audioState: audioStateOn)) + XCTAssertFalse(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertFalse(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + wait(for: [expectation], timeout: 1) + } + + func test_participantsListViewModel_update_when_localUserStateMicOffAndUpdateWithMicOn_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Should publish localParticipantsListCellViewModel") + participantsListViewModel.$localParticipantsListCellViewModel + .dropFirst(2) + .sink(receiveValue: { participantsListCellViewModel in + XCTAssertFalse(participantsListCellViewModel.isMuted) + expectation.fulfill() + }).store(in: cancellable) + + let audioStateOn = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioStateOn) + let participantInfoModel: [ParticipantInfoModel] = [] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: Date()) + + let audioStateOff = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + participantsListViewModel.localParticipantsListCellViewModel = ParticipantsListCellViewModel( + localUserState: LocalUserState(audioState: audioStateOff)) + XCTAssertTrue(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertFalse(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + wait(for: [expectation], timeout: 1) + } + + func test_participantsListViewModel_update_when_localUserStateMicOffAndUpdateWithMicOff_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Should not publish localParticipantsListCellViewModel") + expectation.isInverted = true + participantsListViewModel.$localParticipantsListCellViewModel + .dropFirst(2) + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("micStatus is same and localParticipantsListCellViewModel should not publish") + }).store(in: cancellable) + + let audioStateOff = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let localUserState = LocalUserState(audioState: audioStateOff) + let participantInfoModel: [ParticipantInfoModel] = [] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: Date()) + + participantsListViewModel.localParticipantsListCellViewModel = ParticipantsListCellViewModel( + localUserState: LocalUserState(audioState: audioStateOff)) + XCTAssertTrue(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertTrue(participantsListViewModel.localParticipantsListCellViewModel.isMuted) + wait(for: [expectation], timeout: 1) + } + + // MARK: participantsList test + func test_participantsListViewModel_update_when_lastUpdateTimeStampChangedWithParticipantOrderCheck_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Should publish localParticipantsListCellViewModel") + participantsListViewModel.$participantsList + .dropFirst() + .sink(receiveValue: { participantsList in + XCTAssertEqual(participantsList.count, 1) + expectation.fulfill() + }).store(in: cancellable) + + let audioStateOff = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let timestamp = Date() + let localUserState = LocalUserState(audioState: audioStateOff) + let participantInfoModel: [ParticipantInfoModel] = [ + ParticipantInfoModel(displayName: "User Name", + isSpeaking: false, + isMuted: false, + isRemoteUser: false, + userIdentifier: "MockUUID", + recentSpeakingStamp: Date(), + screenShareVideoStreamModel: nil, + cameraVideoStreamModel: nil) + ] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: timestamp.addingTimeInterval(1)) + + let audioStateOn = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + participantsListViewModel.lastUpdateTimeStamp = timestamp + let localParticipant = ParticipantsListCellViewModel( + localUserState: LocalUserState(audioState: audioStateOn)) + participantsListViewModel.localParticipantsListCellViewModel = localParticipant + XCTAssertEqual(participantsListViewModel.participantsList.count, 0) + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertEqual(participantsListViewModel.participantsList.count, 1) + XCTAssertEqual(localParticipant.displayName, "(You)") + XCTAssertEqual(participantsListViewModel.sortedParticipants().first?.displayName, localParticipant.displayName) + XCTAssertEqual(participantsListViewModel.sortedParticipants().last?.displayName, remoteParticipantsState.participantInfoList.first!.displayName) + wait(for: [expectation], timeout: 1) + } + + func test_participantsListViewModel_update_when_lastUpdateTimeStampNotChanged_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Should not publish participantsList") + expectation.isInverted = true + participantsListViewModel.$participantsList + .dropFirst() + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("lastUpdateTimeStamp is same and participantsList should not publish") + }).store(in: cancellable) + + let audioStateOff = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let timestamp = Date() + let localUserState = LocalUserState(audioState: audioStateOff) + let participantInfoModel: [ParticipantInfoModel] = [ + ParticipantInfoModel(displayName: "User Name", + isSpeaking: false, + isMuted: false, + isRemoteUser: false, + userIdentifier: "MockUUID", + recentSpeakingStamp: Date(), + screenShareVideoStreamModel: nil, + cameraVideoStreamModel: nil) + ] + let remoteParticipantsState = RemoteParticipantsState( + participantInfoList: participantInfoModel, lastUpdateTimeStamp: timestamp) + + let audioStateOn = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + participantsListViewModel.lastUpdateTimeStamp = timestamp + participantsListViewModel.localParticipantsListCellViewModel = ParticipantsListCellViewModel( + localUserState: LocalUserState(audioState: audioStateOn)) + XCTAssertEqual(participantsListViewModel.participantsList.count, 0) + participantsListViewModel.update(localUserState: localUserState, + remoteParticipantsState: remoteParticipantsState) + XCTAssertEqual(participantsListViewModel.participantsList.count, 0) + wait(for: [expectation], timeout: 1) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ContainerViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ContainerViewModelTests.swift new file mode 100644 index 000000000..67ac63522 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ContainerViewModelTests.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +// class ContainerViewModelTests: XCTestCase { +// +// var reducer: ReducerMock! +// var middleware: MiddlewareMock! +// var store: StoreMock! +// var callingSDKWrapper: CallingGatewayMock! +// var videoViewManager: VideoViewManagerMock! +// var containerViewModel: ContainerViewModel! +// +// override func setUp() { +// reducer = mock(Reducer.self) +// middleware = mock(Middleware.self) +// given(middleware.apply(dispatch: any(), getState: any())).willReturn( +// { next in +// return { action in +// return next(action) +// } +// }) +// store = mock(Store.self).initialize(reducer: reducer, middlewares: [middleware], state: AppState()) +// callingSDKWrapper = mock(CallingGateway.self) +// videoViewManager = mock(VideoViewManager.self).initialize(callingSDKWrapper: callingSDKWrapper) +// containerViewModel = ContainerViewModel(store: store, videoViewManager: videoViewManager) +// } +// } diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Factories/CompositeViewModelFactoryTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Factories/CompositeViewModelFactoryTests.swift new file mode 100644 index 000000000..316169d7e --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Factories/CompositeViewModelFactoryTests.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class CompositeViewModelFactoryTests: XCTestCase { + + var logger: LoggerMocking! + var mockStoreFactory = StoreFactoryMocking() + var compositeViewModelFactory: ACSCompositeViewModelFactory! + + override func setUp() { + logger = LoggerMocking() + compositeViewModelFactory = ACSCompositeViewModelFactory( + logger: logger, store: mockStoreFactory.store) + } + + func test_compositeViewModelFactory_getCallingViewModel_when_setupViewModelNotNil_then_getSetupViewModel_shouldReturnDifferentSetupViewModel() { + let setupViewModel1 = compositeViewModelFactory.getSetupViewModel() + let setupViewModel2 = compositeViewModelFactory.getSetupViewModel() + XCTAssertEqual(setupViewModel1.id, setupViewModel2.id) + + _ = compositeViewModelFactory.getCallingViewModel() + let setupViewModel3 = compositeViewModelFactory.getSetupViewModel() + XCTAssertNotEqual(setupViewModel1.id, setupViewModel3.id) + } + + func test_compositeViewModelFactory_getSetupViewModel_when_callingViewModelNotNil_then_getCallingViewModel_shouldReturnDifferentCallingViewModel() { + let callingViewModel1 = compositeViewModelFactory.getCallingViewModel() + let callingViewModel2 = compositeViewModelFactory.getCallingViewModel() + XCTAssertEqual(callingViewModel1.id, callingViewModel2.id) + + _ = compositeViewModelFactory.getSetupViewModel() + let callingViewModel3 = compositeViewModelFactory.getCallingViewModel() + XCTAssertNotEqual(callingViewModel1.id, callingViewModel3.id) + } +} + +extension SetupViewModel: Identifiable {} + +extension CallingViewModel: Identifiable {} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/PreviewAreaViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/PreviewAreaViewModelTests.swift new file mode 100644 index 000000000..1a9314185 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/PreviewAreaViewModelTests.swift @@ -0,0 +1,123 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class PreviewAreaViewModelTests: XCTestCase { + var storeFactory: StoreFactoryMocking! + var previewAreaViewModel: PreviewAreaViewModel! + + override func setUp() { + storeFactory = StoreFactoryMocking() + + func dispatch(action: Action) { + storeFactory.store.dispatch(action: action) + } + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + previewAreaViewModel = PreviewAreaViewModel(compositeViewModelFactory: factoryMocking, + dispatchAction: dispatch) + } + + func test_previewAreaViewModel_when_audioPermissionDenied_then_shouldWarnAudioDisabled() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .denied, + cameraPermission: .notAsked), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + let expectedIcon = CompositeIcon.micOff + let expectedText = "Your audio is disabled. To enable, please go to Settings to allow access. You must enable audio to start this call." + + XCTAssertTrue(previewAreaViewModel.showPermissionWarning()) + XCTAssertEqual(previewAreaViewModel.getPermissionWarningIcon(), expectedIcon) + XCTAssertEqual(previewAreaViewModel.getPermissionWarningText(), expectedText) + } + + func test_previewAreaViewModel_when_cameraPermissionDenied_then_shouldWarnCameraDisabled() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .granted, + cameraPermission: .denied), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + let expectedIcon = CompositeIcon.videoOff + let expectedText = "Your camera is disabled. To enable, please go to Settings to allow access." + + XCTAssertTrue(previewAreaViewModel.showPermissionWarning()) + XCTAssertEqual(previewAreaViewModel.getPermissionWarningIcon(), expectedIcon) + XCTAssertEqual(previewAreaViewModel.getPermissionWarningText(), expectedText) + } + + func test_previewAreaViewModel_when_cameraAndAudioPermissionsDenied_then_shouldWarnCameraAudioDisabled() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .denied, + cameraPermission: .denied), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + let expectedIcon = CompositeIcon.warning + let expectedText = "Your camera and audio are disabled. To enable, please go to Settings to allow access. You must enable audio to start this call." + + XCTAssertTrue(previewAreaViewModel.showPermissionWarning()) + XCTAssertEqual(previewAreaViewModel.getPermissionWarningIcon(), expectedIcon) + XCTAssertEqual(previewAreaViewModel.getPermissionWarningText(), expectedText) + } + + func test_previewAreaViewModel_when_audioPermissionsGranted_cameraOff_then_shouldHideWarning_showAvatar() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .granted, + cameraPermission: .notAsked), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + XCTAssertFalse(previewAreaViewModel.showPermissionWarning()) + } + + func test_previewAreaViewModel_when_cameraAndAudioPermissionsGranted_then_shouldHideWarning() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .granted, + cameraPermission: .granted), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + XCTAssertFalse(previewAreaViewModel.showPermissionWarning()) + } + + func test_previewAreaViewModel_when_permissionWarningHidden_cameraOff_then_showAvatar() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .granted, + cameraPermission: .granted), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + XCTAssertFalse(previewAreaViewModel.showPermissionWarning()) + } + + func test_previewAreaViewModel_when_permissionWarningHidden_cameraOn_then_showVideoRender() { + let cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + let appState = AppState(permissionState: PermissionState(audioPermission: .granted, + cameraPermission: .granted), + localUserState: LocalUserState(cameraState: cameraState)) + previewAreaViewModel.update(localUserState: appState.localUserState, permissionState: appState.permissionState) + + XCTAssertFalse(previewAreaViewModel.showPermissionWarning()) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupControlBarViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupControlBarViewModelTests.swift new file mode 100644 index 000000000..2b579d9c8 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupControlBarViewModelTests.swift @@ -0,0 +1,164 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class SetupControlBarViewModelTests: XCTestCase { + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + + var setupControlBarViewModel: SetupControlBarViewModel! + private let timeout: TimeInterval = 10.0 + + override func setUp() { + storeFactory = StoreFactoryMocking() + cancellable = CancelBag() + let logger = LoggerMocking() + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + setupControlBarViewModel = SetupControlBarViewModel(compositeViewModelFactory: factoryMocking, + logger: logger, + dispatchAction: storeFactory.store.dispatch, + localUserState: LocalUserState()) + } + + func test_setupControlBarViewModel_when_videoButtonTapped_then_requestCameraOnIfPreviouslyOff() { + let expectation = XCTestExpectation(description: "Verify Camera On") + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + storeFactory.store.state = AppState(permissionState: PermissionState(cameraPermission: .granted), + localUserState: LocalUserState(cameraState: cameraState)) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + storeFactory.store.$state + .dropFirst() + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is LocalUserAction.CameraPreviewOnTriggered) + + expectation.fulfill() + }.store(in: cancellable) + + setupControlBarViewModel.videoButtonTapped() + + wait(for: [expectation], timeout: timeout) + } + + func test_setupControlBarViewModel_when_videoButtonTapped_then_requestCameraOffIfPreviouslyOn() { + let expectation = XCTestExpectation(description: "Verify Camera Off") + let cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + storeFactory.store.state = AppState(permissionState: PermissionState(cameraPermission: .granted), + localUserState: LocalUserState(cameraState: cameraState)) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + storeFactory.store.$state + .dropFirst() + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is LocalUserAction.CameraOffTriggered) + + expectation.fulfill() + }.store(in: cancellable) + + setupControlBarViewModel.videoButtonTapped() + + wait(for: [expectation], timeout: timeout) + } + + func test_setupControlBarViewModel_when_micButtonTapped_then_requestMicPreviewOnIfPreviouslyOff() { + let expectation = XCTestExpectation(description: "Verify Mic Preview On") + let audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + storeFactory.store.state = AppState(permissionState: PermissionState(audioPermission: .granted), + localUserState: LocalUserState(audioState: audioState)) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + storeFactory.store.$state + .dropFirst() + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is LocalUserAction.MicrophonePreviewOn) + + expectation.fulfill() + }.store(in: cancellable) + + setupControlBarViewModel.microphoneButtonTapped() + + wait(for: [expectation], timeout: timeout) + } + + func test_setupControlBarViewModel_when_micButtonTapped_then_requestMicPreviewOffIfPreviouslyOn() { + let expectation = XCTestExpectation(description: "Verify Mic Preview Off") + let audioState = LocalUserState.AudioState(operation: .on, + device: .receiverSelected) + storeFactory.store.state = AppState(permissionState: PermissionState(audioPermission: .granted), + localUserState: LocalUserState(audioState: audioState)) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + storeFactory.store.$state + .dropFirst() + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is LocalUserAction.MicrophonePreviewOff) + + expectation.fulfill() + }.store(in: cancellable) + + setupControlBarViewModel.microphoneButtonTapped() + + wait(for: [expectation], timeout: timeout) + } + + func test_setupControlBarViewModel_when_selectAudioDeviceButtonTapped_then_audioDeviceSelectionDisplayed() { + setupControlBarViewModel.selectAudioDeviceButtonTapped() + + XCTAssertTrue(setupControlBarViewModel.isAudioDeviceSelectionDisplayed) + } + + func test_setupControlBarViewModel_when_audioPermissionDenied_then_hideSetupControlBar() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + storeFactory.store.state = AppState(permissionState: PermissionState(audioPermission: .denied, + cameraPermission: .granted), + localUserState: LocalUserState(cameraState: cameraState)) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + XCTAssertTrue(setupControlBarViewModel.isAudioDisabled()) + XCTAssertFalse(setupControlBarViewModel.isCameraDisabled()) + } + + func test_setupControlBarViewModel_when_cameraPermissionDenied_then_disableCameraButton() { + let cameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + storeFactory.store.state = AppState(permissionState: PermissionState(audioPermission: .granted, + cameraPermission: .denied), + localUserState: LocalUserState(cameraState: cameraState)) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + XCTAssertFalse(setupControlBarViewModel.isAudioDisabled()) + XCTAssertTrue(setupControlBarViewModel.isCameraDisabled()) + } + + func test_setupControlBarViewModel_when_microphoneDefaultState_then_defaultToOff() { + storeFactory.store.state = AppState(permissionState: PermissionState(audioPermission: .granted), + localUserState: LocalUserState()) + setupControlBarViewModel.update(localUserState: storeFactory.store.state.localUserState, + permissionState: storeFactory.store.state.permissionState) + + XCTAssertEqual(setupControlBarViewModel.micStatus, .off) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupViewModelTests.swift new file mode 100644 index 000000000..4f5a61bbc --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/Setup/SetupViewModelTests.swift @@ -0,0 +1,92 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class SetupViewModelTests: XCTestCase { + + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + var setupViewModel: SetupViewModel! + private let timeout: TimeInterval = 10.0 + + override func setUp() { + storeFactory = StoreFactoryMocking() + cancellable = CancelBag() + + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + setupViewModel = SetupViewModel(compositeViewModelFactory: factoryMocking, + logger: LoggerMocking(), + store: storeFactory.store) + } + + func test_setupViewModel_when_setupViewLoaded_then_shouldAskAudioPermission() { + let expectation = XCTestExpectation(description: "Verify Last Action is Request Audio") + storeFactory.store.$state + .dropFirst(2) + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is PermissionAction.AudioPermissionRequested) + + expectation.fulfill() + }.store(in: cancellable) + storeFactory.store.state = AppState(permissionState: PermissionState(audioPermission: .notAsked)) + setupViewModel.receive(storeFactory.store.state) + setupViewModel.setupAudioPermissions() + + wait(for: [expectation], timeout: timeout) + } + + func test_setupViewModel_when_setupViewLoaded_then_shouldSetupCall() { + let expectation = XCTestExpectation(description: "Verify Last Action is SetupCall") + setupViewModel.setupCall() + + storeFactory.store.$state + .dropFirst() + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is CallingAction.SetupCall) + + expectation.fulfill() + }.store(in: cancellable) + + wait(for: [expectation], timeout: timeout) + } + + func test_setupViewModel_when_startCallButtonTapped_then_shouldCallingViewLaunched() { + let expectation = XCTestExpectation(description: "Verify Last Action is Calling View Launched") + setupViewModel.startCallButtonTapped() + + storeFactory.store.$state + .dropFirst() + .sink { [weak self] _ in + XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.last is CallingViewLaunched) + + expectation.fulfill() + }.store(in: cancellable) + + wait(for: [expectation], timeout: timeout) + } + + func test_startCallButtonViewModel_when_audioPermissionDenied_then_shouldDisableStartCallButton() { + let permissionState = PermissionState(audioPermission: .denied, + cameraPermission: .notAsked) + setupViewModel.startCallButtonViewModel.update(isDisabled: permissionState.audioPermission == .denied) + + XCTAssertTrue(setupViewModel.startCallButtonViewModel.isDisabled) + } + + func test_startCallButtonViewModel_when_audioPermissionGranted_then_shouldEnableStartCallButton() { + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .denied) + setupViewModel.startCallButtonViewModel.update(isDisabled: permissionState.audioPermission == .denied) + + XCTAssertFalse(setupViewModel.startCallButtonViewModel.isDisabled) + + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/AudioDeviceListViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/AudioDeviceListViewModelTests.swift new file mode 100644 index 000000000..9111d1eed --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/AudioDeviceListViewModelTests.swift @@ -0,0 +1,79 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class AudioDeviceListViewModelTests: XCTestCase { + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + var audioDeviceListViewModel: AudioDeviceListViewModel! + + override func setUp() { + storeFactory = StoreFactoryMocking() + cancellable = CancelBag() + + func dispatch(action: Action) { + storeFactory.store.dispatch(action: action) + } + audioDeviceListViewModel = AudioDeviceListViewModel(dispatchAction: dispatch, + localUserState: LocalUserState()) + } + + func test_audioDeviceListViewModel_update_when_audioDeviceListFirstInitialized_then_shouldPopulateAudioDeviceList() { + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .receiverSelected) + XCTAssertTrue(audioDeviceListViewModel.audioDeviceList.isEmpty) + audioDeviceListViewModel.update(audioDeviceStatus: .receiverSelected) + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .receiverSelected) + XCTAssertFalse(audioDeviceListViewModel.audioDeviceList.isEmpty) + } + + func test_audioDeviceListViewModel_update_when_audioDeviceStatusUpdatedToReceiverSelected_then_shouldBePublished() { + let expectation = XCTestExpectation(description: "Should publish audioDeviceStatus") + audioDeviceListViewModel.$audioDeviceStatus + .dropFirst() + .sink(receiveValue: { audioDeviceStatus in + XCTAssertEqual(audioDeviceStatus, .receiverSelected) + expectation.fulfill() + }).store(in: cancellable) + + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .receiverSelected) + audioDeviceListViewModel.update(audioDeviceStatus: .receiverSelected) + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .receiverSelected) + wait(for: [expectation], timeout: 1) + } + + func test_audioDeviceListViewModel_update_when_audioDeviceStatusUpdatedToSpeakerRequested_then_shouldNotBePublished() { + let expectation = XCTestExpectation(description: "Should not publish audioDeviceStatus") + expectation.isInverted = true + audioDeviceListViewModel.$audioDeviceStatus + .dropFirst(2) + .sink(receiveValue: { _ in + expectation.fulfill() + XCTFail("audio device is in the process of switching so audioDeviceStatus should not publish") + }).store(in: cancellable) + + audioDeviceListViewModel.update(audioDeviceStatus: .receiverSelected) + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .receiverSelected) + audioDeviceListViewModel.update(audioDeviceStatus: .speakerRequested) + XCTAssertNotEqual(audioDeviceListViewModel.audioDeviceStatus, .speakerRequested) + wait(for: [expectation], timeout: 1) + } + + func test_audioDeviceListViewModel_update_when_audioDeviceStatusSwitchedFromReceiverToSpeaker_then_shouldUpdateAudioDeviceList() { + audioDeviceListViewModel.update(audioDeviceStatus: .receiverSelected) + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .receiverSelected) + let initialSelection = audioDeviceListViewModel.audioDeviceList.first(where: { $0.isSelected }) + XCTAssertEqual(initialSelection?.title, AudioDeviceType.receiver.name) + + audioDeviceListViewModel.update(audioDeviceStatus: .speakerRequested) + XCTAssertNotEqual(audioDeviceListViewModel.audioDeviceStatus, .speakerRequested) + + audioDeviceListViewModel.update(audioDeviceStatus: .speakerSelected) + XCTAssertEqual(audioDeviceListViewModel.audioDeviceStatus, .speakerSelected) + let updatedSelection = audioDeviceListViewModel.audioDeviceList.first(where: { $0.isSelected }) + XCTAssertEqual(updatedSelection?.title, AudioDeviceType.speaker.name) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/LocalVideoViewModelTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/LocalVideoViewModelTests.swift new file mode 100644 index 000000000..df06c8dd4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Presentation/ViewComponents/LocalVideoViewModelTests.swift @@ -0,0 +1,58 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class LocalVideoViewModelTests: XCTestCase { + var storeFactory: StoreFactoryMocking! + var cancellable: CancelBag! + var localVideoViewModel: LocalVideoViewModel! + + override func setUp() { + storeFactory = StoreFactoryMocking() + cancellable = CancelBag() + + func dispatch(action: Action) { + storeFactory.store.dispatch(action: action) + } + let factoryMocking = CompositeViewModelFactoryMocking(logger: LoggerMocking(), store: storeFactory.store) + localVideoViewModel = LocalVideoViewModel(compositeViewModelFactory: factoryMocking, + logger: LoggerMocking(), + dispatchAction: dispatch) + } + + func test_localVideoViewModel_when_updateWithLocalVideoStreamId_then_videoSteamIdUpdated() { + let cameraState = LocalUserState.CameraState(operation: .on, + device: .front, + transmission: .local) + let permissionState = PermissionState(audioPermission: .granted, + cameraPermission: .granted) + let localUserState = LocalUserState(cameraState: cameraState, + localVideoStreamIdentifier: "videoSteamId") + let appState = AppState(permissionState: permissionState, + localUserState: localUserState) + localVideoViewModel.update(localUserState: appState.localUserState) + + let expectedVideoStreamId = "videoSteamId" + + XCTAssertEqual(localVideoViewModel.localVideoStreamId, expectedVideoStreamId) + } + + // MARK: Camera switch tests + func test_localVideoVideModel_toggleCameraSwitch_when_cameraStatusOn_then_shouldRequestCameraOnTriggered() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + localVideoViewModel.toggleCameraSwitchTapped() + + storeFactory.store.$state + .dropFirst(1) + .sink { [weak self] _ in XCTAssertEqual(self?.storeFactory.actions.count, 1) + XCTAssertTrue(self?.storeFactory.actions.first is LocalUserAction.CameraSwitchTriggered) + expectation.fulfill() + }.store(in: cancellable) + wait(for: [expectation], timeout: 1) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareHandlerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareHandlerTests.swift new file mode 100644 index 000000000..962618b7a --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareHandlerTests.swift @@ -0,0 +1,458 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +import XCTest +import Combine +@testable import AzureCommunicationUI + +class CallingMiddlewareHandlerTests: XCTestCase { + + var callingMiddlewareHandler: CallingMiddlewareHandler! + var mockLogger: LoggerMocking! + var mockCallingService: CallingServiceMocking! + + override func setUp() { + mockCallingService = CallingServiceMocking() + mockLogger = LoggerMocking() + callingMiddlewareHandler = CallingMiddlewareHandler(callingService: mockCallingService, logger: mockLogger) + } + + func test_callingMiddlewareHandler_requestMicMute_then_muteLocalMicCalled() { + callingMiddlewareHandler.requestMicrophoneMute(state: getEmptyState(), dispatch: getEmptyDispatch()) + + XCTAssertTrue(mockCallingService.muteLocalMicCalled) + } + + func test_callingMiddlewareHandler_requestMicUnmute_then_unmuteLocalMicCalled() { + callingMiddlewareHandler.requestMicrophoneUnmute(state: getEmptyState(), dispatch: getEmptyDispatch()) + XCTAssertTrue(mockCallingService.unmuteLocalMicCalled) + } + + func test_callingMiddlewareHandler_requestMicMute_when_returnsError_then_updateMicrophoneStatusIsError() { + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.MicrophoneOffFailed) + } + mockCallingService.error = error + callingMiddlewareHandler.requestMicrophoneMute(state: getEmptyState(), dispatch: dispatch) + } + + func test_callingMiddlewareHandler_requestMicUnmute_when_returnsError_then_updateMicrophoneStatusIsError() { + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.MicrophoneOnFailed) + } + mockCallingService.error = error + callingMiddlewareHandler.requestMicrophoneUnmute(state: getEmptyState(), dispatch: dispatch) + } + + func test_callingMiddlewareHandler_requestCameraOn_when_cameraPermissionNotAsked_then_shouldDispatchCameraPermissionRequested() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + func dispatch(action: Action) { + XCTAssertTrue(action is PermissionAction.CameraPermissionRequested) + expectation.fulfill() + } + let state: AppState = getState(callingState: .connected, + cameraStatus: .off, + cameraDeviceStatus: .front, + cameraPermission: .notAsked) as! AppState + callingMiddlewareHandler.requestCameraOn(state: state, dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_requestCameraOff_then_stopLocalVideoStreamCalled() { + callingMiddlewareHandler.requestCameraOff(state: getEmptyState(), dispatch: getEmptyDispatch()) + XCTAssertTrue(mockCallingService.stopLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_requestCameraOff_when_permissionNotAsked_then_updateCameraStatusOffUpdate() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOffSucceeded) + expectation.fulfill() + } + callingMiddlewareHandler.requestCameraOff(state: getEmptyState(), dispatch: dispatch) + + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_requestCameraOn_then_startLocalVideoStreamCalled() { + callingMiddlewareHandler.requestCameraOn(state: getEmptyState(), dispatch: getEmptyDispatch()) + XCTAssertTrue(mockCallingService.startLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_requestCameraOn_when_noError_then_updateCameraStatusOnUpdate() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + + let videoId = "Identifier" + mockCallingService.videoStreamId = videoId + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOnSucceeded) + switch action { + case let action as LocalUserAction.CameraOnSucceeded: + XCTAssertEqual(action.videoStreamIdentifier, videoId) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + callingMiddlewareHandler.requestCameraOn(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_requestCameraOff_when_returnsError_then_updateCameraStatusIsError() { + let expectation = XCTestExpectation(description: "Request Camera Off Dispatch Action Should Return Error") + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOffFailed) + expectation.fulfill() + } + mockCallingService.error = error + callingMiddlewareHandler.requestCameraOff(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_requestCameraOn_when_returnsError_then_updateCameraStatusIsError() { + let expectation = XCTestExpectation(description: "Request Camera On Dispatch Action Should Return Error") + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOnFailed) + expectation.fulfill() + } + mockCallingService.error = error + callingMiddlewareHandler.requestCameraOn(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_requestCameraSwitch_then_switchCameraCalled() { + callingMiddlewareHandler.requestCameraSwitch(state: getEmptyState(), dispatch: getEmptyDispatch()) + XCTAssertTrue(mockCallingService.switchCameraCalled) + } + + func test_callingMiddlewareHandler_requestCameraSwitch_when_noError_then_updateCameraDeviceStatusOnUpdate() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + + let cameraDevice = CameraDevice.front + mockCallingService.cameraDevice = cameraDevice + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraSwitchSucceeded) + switch action { + case let action as LocalUserAction.CameraSwitchSucceeded: + XCTAssertEqual(action.cameraDevice, cameraDevice) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + callingMiddlewareHandler.requestCameraSwitch(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_requestCameraSwitch_when_returnsError_then_updateCameraDeviceStatusIsError() { + let expectation = XCTestExpectation(description: "Request Camera Switch Dispatch Action Should Return Error") + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraSwitchFailed) + expectation.fulfill() + } + mockCallingService.error = error + callingMiddlewareHandler.requestCameraSwitch(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_endCall_then_endCallCalled() { + callingMiddlewareHandler.endCall(state: getEmptyState(), dispatch: getEmptyDispatch()) + + XCTAssertTrue(mockCallingService.endCallCalled) + } + + func test_callingMiddlewareHandler_startCall_then_startCallCalled() { + callingMiddlewareHandler.startCall(state: getEmptyState(), dispatch: getEmptyDispatch()) + + XCTAssertTrue(mockCallingService.startCallCalled) + } + + func test_callingMiddlewareHandler_endCall_when_returnNSError_then_updateCallError() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + + let error = getError() + let expectedStatus = ErrorEvent(code: CallCompositeErrorCode.callEnd, error: error) + + func dispatch(action: Action) { + XCTAssertTrue(action is ErrorAction.FatalErrorUpdated) + switch action { + case let action as ErrorAction.FatalErrorUpdated: + XCTAssertEqual(action.error.code, expectedStatus.code) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + mockCallingService.error = error + callingMiddlewareHandler.endCall(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_endCall_when_returnsCompositeError_then_updateClientError() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + + let error = CompositeError.invalidSDKWrapper + let expectedError = ErrorEvent(code: CallCompositeErrorCode.callEnd, error: error) + func dispatch(action: Action) { + XCTAssertTrue(action is ErrorAction.FatalErrorUpdated) + switch action { + case let action as ErrorAction.FatalErrorUpdated: + XCTAssertEqual(action.error.code, expectedError.code) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + mockCallingService.error = error + callingMiddlewareHandler.endCall(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_startCall_when_returnsNSError_then_updateCallingCoreError() { + let error = getError() + let expectation = XCTestExpectation(description: "Dispatch the new action") + let expectedStatus = ErrorEvent(code: CallCompositeErrorCode.callJoin, error: error) + func dispatch(action: Action) { + XCTAssertTrue(action is ErrorAction.FatalErrorUpdated) + switch action { + case let action as ErrorAction.FatalErrorUpdated: + XCTAssertEqual(action.error.code, expectedStatus.code) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + mockCallingService.error = error + callingMiddlewareHandler.startCall(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_startCall_when_returnsCompositeError_then_updateClientError() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + let error = CompositeError.invalidSDKWrapper + let expectedError = ErrorEvent(code: CallCompositeErrorCode.callJoin, error: error) + + func dispatch(action: Action) { + XCTAssertTrue(action is ErrorAction.FatalErrorUpdated) + switch action { + case let action as ErrorAction.FatalErrorUpdated: + XCTAssertEqual(action.error.code, expectedError.code) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + mockCallingService.error = error + callingMiddlewareHandler.startCall(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_setupCall_then_setupCallCalled() { + callingMiddlewareHandler.setupCall(state: getEmptyState(), dispatch: getEmptyDispatch()) + + XCTAssertTrue(mockCallingService.setupCallCalled) + } + + func test_callingMiddlewareHandler_setupCall_when_cameraPermissionGranted_then_cameraOnTriggered() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraPreviewOnTriggered) + expectation.fulfill() + } + callingMiddlewareHandler.setupCall(state: getState(callingState: .connected, + cameraStatus: .off, + cameraDeviceStatus: .front, + cameraPermission: .granted), + dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_setupCall_when_cameraPermissionDenied_then_skipCameraOnTriggered() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + expectation.isInverted = true + + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOnTriggered) + expectation.fulfill() + } + callingMiddlewareHandler.setupCall(state: getState(callingState: .connected, + cameraStatus: .off, + cameraDeviceStatus: .front, + cameraPermission: .denied), + dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_setupCall_when_returnsError_then_updateCallingCoreError() { + let error = getError() + let expectation = XCTestExpectation(description: "Dispatch the new action") + let expectedStatus = ErrorEvent(code: CallCompositeErrorCode.callJoin, error: error) + func dispatch(action: Action) { + XCTAssertTrue(action is ErrorAction.FatalErrorUpdated) + switch action { + case let action as ErrorAction.FatalErrorUpdated: + XCTAssertEqual(action.error.code, expectedStatus.code) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + mockCallingService.error = error + callingMiddlewareHandler.setupCall(state: getEmptyState(), dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_enterBackground_when_callConnected_cameraStatusOn_then_stopLocalVideoStreamCalled() { + callingMiddlewareHandler.enterBackground(state: getState(callingState: .connected, + cameraStatus: .on, + cameraDeviceStatus: .front), + dispatch: getEmptyDispatch()) + XCTAssertTrue(mockCallingService.stopLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_enterBackground_when_callNotConnected_then_stopLocalVideoStreamNotCalled() { + callingMiddlewareHandler.enterBackground(state: getState(callingState: .disconnected, + cameraStatus: .on, + cameraDeviceStatus: .front), + dispatch: getEmptyDispatch()) + XCTAssertFalse(mockCallingService.stopLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_enterBackground_when_cameraStatusNotOn_then_stopLocalVideoStreamNotCalled() { + callingMiddlewareHandler.enterBackground(state: getState(callingState: .connected, + cameraStatus: .off, + cameraDeviceStatus: .front), + dispatch: getEmptyDispatch()) + XCTAssertFalse(mockCallingService.stopLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_enterBackground_when_callConnected_cameraStatusOn_noError_then_updateCameraStatusPauseUpdate() { + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraPausedSucceeded) + } + callingMiddlewareHandler.enterBackground(state: getState(callingState: .connected, + cameraStatus: .on, + cameraDeviceStatus: .front), + dispatch: dispatch) + } + + func test_callingMiddlewareHandler_enterBackground_when_callConnected_cameraStatusOn_returnsError_then_updateCameraStatusIsError() { + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraPausedFailed) + } + mockCallingService.error = error + callingMiddlewareHandler.enterBackground(state: getState(callingState: .connected, + cameraStatus: .on, + cameraDeviceStatus: .front), + dispatch: dispatch) + } + + func test_callingMiddlewareHandler_enterForeground_when_callConnected_cameraStatusPaused_then_startLocalVideoStreamCalled() { + callingMiddlewareHandler.enterForeground(state: getState(callingState: .connected, + cameraStatus: .paused, + cameraDeviceStatus: .front), + dispatch: getEmptyDispatch()) + XCTAssertTrue(mockCallingService.startLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_enterForeground_when_callNotStarted_then_startLocalVideoStreamNotCalled() { + callingMiddlewareHandler.enterForeground(state: getState(callingState: .disconnected, + cameraStatus: .paused, + cameraDeviceStatus: .front), + dispatch: getEmptyDispatch()) + XCTAssertFalse(mockCallingService.startLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_enterForeground_when_cameraStatusNotPaused_then_startLocalVideoStreamNotCalled() { + callingMiddlewareHandler.enterForeground(state: getState(callingState: .connected, + cameraStatus: .off, + cameraDeviceStatus: .front), + dispatch: getEmptyDispatch()) + XCTAssertFalse(mockCallingService.startLocalVideoStreamCalled) + } + + func test_callingMiddlewareHandler_enterForeground_when_callConnected_cameraStatusOn_noError_then_updateCameraStatusOnUpdate() { + let expectation = XCTestExpectation(description: "Dispatch the new action") + let id = "identifier" + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOnSucceeded) + switch action { + case let action as LocalUserAction.CameraOnSucceeded: + XCTAssertEqual(action.videoStreamIdentifier, id) + expectation.fulfill() + default: + XCTFail("Should not be default \(action)") + } + } + mockCallingService.videoStreamId = id + callingMiddlewareHandler.enterForeground(state: getState(callingState: .connected, + cameraStatus: .paused, + cameraDeviceStatus: .front), + dispatch: dispatch) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddlewareHandler_enterForeground_when_callConnected_cameraStatusOn_returnsError_then_updateCameraStatusIsError() { + let error = getError() + func dispatch(action: Action) { + XCTAssertTrue(action is LocalUserAction.CameraOnFailed) + } + mockCallingService.error = error + callingMiddlewareHandler.enterForeground(state: getState(callingState: .connected, + cameraStatus: .paused, + cameraDeviceStatus: .front), + dispatch: dispatch) + } +} + +extension CallingMiddlewareHandlerTests { + + private func getEmptyState() -> ReduxState? { + return AppState() + } + + private func getState(callingState: CallingStatus, + cameraStatus: LocalUserState.CameraOperationalStatus, + cameraDeviceStatus: LocalUserState.CameraDeviceSelectionStatus, + cameraPermission: AppPermission.Status = .unknown) -> ReduxState { + let callState = CallingState(status: callingState) + let cameraState = LocalUserState.CameraState(operation: cameraStatus, + device: cameraDeviceStatus, + transmission: .local) + let audioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let localState = LocalUserState(cameraState: cameraState, + audioState: audioState, + displayName: nil, + localVideoStreamIdentifier: nil) + let permissionState = PermissionState(audioPermission: .unknown, + cameraPermission: cameraPermission) + return AppState(callingState: callState, + permissionState: permissionState, + localUserState: localState, + lifeCycleState: LifeCycleState(), + remoteParticipantsState: .init()) + } + + private func getEmptyDispatch() -> ActionDispatch { + return { _ in } + } + + private func getError() -> Error { + return NSError(domain: "", code: 100, userInfo: [ + NSLocalizedDescriptionKey: "Error" + ]) + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareTests.swift new file mode 100644 index 000000000..08d5aae95 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Middleware/CallingMiddlewareTests.swift @@ -0,0 +1,141 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation + +import XCTest +import Combine +@testable import AzureCommunicationUI + +class CallingMiddlewareTests: XCTestCase { + + var mockMiddlewareHandler: CallingMiddlewareHandlerMocking! + var callingMiddleware: CallingMiddleware! + + override func setUp() { + mockMiddlewareHandler = CallingMiddlewareHandlerMocking() + callingMiddleware = CallingMiddleware(callingMiddlewareHandler: mockMiddlewareHandler) + } + + func test_callingMiddleware_apply_when_setupCallCallingAction_then_handlerSetupCallBeingCalled() { + + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(CallingAction.SetupCall()) + + XCTAssertTrue(mockMiddlewareHandler.setupCallWasCalled) + } + + func test_callingMiddleware_apply_when_startCallCallingAction_then_handlerStartCallBeingCalled() { + + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(CallingAction.CallStartRequested()) + + XCTAssertTrue(mockMiddlewareHandler.startCallWasCalled) + } + + func test_callingMiddleware_apply_when_endCallCallingAction_then_handlerEndCallBeingCalled() { + + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(CallingAction.CallEndRequested()) + + XCTAssertTrue(mockMiddlewareHandler.endCallWasCalled) + } + + func test_callingMiddleware_apply_when_requestMicrophoneOffLocalUserAction_then_handlerRequestMicMuteCalled() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(LocalUserAction.MicrophoneOffTriggered()) + + XCTAssertTrue(mockMiddlewareHandler.requestMicMuteCalled) + } + + func test_callingMiddleware_apply_when_requestMicrophoneOnLocalUserAction_then_handlerRequestMicUnmuteCalled() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(LocalUserAction.MicrophoneOnTriggered()) + + XCTAssertTrue(mockMiddlewareHandler.requestMicUnmuteCalled) + } + + func test_callingMiddleware_apply_when_cameraPermissionGranted_then_handlerOnCameraPermissionIsSet() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(PermissionAction.CameraPermissionGranted()) + + XCTAssertTrue(mockMiddlewareHandler.cameraPermissionSetCalled) + } + + func test_callingMiddleware_apply_when_requestCameraPreviewOn_then_handlerRequestCameraPreviewOnCalled() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(LocalUserAction.CameraPreviewOnTriggered()) + + XCTAssertTrue(mockMiddlewareHandler.requestCameraPreviewOnCalled) + } + + func test_callingMiddleware_apply_when_requestCameraOnLocalUserAction_then_handlerRequestCameraOnCalled() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(LocalUserAction.CameraOnTriggered()) + + XCTAssertTrue(mockMiddlewareHandler.requestCameraOnCalled) + } + + func test_callingMiddleware_apply_when_requestCameraOffLocalUserAction_then_handlerRequestCameraOffCalled() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + middlewareDispatch(getEmptyDispatch())(LocalUserAction.CameraOffTriggered()) + + XCTAssertTrue(mockMiddlewareHandler.requestCameraOffCalled) + } + + func test_callingMiddleware_apply_when_requestCameraOn_then_nextActionDispatchCameraOnTriggered() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + + let action = LocalUserAction.CameraOnTriggered() + let expectation = XCTestExpectation(description: "Verify is same action Type") + let nextDispatch = getAssertSameActionDispatch(action: action, expectation: expectation) + middlewareDispatch(nextDispatch)(action) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddleware_apply_when_requestMicOn_then_nextActionDispatchMicrophoneOnTriggered() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + + let action = LocalUserAction.MicrophoneOnTriggered() + let expectation = XCTestExpectation(description: "Verify is same action Type") + let nextDispatch = getAssertSameActionDispatch(action: action, expectation: expectation) + middlewareDispatch(nextDispatch)(action) + wait(for: [expectation], timeout: 1) + } + + func test_callingMiddleware_apply_when_enterForeground_then_nextActionDispatchEnterForeground() { + let middlewareDispatch = getEmptyCallingMiddlewareFunction() + + let action = LifecycleAction.ForegroundEntered() + let expectation = XCTestExpectation(description: "Verify is same action Type") + let nextDispatch = getAssertSameActionDispatch(action: action, expectation: expectation) + middlewareDispatch(nextDispatch)(action) + wait(for: [expectation], timeout: 1) + } + +} + +extension CallingMiddlewareTests { + + private func getEmptyState() -> ReduxState? { + return AppState() + } + private func getEmptyDispatch() -> ActionDispatch { + return { _ in } + } + + private func getEmptyCallingMiddlewareFunction() -> (@escaping ActionDispatch) -> ActionDispatch { + + return callingMiddleware.apply(dispatch: getEmptyDispatch(), getState: getEmptyState) + } + + private func getAssertSameActionDispatch(action: Action, expectation: XCTestExpectation) -> ActionDispatch { + return { nextAction in + XCTAssertTrue(type(of: action) == type(of: nextAction)) + expectation.fulfill() + } + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/AppStateReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/AppStateReducerTests.swift new file mode 100644 index 000000000..4c1987e97 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/AppStateReducerTests.swift @@ -0,0 +1,211 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class AppStateReducerTests: XCTestCase { + + // MARK: reduce + func test_appStateReducer_reduce_when_notAppState_then_return() { + let mockState = StateMocking() + let sut = getSUT() + let result = sut.reduce(mockState, ActionMocking()) + XCTAssert(result is StateMocking) + } + + func test_appStateReducer_reducePermissionState_then_permissionReducerCalled_stateUpdated() { + let oldPermissionState = PermissionState(audioPermission: .denied, cameraPermission: .denied) + let mockSubReducer = ReducerMocking() + let expectedPermissionState = AppPermission.Status.granted + let newPermissionState = PermissionState(audioPermission: expectedPermissionState, cameraPermission: expectedPermissionState) + mockSubReducer.outputState = newPermissionState + + let state = getAppState(permissionState: oldPermissionState) + let sut = getSUT(permissionReducer: mockSubReducer) + let result = sut.reduce(state, ActionMocking()) + guard let result = result as? AppState else { + XCTFail() + return + } + XCTAssertEqual(result.permissionState.cameraPermission, expectedPermissionState) + XCTAssertEqual(result.permissionState.audioPermission, expectedPermissionState) + XCTAssert(mockSubReducer.inputAction is ActionMocking) + } + + func test_appStateReducer_reduceLocalUserState_then_localUserReducerCalled_stateUpdated() { + let oldCameraState = LocalUserState.CameraState(operation: .off, + device: .front, + transmission: .local) + let oldAudioState = LocalUserState.AudioState(operation: .off, + device: .receiverSelected) + let oldLocalUserState = LocalUserState(cameraState: oldCameraState, + audioState: oldAudioState, + displayName: "", + localVideoStreamIdentifier: "") + let mockSubReducer = ReducerMocking() + let expectedCameraStatus = LocalUserState.CameraOperationalStatus.on + let expectedCameraDeviceStatus = LocalUserState.CameraDeviceSelectionStatus.front + let expectedMicStatus = LocalUserState.AudioOperationalStatus.on + let expectedDisplayName = "IAmExpected" + let expectedVideoStreamId = "IAmExpectedVideoId" + let expectedCameraState = LocalUserState.CameraState(operation: expectedCameraStatus, + device: expectedCameraDeviceStatus, + transmission: .local) + let expectedAudioState = LocalUserState.AudioState(operation: expectedMicStatus, + device: .receiverSelected) + + let newLocalUserState = LocalUserState(cameraState: expectedCameraState, + audioState: expectedAudioState, + displayName: expectedDisplayName, + localVideoStreamIdentifier: expectedVideoStreamId) + mockSubReducer.outputState = newLocalUserState + + let state = getAppState(localUserState: oldLocalUserState) + let sut = getSUT(localUserReducer: mockSubReducer) + let result = sut.reduce(state, ActionMocking()) + guard let result = result as? AppState else { + XCTFail() + return + } + XCTAssertEqual(result.localUserState.cameraState.operation, expectedCameraStatus) + XCTAssertEqual(result.localUserState.cameraState.device, expectedCameraDeviceStatus) + XCTAssertEqual(result.localUserState.audioState.operation, expectedMicStatus) + XCTAssertEqual(result.localUserState.localVideoStreamIdentifier, expectedVideoStreamId) + XCTAssertEqual(result.localUserState.displayName, expectedDisplayName) + XCTAssert(mockSubReducer.inputAction is ActionMocking) + } + + func test_appStateReducer_reduceLifeCycleState_then_lifeCycleReducerCalled_stateUpdated() { + let oldLifeCycleState = LifeCycleState(currentStatus: .background) + let mockSubReducer = ReducerMocking() + let expectedState = LifeCycleState.AppStatus.foreground + + let newLifeCycleState = LifeCycleState(currentStatus: expectedState) + mockSubReducer.outputState = newLifeCycleState + + let state = getAppState(lifeCycleState: oldLifeCycleState) + let sut = getSUT(lifeCycleReducer: mockSubReducer) + let result = sut.reduce(state, ActionMocking()) + guard let result = result as? AppState else { + XCTFail() + return + } + XCTAssertEqual(result.lifeCycleState.currentStatus, expectedState) + + XCTAssert(mockSubReducer.inputAction is ActionMocking) + } + + func test_appStateReducer_reduceCallingState_then_callingStateReducerCalled_stateUpdated() { + let oldState = CallingState(status: .connected) + let mockSubReducer = ReducerMocking() + let expectedState = CallingStatus.disconnected + + let newState = CallingState(status: expectedState) + mockSubReducer.outputState = newState + + let state = getAppState(callingState: oldState) + let sut = getSUT(callingReducer: mockSubReducer) + let result = sut.reduce(state, ActionMocking()) + guard let result = result as? AppState else { + XCTFail() + return + } + XCTAssertEqual(result.callingState.status, expectedState) + + XCTAssert(mockSubReducer.inputAction is ActionMocking) + } + + func test_appStateReducer_reduceNaviState_then_naviStateReducerCalled_stateUpdated() { + let oldState = NavigationState(status: .setup) + let mockSubReducer = ReducerMocking() + let expectedState = NavigationState(status: .inCall) + + mockSubReducer.outputState = expectedState + + let state = getAppState(navigationState: oldState) + let sut = getSUT(navigationReducer: mockSubReducer) + let result = sut.reduce(state, ActionMocking()) + guard let result = result as? AppState else { + XCTFail() + return + } + XCTAssertEqual(result.navigationState, expectedState) + + XCTAssert(mockSubReducer.inputAction is ActionMocking) + } + + func test_appStateReducer_reduceErrorState_then_errorStateReducerCalled_stateUpdated() { + let oldState = ErrorState(errorCode: "") + let mockSubReducer = ReducerMocking() + let expectedState = ErrorState(error: nil, errorCode: CallCompositeErrorCode.callJoin, errorCategory: .callState) + + mockSubReducer.outputState = expectedState + + let state = getAppState(errorState: oldState) + let sut = getSUT(errorReducer: mockSubReducer) + let result = sut.reduce(state, ActionMocking()) + + guard let result = result as? AppState else { + XCTFail() + return + } + + XCTAssertEqual(result.errorState, expectedState) + XCTAssert(mockSubReducer.inputAction is ActionMocking) + } + + func test_appStateReducer_reduce_when_participantListUpdate_then_stateUpdated() { + let userId = UUID().uuidString + let infoModel = ParticipantInfoModelBuilder.get(participantIdentifier: userId, + videoStreamId: "", + displayName: "", + isSpeaking: false, + recentSpeakingStamp: Date()) + let action = ParticipantListUpdated(participantsInfoList: [infoModel]) + let sut = getSUT() + let state = getAppState() + let result = sut.reduce(state, action) + guard let result = result as? AppState else { + XCTFail() + return + } + XCTAssertEqual(result.remoteParticipantsState.participantInfoList.count, 1) + XCTAssertEqual(result.remoteParticipantsState.participantInfoList.first?.userIdentifier, userId) + } +} + +extension AppStateReducerTests { + func getSUT(permissionReducer: Reducer = ReducerMocking(), + localUserReducer: Reducer = ReducerMocking(), + lifeCycleReducer: Reducer = ReducerMocking(), + callingReducer: Reducer = ReducerMocking(), + navigationReducer: Reducer = ReducerMocking(), + errorReducer: Reducer = ReducerMocking()) -> AppStateReducer { + return AppStateReducer(permissionReducer: permissionReducer, + localUserReducer: localUserReducer, + lifeCycleReducer: lifeCycleReducer, + callingReducer: callingReducer, + navigationReducer: navigationReducer, + errorReducer: errorReducer) + } + + func getAppState(callingState: CallingState = .init(), + permissionState: PermissionState = .init(), + localUserState: LocalUserState = .init(), + lifeCycleState: LifeCycleState = .init(), + navigationState: NavigationState = .init(), + remoteParticipantsState: RemoteParticipantsState = .init(), + errorState: ErrorState = .init()) -> AppState { + return AppState(callingState: callingState, + permissionState: permissionState, + localUserState: localUserState, + lifeCycleState: lifeCycleState, + navigationState: navigationState, + remoteParticipantsState: remoteParticipantsState, + errorState: errorState) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/CallingReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/CallingReducerTests.swift new file mode 100644 index 000000000..dce40c4e4 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/CallingReducerTests.swift @@ -0,0 +1,124 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class CallingReducerTests: XCTestCase { + func test_callingReducer_reduce_when_notCallingState_then_return() { + let state = StateMocking() + let action = CallingAction.StateUpdated(status: .connected) + let sut = getSUT() + let resultState = sut.reduce(state, action) + + XCTAssert(resultState is StateMocking) + } + + func test_callingReducer_reduce_when_callingActionStateUpdated_then_stateUpdated() { + let expectedState = CallingStatus.connected + let state = CallingState(status: .disconnected) + let action = CallingAction.StateUpdated(status: expectedState) + let sut = getSUT() + let resultState = sut.reduce(state, action) + + guard let resultState = resultState as? CallingState else { + XCTFail() + return + } + XCTAssertEqual(resultState.status, expectedState) + } + + func test_callingReducer_reduce_when_mockingAction_then_stateNotUpdate() { + let expectedState = CallingStatus.disconnected + let state = CallingState(status: expectedState) + let action = ActionMocking() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? CallingState else { + XCTFail() + return + } + XCTAssertEqual(resultState.status, expectedState) + } + + func test_callingReducer_reduce_when_callingActionRecordingStateUpdatedTrue_then_recordingStateUpdatedTrue() { + let expectedState = CallingState(status: .connected, + isRecordingActive: true, + isTranscriptionActive: false) + let state = CallingState(status: .connected, + isRecordingActive: false, + isTranscriptionActive: false) + let action = CallingAction.RecordingStateUpdated(isRecordingActive: true) + let sut = getSUT() + let resultState = sut.reduce(state, action) + + guard let resultState = resultState as? CallingState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } + + func test_callingReducer_reduce_when_callingActionRecordingStateUpdatedFalse_then_recordingStateUpdatedFalse() { + let expectedState = CallingState(status: .connected, + isRecordingActive: false, + isTranscriptionActive: false) + let state = CallingState(status: .connected, + isRecordingActive: true, + isTranscriptionActive: false) + let action = CallingAction.RecordingStateUpdated(isRecordingActive: false) + let sut = getSUT() + let resultState = sut.reduce(state, action) + + guard let resultState = resultState as? CallingState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } + + func test_callingReducer_reduce_when_callingActionTranscriptionStateUpdatedTrue_then_transcriptionStateUpdatedTrue() { + let expectedState = CallingState(status: .connected, + isRecordingActive: false, + isTranscriptionActive: true) + let state = CallingState(status: .connected, + isRecordingActive: false, + isTranscriptionActive: false) + let action = CallingAction.TranscriptionStateUpdated(isTranscriptionActive: true) + let sut = getSUT() + let resultState = sut.reduce(state, action) + + guard let resultState = resultState as? CallingState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } + + func test_callingReducer_reduce_when_callingActionTranscriptionStateUpdatedFalse_then_transcriptionStateUpdatedFalse() { + let expectedState = CallingState(status: .connected, + isRecordingActive: false, + isTranscriptionActive: false) + let state = CallingState(status: .connected, + isRecordingActive: false, + isTranscriptionActive: true) + let action = CallingAction.TranscriptionStateUpdated(isTranscriptionActive: false) + let sut = getSUT() + let resultState = sut.reduce(state, action) + + guard let resultState = resultState as? CallingState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } +} + +extension CallingReducerTests { + private func getSUT() -> CallingReducer { + return CallingReducer() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/ErrorReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/ErrorReducerTests.swift new file mode 100644 index 000000000..ff68432c2 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/ErrorReducerTests.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class ErrorReducerTests: XCTestCase { + override func setUp() { } + + func test_handleErrorReducer_reduce_when_notErrorState_then_return() { + let state = StateMocking() + let action = ErrorAction.FatalErrorUpdated(error: ErrorEvent(code: "", + error: nil), + errorCode: "") + let sut = getSUT() + + let resultState = sut.reduce(state, action) + XCTAssert(resultState is StateMocking) + } + + func test_handleErrorReducer_reduce_when_fatalErrorUpdated_then_returnErrorState() { + let state = ErrorState(error: nil, errorCode: CallCompositeErrorCode.callJoin, errorCategory: .callState) + let error = ErrorEvent(code: CallCompositeErrorCode.callJoin, error: nil) + + let action = ErrorAction.FatalErrorUpdated(error: error, + errorCode: CallCompositeErrorCode.callJoin) + let sut = getSUT() + + let resultState = sut.reduce(state, action) + XCTAssertTrue(resultState is ErrorState) + guard let errorState = resultState as? ErrorState else { + XCTFail() + return + } + + XCTAssertEqual(errorState.errorCode, CallCompositeErrorCode.callJoin) + } +} + +extension ErrorReducerTests { + private func getSUT() -> ErrorReducer { + return ErrorReducer() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LifeCycleReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LifeCycleReducerTests.swift new file mode 100644 index 000000000..c984e6335 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LifeCycleReducerTests.swift @@ -0,0 +1,65 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class LifeCycleReducerTests: XCTestCase { + func test_lifeCycleReducer_reduce_when_notLocalUserState_then_return() { + let state = StateMocking() + let action = LocalUserAction.MicrophoneOffTriggered() + let sut = getSUT() + let resultState = sut.reduce(state, action) + + XCTAssert(resultState is StateMocking) + } + + func test_lifeCycleReducer_reduce_when_foregroundEnteredAction_then_stateUpdated() { + let expectedState = LifeCycleState.AppStatus.foreground + let state = LifeCycleState(currentStatus: .background) + let action = LifecycleAction.ForegroundEntered() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? LifeCycleState else { + XCTFail() + return + } + XCTAssertEqual(resultState.currentStatus, expectedState) + } + + func test_lifeCycleReducer_reduce_when_backgroundEnteredAction_then_stateUpdated() { + let expectedState = LifeCycleState.AppStatus.background + let state = LifeCycleState(currentStatus: .foreground) + let action = LifecycleAction.BackgroundEntered() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? LifeCycleState else { + XCTFail() + return + } + XCTAssertEqual(resultState.currentStatus, expectedState) + } + + func test_lifeCycleReducer_reduce_when_mockingAction_then_stateNotUpdate() { + let expectedState = LifeCycleState.AppStatus.background + let state = LifeCycleState(currentStatus: expectedState) + let action = ActionMocking() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? LifeCycleState else { + XCTFail() + return + } + XCTAssertEqual(resultState.currentStatus, expectedState) + } +} + +extension LifeCycleReducerTests { + func getSUT() -> LifeCycleReducer { + return LifeCycleReducer() + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LocalUserReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LocalUserReducerTests.swift new file mode 100644 index 000000000..50a9e0939 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/LocalUserReducerTests.swift @@ -0,0 +1,203 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class LocalUserReducerTests: XCTestCase { + func test_localUserReducer_reduce_when_notLocalUserState_then_return() { + let state = StateMocking() + let action = LocalUserAction.MicrophoneOffTriggered() + let sut = getSUT() + let resultState = sut.reduce(state, action) + + XCTAssert(resultState is StateMocking) + } + + func test_localUserReducer_reduce_when_localUserActionUpdateMicStateUpdated_then_localUserMuted() { + let state = LocalUserState() + let action = LocalUserAction.MicrophoneMuteStateUpdated(isMuted: true) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.operation, .off) + } + + func test_localUserReducer_reduce_when_localUserActionUpdateMicStateUpdated_then_localUserUnMuted() { + let state = LocalUserState() + let action = LocalUserAction.MicrophoneMuteStateUpdated(isMuted: false) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.operation, .on) + } + + func test_localUserReducer_reduce_when_localUserActionUpdateCameraStatusOn_then_micStatusIsOn() { + let state = LocalUserState() + let expectedVideoId = "expected" + let action = LocalUserAction.CameraOnSucceeded(videoStreamIdentifier: expectedVideoId) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.cameraState.operation, .on) + XCTAssertEqual(resultState.localVideoStreamIdentifier, expectedVideoId) + } + + func test_localUserReducer_reduce_when_localUserActionUpdateCameraStatusOff_then_micStatusIsOff() { + let state = LocalUserState() + let action = LocalUserAction.CameraOffSucceeded() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.cameraState.operation, .off) + } + + func test_localUserReducer_reduce_when_localUserActionMicrophoneOnRequested_then_micStatusSwitching() { + let state = LocalUserState() + let action = LocalUserAction.MicrophoneOnTriggered() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.operation, .pending) + } + + func test_localUserReducer_reduce_when_localUserActionMicrophoneOffRequested_then_micStatusSwitching() { + let state = LocalUserState() + let action = LocalUserAction.MicrophoneOffTriggered() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.operation, .pending) + } + + func test_localUserReducer_reduce_when_localUserActionMicrophonePreviewOn_then_micStatusOn() { + let state = LocalUserState() + let action = LocalUserAction.MicrophonePreviewOn() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.operation, .on) + } + + func test_localUserReducer_reduce_when_localUserActionMicrophonePreviewOff_then_micStatusOff() { + let state = LocalUserState() + let action = LocalUserAction.MicrophonePreviewOff() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.operation, .off) + } + + func test_localUserReducer_reduce_when_localUserActionCameraSwitchTriggered_then_cameraDeviceStatusIsSwitching() { + let state = LocalUserState() + let expectedCameraDeviceStatus = LocalUserState.CameraDeviceSelectionStatus.switching + let action = LocalUserAction.CameraSwitchTriggered() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.cameraState.device, expectedCameraDeviceStatus) + } + + func test_localUserReducer_reduce_when_localUserActionCameraSwitchSuccessToBack_then_cameraDeviceStatusIsBack() { + let state = LocalUserState() + let expectedCameraDeviceStatus = LocalUserState.CameraDeviceSelectionStatus.back + let action = LocalUserAction.CameraSwitchSucceeded(cameraDevice: .back) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.cameraState.device, expectedCameraDeviceStatus) + } + + func test_localUserReducer_reduce_when_localUserActionCameraSwitchFail_then_cameraDeviceStatusIsError() { + let state = LocalUserState() + let expectedCameraDeviceStatus = LocalUserState.CameraDeviceSelectionStatus.error(ErrorMocking.mockError) + let action = LocalUserAction.CameraSwitchFailed(error: ErrorMocking.mockError) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.cameraState.device, expectedCameraDeviceStatus) + } + + func test_localUserReducer_reduce_when_localUserActionAudioDeviceChangeRequested_then_audioDeviceStatusIsSpeakerRequested() { + let state = LocalUserState() + let action = LocalUserAction.AudioDeviceChangeRequested(device: .speaker) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.device, .speakerRequested) + } + + func test_localUserReducer_reduce_when_localUserAudioDeviceChangeRequested_then_audioDeviceStatusIsReceiverRequested() { + let state = LocalUserState() + let action = LocalUserAction.AudioDeviceChangeRequested(device: .receiver) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.device, .receiverRequested) + } + + func test_localUserReducer_reduce_when_localUserActionAudioDeviceChangeSucceeded_then_audioDeviceStatusIsSpeakerSelected() { + let state = LocalUserState() + let action = LocalUserAction.AudioDeviceChangeSucceeded(device: .speaker) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.device, .speakerSelected) + } + + func test_localUserReducer_reduce_when_localUserActionAudioDeviceChangeSucceeded_then_audioDeviceStatusIsReceiverSelected() { + let state = LocalUserState() + let action = LocalUserAction.AudioDeviceChangeSucceeded(device: .receiver) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.device, .receiverSelected) + } + + func test_localUserReducer_reduce_when_localUserActionAudioDeviceChangeFailed_then_audioDeviceStatusIsError() { + let state = LocalUserState() + let expectedAudioDeviceStatus = LocalUserState.AudioDeviceSelectionStatus.error(ErrorMocking.mockError) + let action = LocalUserAction.AudioDeviceChangeFailed(error: ErrorMocking.mockError) + let sut = getSUT() + let resultState = sut.reduce(state, action) as! LocalUserState + + XCTAssertEqual(resultState.audioState.device, expectedAudioDeviceStatus) + } + + func test_localUserReducer_reduce_when_mockingAction_then_stateNotUpdate() { + let expectedVideoId = "expected" + + let expectedCameraStatus = LocalUserState.CameraOperationalStatus.off + let expectedCameraDeviceStatus = LocalUserState.CameraDeviceSelectionStatus.front + let expectedMicStatus = LocalUserState.AudioOperationalStatus.off + let expectedCameraState = LocalUserState.CameraState(operation: expectedCameraStatus, + device: expectedCameraDeviceStatus, + transmission: .local) + let expectedAudioState = LocalUserState.AudioState(operation: expectedMicStatus, + device: .receiverSelected) + let state = LocalUserState(cameraState: expectedCameraState, + audioState: expectedAudioState, + displayName: "", + localVideoStreamIdentifier: expectedVideoId) + let action = ActionMocking() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? LocalUserState else { + XCTFail() + return + } + XCTAssertEqual(resultState.cameraState.operation, expectedCameraStatus) + XCTAssertEqual(resultState.cameraState.device, expectedCameraDeviceStatus) + XCTAssertEqual(resultState.audioState.operation, expectedMicStatus) + XCTAssertEqual(resultState.localVideoStreamIdentifier, expectedVideoId) + } +} + +extension LocalUserReducerTests { + func getSUT() -> LocalUserReducer { + return LocalUserReducer() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/NavigationReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/NavigationReducerTests.swift new file mode 100644 index 000000000..261414f33 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/NavigationReducerTests.swift @@ -0,0 +1,76 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class NavigationReducerTests: XCTestCase { + func test_navigationReducer_reduce_when_notNavigationStatus_then_return() { + let state = StateMocking() + let action = CallingViewLaunched() + let sut = getSUT() + let resultState = sut.reduce(state, action) + + XCTAssert(resultState is StateMocking) + } + + func test_navigationReducer_reduce_when_callingActionStateUpdatedNotDisconnected_then_stateNotUpdated() { + let expectedState = NavigationState(status: .setup) + let state = NavigationState(status: .setup) + let action = CallingAction.StateUpdated(status: .connected) + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? NavigationState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } + + func test_navigationReducer_reduce_when_compositexitaction_then_stateExitUpdated() { + let expectedState = NavigationState(status: .exit) + let state = NavigationState(status: .setup) + let action = CompositeExitAction() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? NavigationState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } + + func test_navigationReducer_reduce_when_callingViewLaunched_then_stateinCallUpdated() { + let expectedState = NavigationState(status: .inCall) + let state = NavigationState(status: .exit) + let action = CallingViewLaunched() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? NavigationState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } + + func test_navigationReducer_reduce_when_mockingAction_then_stateNotUpdate() { + let expectedState = NavigationState(status: .inCall) + let action = ActionMocking() + let sut = getSUT() + let resultState = sut.reduce(expectedState, action) + guard let resultState = resultState as? NavigationState else { + XCTFail() + return + } + XCTAssertEqual(resultState, expectedState) + } +} + +extension NavigationReducerTests { + private func getSUT() -> NavigationReducer { + return NavigationReducer() + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/PermissionReducerTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/PermissionReducerTests.swift new file mode 100644 index 000000000..14c5069ab --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Redux/Reducer/PermissionReducerTests.swift @@ -0,0 +1,98 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class PermissionReducerTests: XCTestCase { + + func test_permissionReducer_reduce_when_notPermissionState_then_return() { + let state = StateMocking() + let action = PermissionAction.AudioPermissionGranted() + let sut = getSUT() + let resultState = sut.reduce(state, action) + + XCTAssert(resultState is StateMocking) + } + + func test_permissionReducer_reduce_when_audioPermissionSet_shouldReturnAudioPermissionGranted() { + let state = PermissionState() + let action = PermissionAction.AudioPermissionGranted() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! PermissionState + + XCTAssertEqual(resultState.audioPermission, .granted) + } + + func test_permissionReducer_reduce_when_audioPermissionRequest_shouldReturnAudioPermissionRequesting() { + let state = PermissionState() + let action = PermissionAction.AudioPermissionRequested() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! PermissionState + + XCTAssertEqual(resultState.audioPermission, .requesting) + } + + func test_permissionReducer_reduce_when_audioPermissionNotAsked_shouldReturnAudioPermissionNotAsked() { + let state = PermissionState() + let action = PermissionAction.AudioPermissionNotAsked() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! PermissionState + + XCTAssertEqual(resultState.audioPermission, .notAsked) + } + + func test_permissionReducer_reduce_when_cameraPermissionSet_shouldReturnCameraPermissionGranted() { + let state = PermissionState() + let action = PermissionAction.CameraPermissionGranted() + let sut = getSUT() + let resultState = sut.reduce(state, action) as! PermissionState + + XCTAssertEqual(resultState.cameraPermission, .granted) + } + + func test_permissionReducer_reduce_when_cameraPermissionRequest_shouldReturnCameraPermissionRequesting() { + let state = PermissionState() + let action = PermissionAction.CameraPermissionRequested() + + let sut = getSUT() + let resultState = sut.reduce(state, action) as! PermissionState + + XCTAssertEqual(resultState.cameraPermission, .requesting) + } + + func test_permissionReducer_reduce_when_cameraPermissionNotAsked_shouldReturnCameraPermissionNotAsked() { + let state = PermissionState() + let action = PermissionAction.CameraPermissionNotAsked() + + let sut = getSUT() + let resultState = sut.reduce(state, action) as! PermissionState + + XCTAssertEqual(resultState.cameraPermission, .notAsked) + } + + func test_permissionReducer_reduce_when_mockingAction_then_stateNotUpdate() { + let expectedState = AppPermission.Status.granted + let state = PermissionState(audioPermission: expectedState, + cameraPermission: expectedState) + let action = ActionMocking() + let sut = getSUT() + let resultState = sut.reduce(state, action) + guard let resultState = resultState as? PermissionState else { + XCTFail() + return + } + XCTAssertEqual(resultState.cameraPermission, expectedState) + XCTAssertEqual(resultState.audioPermission, expectedState) + } +} + +extension PermissionReducerTests { + private func getSUT() -> PermissionReducer { + return PermissionReducer() + } + +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Service/CallingServiceTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Service/CallingServiceTests.swift new file mode 100644 index 000000000..f50b551e7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Service/CallingServiceTests.swift @@ -0,0 +1,95 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +import Combine +@testable import AzureCommunicationUI + +class CallingServiceTests: XCTestCase { + + var logger: LoggerMocking! + var callingSDKWrapper: CallingSDKWrapperMocking! + var callingService: ACSCallingService! + var cancellable: CancelBag! + + override func setUp() { + cancellable = CancelBag() + logger = LoggerMocking() + callingSDKWrapper = CallingSDKWrapperMocking() + callingService = ACSCallingService(logger: logger, callingSDKWrapper: callingSDKWrapper) + } + + func test_callingService_setupCall_shouldCallcallingSDKWrapperSetupCall() { + _ = callingService.setupCall() + + XCTAssertTrue(callingSDKWrapper.setupCallWasCalled()) + } + + func test_callingService_startCall_shouldCallcallingSDKWrapperStartCall() { + _ = callingService.startCall(isCameraPreferred: false, isAudioPreferred: false) + + XCTAssertTrue(callingSDKWrapper.startCallWasCalled()) + } + + func test_callingService_endCall_shouldCallCallingGatewayEndCall() { + _ = callingService.endCall() + + XCTAssertTrue(callingSDKWrapper.endCallWasCalled()) + } + + func test_callingService_mute_when_startCall_then_callWasMutedTrue() { + _ = callingService.startCall(isCameraPreferred: false, isAudioPreferred: false) + _ = callingService.muteLocalMic() + + XCTAssertTrue(callingSDKWrapper.muteWasCalled()) + } + + func test_callingService_unmute_when_startCallAndMute_then_callWasUnmutedTrue() { + _ = callingService.startCall(isCameraPreferred: false, isAudioPreferred: false) + _ = callingService.muteLocalMic() + + XCTAssertTrue(callingSDKWrapper.muteWasCalled()) + + _ = callingService.unmuteLocalMic() + + XCTAssertTrue(callingSDKWrapper.unmuteWasCalled()) + } + + func test_callingService_startCall_when_joinCallCameraOptionOn_then_videoWasEnabled() { + _ = callingService.startCall(isCameraPreferred: true, isAudioPreferred: false) + + XCTAssertTrue(callingSDKWrapper.videoEnabledWhenJoinCall()) + } + + func test_callingService_startCall_when_joinCallCameraOptionOff_then_videoWasDisabled() { + _ = callingService.startCall(isCameraPreferred: false, isAudioPreferred: false) + + XCTAssertFalse(callingSDKWrapper.videoEnabledWhenJoinCall()) + } + + func test_callingService_startCall_when_joinCallMicOptionOn_then_callWasUnmuted() { + _ = callingService.startCall(isCameraPreferred: false, isAudioPreferred: true) + + XCTAssertFalse(callingSDKWrapper.mutedWhenJoinCall()) + } + + func test_callingService_startCall_when_joinCallMicOptionOff_then_callWasMuted() { + _ = callingService.startCall(isCameraPreferred: false, isAudioPreferred: false) + + XCTAssertTrue(callingSDKWrapper.mutedWhenJoinCall()) + } + + func test_callingService_switchCamera_then_switchCameraWasCalled() { + _ = callingService.switchCamera() + + XCTAssertTrue(callingSDKWrapper.switchCameraWasCalled()) + } + + func test_callService_requestCameraPreviewOn_then_startPreviewVideoStreamCalled() { + _ = callingService.requestCameraPreviewOn() + XCTAssertTrue(callingSDKWrapper.startPreviewVideoStreamCalled) + } +} diff --git a/AzureCommunicationUI/AzureCommunicationUITests/Service/NavigationRouterTests.swift b/AzureCommunicationUI/AzureCommunicationUITests/Service/NavigationRouterTests.swift new file mode 100644 index 000000000..1629544c7 --- /dev/null +++ b/AzureCommunicationUI/AzureCommunicationUITests/Service/NavigationRouterTests.swift @@ -0,0 +1,64 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import XCTest +@testable import AzureCommunicationUI + +class NavigationRouterTests: XCTestCase { + var storeFactory: StoreFactoryMocking! + var logger: LoggerMocking! + var swiftUIRouter: NavigationRouter! + + override func setUp() { + storeFactory = StoreFactoryMocking() + logger = LoggerMocking() + swiftUIRouter = NavigationRouter(store: storeFactory.store, logger: logger) + } + + func test_router_navigate_whenNavigateToNewView_shouldCallLog() { + let state = AppState(navigationState: NavigationState(status: .inCall)) + + swiftUIRouter.receive(state) + + XCTAssertTrue(logger.logWasCalled(), "Log was not called") + } + + func test_router_navigate_when_navigationStatusInCall_then_navigateToCallView() { + let naviState = NavigationState(status: .inCall) + let state = AppState(navigationState: naviState) + + swiftUIRouter.receive(state) + + XCTAssertEqual( + swiftUIRouter.currentView, + .callingView, + "\(swiftUIRouter.currentView) is not the expected navigated view for state: \(state.callingState.status)") + } + + func test_router_navigate_when_navigationStatusSetupView_then_navigateToSetupView() { + let naviState = NavigationState(status: .setup) + let state = AppState(navigationState: naviState) + + swiftUIRouter.receive(state) + + XCTAssertEqual( + swiftUIRouter.currentView, + .setupView, + "\(swiftUIRouter.currentView) is not the expected navigated view for state: \(state.callingState.status)") + } + + func test_router_navigate_when_navigationStatusExit_then_navigateToNone() { + let naviState = NavigationState(status: .exit) + let state = AppState(navigationState: naviState) + + swiftUIRouter.receive(state) + + XCTAssertEqual( + swiftUIRouter.currentView, + .setupView, + "\(swiftUIRouter.currentView) is not the expected navigated view for state: \(state.callingState.status)") + } +} diff --git a/AzureCommunicationUI/Podfile b/AzureCommunicationUI/Podfile new file mode 100644 index 000000000..8436a3165 --- /dev/null +++ b/AzureCommunicationUI/Podfile @@ -0,0 +1,42 @@ +platform :ios, '13.0' + +workspace 'AzureCommunicationUI' + +project 'AzureCommunicationUI.xcodeproj' +project 'AzureCommunicationUIDemoApp.xcodeproj' + +target 'AzureCommunicationUI' do + project 'AzureCommunicationUI.xcodeproj' + use_frameworks! + pod 'AzureCommunicationCalling', '2.2.0-beta.1' + pod 'MicrosoftFluentUI/Avatar_ios', '0.3.5' + pod 'MicrosoftFluentUI/BottomSheet_ios', '0.3.5' + pod 'MicrosoftFluentUI/Button_ios', '0.3.5' + pod 'MicrosoftFluentUI/PopupMenu_ios', '0.3.5' + pod 'SwiftLint', '0.42.0' + + target 'AzureCommunicationUITests' do + # Pods for testing + pod 'SwiftLint', '0.42.0' + end +end + +target 'AzureCommunicationUIDemoApp' do + project 'AzureCommunicationUIDemoApp.xcodeproj' + use_frameworks! + pod 'AzureCommunicationCalling', '2.2.0-beta.1' + pod 'MicrosoftFluentUI/Avatar_ios', '0.3.5' + pod 'MicrosoftFluentUI/BottomSheet_ios', '0.3.5' + pod 'MicrosoftFluentUI/Button_ios', '0.3.5' + pod 'MicrosoftFluentUI/PopupMenu_ios', '0.3.5' +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + if target.name == 'SwiftLint' + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' + end + end + end +end diff --git a/AzureCommunicationUI/Podfile.lock b/AzureCommunicationUI/Podfile.lock new file mode 100644 index 000000000..f995b2541 --- /dev/null +++ b/AzureCommunicationUI/Podfile.lock @@ -0,0 +1,61 @@ +PODS: + - AzureCommunicationCalling (2.2.0-beta.1): + - AzureCommunicationCommon (~> 1.0.2) + - AzureCommunicationCommon (1.0.2) + - MicrosoftFluentUI/Avatar_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - MicrosoftFluentUI/BottomSheet_ios (0.3.5): + - MicrosoftFluentUI/Obscurable_ios + - MicrosoftFluentUI/ResizingHandleView_ios + - MicrosoftFluentUI/Button_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - MicrosoftFluentUI/Core_ios (0.3.5) + - MicrosoftFluentUI/Drawer_ios (0.3.5): + - MicrosoftFluentUI/Obscurable_ios + - MicrosoftFluentUI/ResizingHandleView_ios + - MicrosoftFluentUI/Separator_ios + - MicrosoftFluentUI/TouchForwardingView_ios + - MicrosoftFluentUI/Label_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - MicrosoftFluentUI/Obscurable_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - MicrosoftFluentUI/PopupMenu_ios (0.3.5): + - MicrosoftFluentUI/Drawer_ios + - MicrosoftFluentUI/Label_ios + - MicrosoftFluentUI/Separator_ios + - MicrosoftFluentUI/TableView_ios + - MicrosoftFluentUI/ResizingHandleView_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - MicrosoftFluentUI/Separator_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - MicrosoftFluentUI/TableView_ios (0.3.5): + - MicrosoftFluentUI/Label_ios + - MicrosoftFluentUI/Separator_ios + - MicrosoftFluentUI/TouchForwardingView_ios (0.3.5): + - MicrosoftFluentUI/Core_ios + - SwiftLint (0.42.0) + +DEPENDENCIES: + - AzureCommunicationCalling (= 2.2.0-beta.1) + - MicrosoftFluentUI/Avatar_ios (= 0.3.5) + - MicrosoftFluentUI/BottomSheet_ios (= 0.3.5) + - MicrosoftFluentUI/Button_ios (= 0.3.5) + - MicrosoftFluentUI/PopupMenu_ios (= 0.3.5) + - SwiftLint (= 0.42.0) + +SPEC REPOS: + trunk: + - AzureCommunicationCalling + - AzureCommunicationCommon + - MicrosoftFluentUI + - SwiftLint + +SPEC CHECKSUMS: + AzureCommunicationCalling: df1fdad2fc9bbeabfeff93b0975df3487dc6978e + AzureCommunicationCommon: 743009b95ca5de0b32fb5327b1105c6239b7d15d + MicrosoftFluentUI: 017bdf8d40b7d453c094252e2b5534eb7bfafae4 + SwiftLint: 4fa9579c63416865179bc416f0a92d55f009600d + +PODFILE CHECKSUM: 247bd153645756dd0c868caaffc40fcf1d4563d2 + +COCOAPODS: 1.11.2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f6ebb729a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Release History + +## 1.0.0-beta.1 (2021-12-09) +This is the initial release of Azure Communication UI Library. For more information, please see the [README](README.md) and [QuickStart](https://docs.microsoft.com/en-us/azure/communication-services/quickstarts/ui-library/get-started-call?tabs=kotlin&pivots=platform-ios). + +This is a Public Preview version, so breaking changes are possible in subsequent releases as we improve the product. To provide feedback, please submit an issue in our [Issues](https://github.com/Azure/communication-ui-library-ios/issues). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..a02de0656 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to Azure Communication Services UI Library + +## Getting started + +To get up and running making changes in the repo, check out our [guide on making a contribution](docs/contributing-guide.md) + +## Contribution policy + +A “Contribution” is work voluntarily submitted to a project. This submitted work can include code, documentation, design, answering questions, or submitting and triaging issues. + +Many contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to grant and do grant the rights to use your contribution. For details, visit [https://cla.microsoft.com](https://cla.microsoft.com). + +When you submit a pull request, a CLA-bot automatically determines if you need to provide a CLA and decorates the pull request appropriately (e.g., label, comment). Follow the instructions provided by the bot. You only need to do this once across all repositories using our CLA. + +## Acceptance and consensus seeking process + +Acceptance of contributions follows the consensus-seeking process. + +All pull requests must be approved by a *collaborator* before the pull request can be accepted. + +Before a pull request is accepted, time should be given to receive input from *collaborators* or *code owners* with the expertise to evaluate the changes. The amount of time can vary but at least 3 days during the typical working week and 5 days over weekends should be given to account for international time differences and work schedules. + +When a pull request : (a) has a significant impact on the project, (b) is inherently controversial, or (c) has not reached consensus with *collaborators*; add a "controversial" label to the pull request for the *steering committee* to review the pull request. Pull requests labeled with "controversial" are not approved until the *steering committee* reviews the issue and makes a decision. + +Additionally, *owners*, can temporarily enable [interaction limits](https://help.github.com/articles/limiting-interactions-with-your-repository/) to allow a "cool-down" period when hot topics become disruptive. + +Specific *collaborators* or *code owners* can be added to a pull request by including their user alias. + +## Stability policy + +An essential consideration in every pull request is its impact on the system. To manage impacts, we work collectively to ensure that we do not introduce unnecessary breaking changes, performance or functional regressions, or negative impacts on usability for users or supported partners. + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +* a. The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or +* b. The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or +* c. The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. +* d. I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. + +## Resources + +Several open source projects have influenced our contribution policy: + +* [Microsoft FAST](https://github.com/microsoft/fast) +* [Project Governance @Node](https://nodejs.org/en/about/governance/) +* [Contributions @Node](https://github.com/nodejs/node/blob/master/CONTRIBUTING.md) +* [Open Source @GitHub](https://github.com/blog/2039-adopting-the-open-code-of-conduct) +* [Open Source examples @todogroup](https://github.com/todogroup/policies) diff --git a/README.md b/README.md index 5cd7cecfc..0e5aab100 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,82 @@ -# Project +![Hero Imaage](/docs/images/mobile-ui-library-hero-image.png) -> This repo has been populated by an initial template to help get you started. Please -> make sure to update the content to build a great experience for community-building. +# Azure Communication UI Mobile Library for iOS -As the maintainer of this project, please make a few updates: +![Cocoapods](https://img.shields.io/cocoapods/l/AzureCommunicationUI) +![CocoaPods Compatible](https://img.shields.io/cocoapods/v/AzureCommunicationUI) +![Cocoapods platforms](https://img.shields.io/cocoapods/p/AzureCommunicationUI) -- Improving this README.MD file to provide a great experience -- Updating SUPPORT.MD with content about this project's support experience -- Understanding the security reporting process in SECURITY.MD -- Remove this section from the README +Azure Communication [UI Mobile Library](https://docs.microsoft.com/en-us/azure/communication-services/concepts/ui-library/ui-library-overview) is an Azure Communication Services capability focused on providing UI components for common business-to-consumer and business-to-business calling interactions. -## Contributing +## Getting Started -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. +Get started with Azure Communication Services by using the UI Library to integrate communication experiences into your applications. For detailed instructions to quickly integrate the UI Library functionalities visit the [Quick-start Documentation](https://docs.microsoft.com/en-us/azure/communication-services/quickstarts/ui-library/get-started-call?tabs=kotlin&pivots=platform-ios). -When you submit a pull request, a CLA bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. +### Prerequisites -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +* An Azure account with an active subscription. [Create an account for free](https://azure.microsoft.com/free/?WT.mc_id=A261C142F). +* A Mac running [Xcode](https://go.microsoft.com/fwLink/p/?LinkID=266532), along with a valid developer certificate installed into your Keychain. [CocoaPods](https://cocoapods.org/) must also be installed to fetch dependencies. +* A deployed Communication Services resource. Create a [Communication Services resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource). +* Azure Communication Services Token. [See example](https://docs.microsoft.com/en-us/azure/communication-services/quickstarts/identity/quick-create-identity). -## Trademarks +### Installation -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft -trademarks or logos is subject to and must follow -[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). -Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. -Any use of third-party trademarks or logos are subject to those third-party's policies. +#### Requirements + +* iOS 13+ +* Xcode 11+ +* Swift 5.0+ + +#### Using CocoaPods + +CocoaPods is a dependency manager for Cocoa projects. To set up with CocoaPods visit their [Getting Started Guide](https://guides.cocoapods.org/using/getting-started.html). To integrate UI Mobile Library into your Xcode project using CocoaPods, specify it in your `Podfile`: + +```ruby +pod 'AzureCommunicationUI' +``` + +#### Manual Installation + + +If you prefer importing Mobile UI Library as an Embedded Framework to your project, please visit our [Manual Installation](docs/manual-installation.md) guide. + +### Quick Sample + +Replace `` with your group id for your call, `` with your name, and `` with your token. + +```swift +let callCompositeOptions = CallCompositeOptions() +callComposite = CallComposite(withOptions: callCompositeOptions) +let communicationTokenCredential = try! CommunicationTokenCredential(token: "") +let options = GroupCallOptions(communicationTokenCredential: communicationTokenCredential, + groupId: UUID("")!, + displayName: "") +callComposite?.launch(with: options) +``` + +For more details on Mobile UI Library functionalities visit the [API Reference Documentation](docs/api/CallComposite/Reference.md). + + +## Contributing to the Library or Sample + +Before developing and contributing to Communication Mobile UI Library, check out our [making a contribution guide](docs/contributing-guide.md). +Included in this repository is a demo of using Mobile UI Library to start a call. You can find the detail of using and developing the UI Library in the [Demo Guide](AzureCommunicationUI/AzureCommunicationUIDemoApp). + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. Also, please check our [Contribution Policy](CONTRIBUTING.md). + +## Community Help and Support + +If you find a bug or have a feature request, please raise the issue on [GitHub Issues](https://github.com/Azure/azure-communication-ui-library-ios/issues). + +## Known Issues + +Please refer to the [wiki](https://github.com/Azure/azure-communication-ui-library-ios/wiki/Known-Issues) for known issues related to the library. + + +## Further Reading + +* [Azure Communication UI Library Conceptual Documentation](https://docs.microsoft.com/azure/communication-services/concepts/ui-framework/ui-sdk-overview) +* [Azure Communication Service](https://docs.microsoft.com/en-us/azure/communication-services/overview) +* [Azure Communication Client and Server Architecture](https://docs.microsoft.com/en-us/azure/communication-services/concepts/client-and-server-architecture) +* [Azure Communication Authentication](https://docs.microsoft.com/en-us/azure/communication-services/concepts/authentication) +* [Azure Communication Service Troubleshooting](https://docs.microsoft.com/en-us/azure/communication-services/concepts/troubleshooting-info) diff --git a/docs/api/CallComposite/Reference.md b/docs/api/CallComposite/Reference.md new file mode 100644 index 000000000..f976b7f30 --- /dev/null +++ b/docs/api/CallComposite/Reference.md @@ -0,0 +1,20 @@ +# Reference Documentation + + +## Classes + +- [CallComposite](classes/CallComposite.md) + + +## Structs + +- [CallCompositeOptions](structs/CallCompositeOptions.md) +- [GroupCallOptions](structs/GroupCallOptions.md) +- [TeamsMeetingOptions](structs/TeamsMeetingOptions.md) +- [ErrorEvent](structs/ErrorEvent.md) +- [CallCompositeErrorCode](structs/CallCompositeErrorCode.md) + + +## Protocols + +- [ThemeConfiguration](protocols/ThemeConfiguration.md) diff --git a/docs/api/CallComposite/classes/CallComposite.md b/docs/api/CallComposite/classes/CallComposite.md new file mode 100644 index 000000000..55f9ec391 --- /dev/null +++ b/docs/api/CallComposite/classes/CallComposite.md @@ -0,0 +1,54 @@ +**CLASS** + +# `CallComposite` + +```swift +public class CallComposite +``` + +## Description + +This is the main class representing the entry point for the Call Composite. + +## Methods + +### `init` + +Create an instance of `CallComposite` with options. + +```swift +public init(withOptions options: CallCompositeOptions) +``` + +#### Parameters +* `options` - The CallCompositeOptions used to configure the experience. + +### `launch` + +Start call composite experience with joining a group call. + +```swift +public func launch(with options: GroupCallOptions) +``` + +#### Parameters +* `options` - The GroupCallOptions used to locate the group call. + +Start call composite experience with joining a Teams meeting. + +```swift +public func launch(with options: TeamsMeetingOptions) +``` + +#### Parameters +* `options` - The TeamsMeetingOptions used to locate the Teams meetings. + + +### `setTarget` +Assign an action to perform when an error occurs. + +```swift +public func setTarget(didFail action: ((CallCompositeError) -> Void)?) +``` +#### Parameters +* `action` - The closure on subscribing the error thrown from Call Composite. diff --git a/docs/api/CallComposite/protocols/ThemeConfiguration.md b/docs/api/CallComposite/protocols/ThemeConfiguration.md new file mode 100644 index 000000000..382712cb3 --- /dev/null +++ b/docs/api/CallComposite/protocols/ThemeConfiguration.md @@ -0,0 +1,21 @@ +**PROTOCOL** + +# `ThemeConfiguration` + +```swift +public protocol ThemeConfiguration +``` + +## Description + +A protocol to allow customizing the theme. + +## Properties + +### `primaryColor` + +Provide a getter to return a custom primary color. + +```swift +var primaryColor: UIColor { get } +``` diff --git a/docs/api/CallComposite/structs/CallCompositeErrorCode.md b/docs/api/CallComposite/structs/CallCompositeErrorCode.md new file mode 100644 index 000000000..b3985b19f --- /dev/null +++ b/docs/api/CallComposite/structs/CallCompositeErrorCode.md @@ -0,0 +1,35 @@ +**STRUCT** + +# `CallCompositeErrorCode` + +Call Composite runtime error types. + +```swift +public struct CallCompositeErrorCode +``` + +## Properties + +### `callJoin` + +Error when local user fails to join a call. + +```swift +public static let callJoin: String = "callJoin" +``` + +### `callEnd` + +Error when a call disconnects unexpectedly or fails on ending. + +```swift +public static let callEnd: String = "callEnd" +``` + +### `tokenExpired` + +Error when the input token is expired. + +```swift +public static let tokenExpired: String = "tokenExpired" +``` diff --git a/docs/api/CallComposite/structs/CallCompositeOptions.md b/docs/api/CallComposite/structs/CallCompositeOptions.md new file mode 100644 index 000000000..d05d37483 --- /dev/null +++ b/docs/api/CallComposite/structs/CallCompositeOptions.md @@ -0,0 +1,29 @@ +**STRUCT** + +# `CallCompositeOptions` + +```swift +public struct CallCompositeOptions +``` + +## Description + +User-configurable options for creating CallComposite. + +## Methods + +### `init` +Creates an instance of `CallCompositeOptions` with related options. + +```swift +public init() +``` + +```swift +public init(themeConfiguration: ThemeConfiguration) +``` + + + +### Parameters +* `themeConfiguration` - ThemeConfiguration for changing color pattern diff --git a/docs/api/CallComposite/structs/ErrorEvent.md b/docs/api/CallComposite/structs/ErrorEvent.md new file mode 100644 index 000000000..92631d1c6 --- /dev/null +++ b/docs/api/CallComposite/structs/ErrorEvent.md @@ -0,0 +1,26 @@ +**STRUCT** + +# `ErrorEvent` + +```swift +public struct ErrorEvent +``` +## Description + +The error thrown after Call Composite launching. + +## Properties +### `code` + +The string representing the CallCompositeErrorCode. + +```swift +public let code: String +``` + +### `error` +The NSError returned from Azure Communication SDK. + +```swift +public var error: Error? +``` diff --git a/docs/api/CallComposite/structs/GroupCallOptions.md b/docs/api/CallComposite/structs/GroupCallOptions.md new file mode 100644 index 000000000..33df8411c --- /dev/null +++ b/docs/api/CallComposite/structs/GroupCallOptions.md @@ -0,0 +1,61 @@ +**STRUCT** + +# `GroupCallOptions` + +```swift +public struct GroupCallOptions +``` + +## Description + +Options for joining a group call. + +## Properties + +### `communicationTokenCredential` + +The token credential used for communication service authentication. + +```swift +public let communicationTokenCredential: CommunicationTokenCredential +``` + +### `groupId` + +The unique identifier for the group conversation. + +```swift +public let groupId: UUID +``` + +### `displayName` + +The display name of the local participant when joining the call. + +```swift +public let displayName: String +``` + +## Methods + +### `init` + +Create an instance of a `GroupCallOptions` with options. + +```swift +public init( + communicationTokenCredential: CommunicationTokenCredential, + groupId: UUID) +``` + +```swift +public init( + communicationTokenCredential: CommunicationTokenCredential, + groupId: UUID, + displayName: String) +``` + +### Parameters +* `communicationTokenCredential` - The CommunicationTokenCredential used for communication service authentication +* `groupId` - The unique identifier for joining a specific group conversation +* `displayName` - Specify the display name of the local participant for the call diff --git a/docs/api/CallComposite/structs/TeamsMeetingOptions.md b/docs/api/CallComposite/structs/TeamsMeetingOptions.md new file mode 100644 index 000000000..36eb48cec --- /dev/null +++ b/docs/api/CallComposite/structs/TeamsMeetingOptions.md @@ -0,0 +1,61 @@ +**STRUCT** + +# `TeamsMeetingOptions` + +```swift +public struct TeamsMeetingOptions +``` + +## Description + +Options for joining a Team's meeting. + +## Properties + +### `communicationTokenCredential` + +The token credential used for communication service authentication. + +```swift +public let communicationTokenCredential: CommunicationTokenCredential +``` + +### `meetingLink` + +The URI of the Team's meeting. + +```swift +public let meetingLink: String +``` + +### `displayName` + +The display name of the local participant when joining the call. + +```swift +public let displayName: String +``` + +## Methods + +### `init` + +Create an instance of a `TeamsMeetingOptions` with options. + +```swift +public init( + communicationTokenCredential: CommunicationTokenCredential, + meetingLink: String, + displayName: String) +``` + +```swift +public init( + communicationTokenCredential: CommunicationTokenCredential, + meetingLink: String, + displayName: String) +``` +### Parameters +* `communicationTokenCredential` - The CommunicationTokenCredential used for communication service authentication +* `meetingLink` - A string representing the full URI of the teams meeting to join. +* `displayName` - Specify the display name of the local participant for the call diff --git a/docs/contributing-guide.md b/docs/contributing-guide.md new file mode 100644 index 000000000..bc50de6e0 --- /dev/null +++ b/docs/contributing-guide.md @@ -0,0 +1,61 @@ +# Contribution Guide + +## Ways to Contribute + +You can help Azure Communication UI Library with any of the following: + +- Reporting and fixing issues +- Suggesting new features +- Increasing unit test coverage +- Answering any open issues +- Improving documentation +- Reviewing pull requests + +We enthusiastically welcome contributions and feedback. You can fork the repo and start contributing now. +Here are the steps to start and develop inside iOS Mobile UI Library repo. + +1. [Setup & Run Samples](#1-setup-and-run-samples) +2. [Submitting a PR](#2-submitting-a-pr) +3. [Having your changes published](#3-having-your-changes-published) + +## 1. Setup and Run Samples +### Setup Environment + +1. Begin by cloning the [Repo](https://github.com/Azure/azure-communication-ui-library-ios) in your local environment, `cd` to the `AzureCommunicationUI` folder in the root of the project directory. +2. Run `pod install`, this generates a `.xcworkspace` file. +3. Open `AzureCommunicationUI.xcworkspace` file generated in the above step. +4. Hit `⌘+R` to start running. + +### Running a Sample + +For details on development guidelines and instructions on how to build and run the samples, visit the [Demo App](../AzureCommunicationUI/AzureCommunicationUIDemoApp/README.md). + +## 2. Submitting a PR + +You can send pull requests to fix the open issues. For any pull request, it's recommended to open an issue and reach an agreement on an implementation design/plan with other contributors first. + +We recommend making small and simple pull requests. Avoid making the implementation complicated when there is a simple, small alternative. + +Please fork the repository and submit pull requests to `develop/beta` branch. For details on how to set up a fork of this repository and keep it up-to-date see [Fork a Repo - GitHub Help](https://help.github.com/en/github/getting-started-with-github/fork-a-repo). + +### Writing unit tests + +When submitting a pull request, please add relevant tests and ensure your changes don't break any existing tests. Pull requests should be thoroughly tested and CI checks passed. + +#### Running unit tests + +1. Open the `AzureCommunicationUI.xcworkspace` with Xcode +2. Select `AzureCommunicationUI` scheme and target at any iOS simulator +3. Navigate to `Product` -> `Test` or hit `⌘+U` to start testing + +### Style Guidelines + +Azure Mobile UI Library employs a few practices to ensure the clean code and project standards. Please follow these practices to make your Pull Request consistent with the MobileUILibrary + +1. [SwiftLint](https://github.com/realm/SwiftLint) is added to enforce coding style and conventions +2. [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/) is the guidelines we are following +3. [Microsoft Swift Style Guide](https://github.com/microsoft/swift-guide) is also what we adhere to + +## 3. Having your changes published + +Once your PR is merged, your changes are ready to be published in a new version! We do manual publishes of new package versions semi-regularly. diff --git a/docs/images/EnvConfig.png b/docs/images/EnvConfig.png new file mode 100644 index 0000000000000000000000000000000000000000..9a9421baf6df08953a682aa929308b5d14c9209e GIT binary patch literal 73472 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 Wh?Z%r8Sy&+zKqVA>6hraVE+rjDLg9x literal 0 HcmV?d00001 diff --git a/docs/images/mobile-ui-library-hero-image.png b/docs/images/mobile-ui-library-hero-image.png new file mode 100644 index 0000000000000000000000000000000000000000..69057c0417da1930603e112afd3b5e626d5b0036 GIT binary patch literal 176397 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 X14F-4#Gc6j-X5ioY9DH4&BOj5(w<5| literal 0 HcmV?d00001 diff --git a/docs/manual-installation.md b/docs/manual-installation.md new file mode 100644 index 000000000..133521b08 --- /dev/null +++ b/docs/manual-installation.md @@ -0,0 +1,25 @@ +## Embedded Framework Installation + +### Prerequisties + +Azure Communication Mobile UI Library requires a few dependencies. Please import the following libraries into your project if you prefer manually embedding Mobile UI Library. + +- [MicrosoftFluentUI](https://github.com/microsoft/fluentui-apple) +- [AzureCommunicationCalling](https://github.com/Azure/azure-sdk-for-ios/tree/main/sdk/communication/AzureCommunicationCalling) +- [SwiftLint](https://github.com/realm/SwiftLint) + +### Manual Installation Steps + +- Open Terminal and `cd` to your project root directory +- Run `git init` if there is no repository set up inside your project +- Add Mobile UI Library as a git [submodule](https://git-scm.com/docs/git-submodule): + +```bash +git submodule add https://github.com/Azure/azure-communication-ui-library-ios +``` + +- Open the newly added folder from running the command and navigate into `AzureCommunicationUI` subfolder, drag the `AzureCommunicationUI.xcodeproj` into your Xcode project's Navigator +- Match the deployment target of `AzureCommunicationUI.xcodeproj` with your application target +- Select your application project in the Xcode Navigator, and open the target you want to import Mobile UI Library +- Open the "General" panel and click on the `+` button under the "Frameworks and Libraries" section +- Select `AzureCommunicationUI.framework` and then you can `import AzureCommunicationUI` inside the code to use Mobile UI Library