From 05a16da189eeb0272a936375d6fc25d5ac271d7d Mon Sep 17 00:00:00 2001 From: Niels Andriesse Date: Mon, 2 Aug 2021 15:12:17 +1000 Subject: [PATCH] Add back UI code --- Session.xcodeproj/project.pbxproj | 118 +- .../Group/GroupCallUpdateMessageHandler.swift | 8 +- Session/Calls/UserInterface/CallButton.swift | 153 ++ .../Calls/UserInterface/CallControls.swift | 291 ++++ Session/Calls/UserInterface/CallHeader.swift | 343 +++++ .../Group/GroupCallErrorView.swift | 146 ++ .../Group/GroupCallMemberSheet.swift | 350 +++++ .../Group/GroupCallMemberView.swift | 449 ++++++ .../Group/GroupCallNotificationView.swift | 268 ++++ .../Group/GroupCallSwipeToastView.swift | 54 + .../Group/GroupCallTooltip.swift | 32 + .../Group/GroupCallVideoGrid.swift | 203 +++ .../Group/GroupCallVideoGridLayout.swift | 158 ++ .../Group/GroupCallVideoOverflow.swift | 258 ++++ .../Group/GroupCallViewController.swift | 851 +++++++++++ .../CallKit/CallKitCallManager.swift | 151 ++ .../CallKit/CallKitCallUIAdaptee.swift | 455 ++++++ .../Individual/CallUIAdapter.swift | 310 ++++ .../IndividualCallViewController.swift | 1344 +++++++++++++++++ .../Individual/NonCallKitCallUIAdaptee.swift | 182 +++ .../Calls/UserInterface/LocalVideoView.swift | 117 ++ Session/Calls/UserInterface/RemoteVideoView.h | 21 + Session/Calls/UserInterface/RemoteVideoView.m | 289 ++++ 23 files changed, 6545 insertions(+), 6 deletions(-) create mode 100644 Session/Calls/UserInterface/CallButton.swift create mode 100644 Session/Calls/UserInterface/CallControls.swift create mode 100644 Session/Calls/UserInterface/CallHeader.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallErrorView.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallMemberSheet.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallMemberView.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallNotificationView.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallSwipeToastView.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallTooltip.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallVideoGrid.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallVideoGridLayout.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallVideoOverflow.swift create mode 100644 Session/Calls/UserInterface/Group/GroupCallViewController.swift create mode 100644 Session/Calls/UserInterface/Individual/CallKit/CallKitCallManager.swift create mode 100644 Session/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift create mode 100644 Session/Calls/UserInterface/Individual/CallUIAdapter.swift create mode 100644 Session/Calls/UserInterface/Individual/IndividualCallViewController.swift create mode 100644 Session/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift create mode 100644 Session/Calls/UserInterface/LocalVideoView.swift create mode 100644 Session/Calls/UserInterface/RemoteVideoView.h create mode 100644 Session/Calls/UserInterface/RemoteVideoView.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 726a3a711..5eb3e2cca 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -177,6 +177,26 @@ B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; }; B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3C25C7B34D00488AB4 /* InputTextView.swift */; }; B82A0C0026B79EB700C1BCE3 /* GroupCallUpdateMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0BFF26B79EB700C1BCE3 /* GroupCallUpdateMessage.swift */; }; + B82A0C1C26B7B45200C1BCE3 /* GroupCallNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0526B7B45200C1BCE3 /* GroupCallNotificationView.swift */; }; + B82A0C1D26B7B45200C1BCE3 /* GroupCallVideoOverflow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0626B7B45200C1BCE3 /* GroupCallVideoOverflow.swift */; }; + B82A0C1E26B7B45200C1BCE3 /* GroupCallMemberSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0726B7B45200C1BCE3 /* GroupCallMemberSheet.swift */; }; + B82A0C1F26B7B45200C1BCE3 /* GroupCallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0826B7B45200C1BCE3 /* GroupCallViewController.swift */; }; + B82A0C2026B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0926B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift */; }; + B82A0C2126B7B45200C1BCE3 /* GroupCallVideoGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0A26B7B45200C1BCE3 /* GroupCallVideoGrid.swift */; }; + B82A0C2226B7B45200C1BCE3 /* GroupCallSwipeToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0B26B7B45200C1BCE3 /* GroupCallSwipeToastView.swift */; }; + B82A0C2326B7B45200C1BCE3 /* GroupCallErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0C26B7B45200C1BCE3 /* GroupCallErrorView.swift */; }; + B82A0C2426B7B45200C1BCE3 /* GroupCallTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0D26B7B45200C1BCE3 /* GroupCallTooltip.swift */; }; + B82A0C2526B7B45200C1BCE3 /* GroupCallMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0E26B7B45200C1BCE3 /* GroupCallMemberView.swift */; }; + B82A0C2626B7B45200C1BCE3 /* RemoteVideoView.m in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C0F26B7B45200C1BCE3 /* RemoteVideoView.m */; }; + B82A0C2726B7B45200C1BCE3 /* LocalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1026B7B45200C1BCE3 /* LocalVideoView.swift */; }; + B82A0C2826B7B45200C1BCE3 /* CallHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1126B7B45200C1BCE3 /* CallHeader.swift */; }; + B82A0C2926B7B45200C1BCE3 /* CallButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1226B7B45200C1BCE3 /* CallButton.swift */; }; + B82A0C2A26B7B45200C1BCE3 /* CallControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1326B7B45200C1BCE3 /* CallControls.swift */; }; + B82A0C2B26B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1626B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift */; }; + B82A0C2C26B7B45200C1BCE3 /* IndividualCallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1726B7B45200C1BCE3 /* IndividualCallViewController.swift */; }; + B82A0C2D26B7B45200C1BCE3 /* CallKitCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1926B7B45200C1BCE3 /* CallKitCallManager.swift */; }; + B82A0C2E26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1A26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift */; }; + B82A0C2F26B7B45200C1BCE3 /* CallUIAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C1B26B7B45200C1BCE3 /* CallUIAdapter.swift */; }; B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40872399EB0E00A248E7 /* LandingVC.swift */; }; B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40892399EC0600A248E7 /* FakeChatView.swift */; }; B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B408B239A068800A248E7 /* RegisterVC.swift */; }; @@ -1183,6 +1203,27 @@ B8269D3225C7A8C600488AB4 /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; B8269D3C25C7B34D00488AB4 /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; B82A0BFF26B79EB700C1BCE3 /* GroupCallUpdateMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallUpdateMessage.swift; sourceTree = ""; }; + B82A0C0526B7B45200C1BCE3 /* GroupCallNotificationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallNotificationView.swift; sourceTree = ""; }; + B82A0C0626B7B45200C1BCE3 /* GroupCallVideoOverflow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallVideoOverflow.swift; sourceTree = ""; }; + B82A0C0726B7B45200C1BCE3 /* GroupCallMemberSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallMemberSheet.swift; sourceTree = ""; }; + B82A0C0826B7B45200C1BCE3 /* GroupCallViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallViewController.swift; sourceTree = ""; }; + B82A0C0926B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallVideoGridLayout.swift; sourceTree = ""; }; + B82A0C0A26B7B45200C1BCE3 /* GroupCallVideoGrid.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallVideoGrid.swift; sourceTree = ""; }; + B82A0C0B26B7B45200C1BCE3 /* GroupCallSwipeToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallSwipeToastView.swift; sourceTree = ""; }; + B82A0C0C26B7B45200C1BCE3 /* GroupCallErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallErrorView.swift; sourceTree = ""; }; + B82A0C0D26B7B45200C1BCE3 /* GroupCallTooltip.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallTooltip.swift; sourceTree = ""; }; + B82A0C0E26B7B45200C1BCE3 /* GroupCallMemberView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallMemberView.swift; sourceTree = ""; }; + B82A0C0F26B7B45200C1BCE3 /* RemoteVideoView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RemoteVideoView.m; sourceTree = ""; }; + B82A0C1026B7B45200C1BCE3 /* LocalVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalVideoView.swift; sourceTree = ""; }; + B82A0C1126B7B45200C1BCE3 /* CallHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallHeader.swift; sourceTree = ""; }; + B82A0C1226B7B45200C1BCE3 /* CallButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallButton.swift; sourceTree = ""; }; + B82A0C1326B7B45200C1BCE3 /* CallControls.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallControls.swift; sourceTree = ""; }; + B82A0C1426B7B45200C1BCE3 /* RemoteVideoView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RemoteVideoView.h; sourceTree = ""; }; + B82A0C1626B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NonCallKitCallUIAdaptee.swift; sourceTree = ""; }; + B82A0C1726B7B45200C1BCE3 /* IndividualCallViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndividualCallViewController.swift; sourceTree = ""; }; + B82A0C1926B7B45200C1BCE3 /* CallKitCallManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallKitCallManager.swift; sourceTree = ""; }; + B82A0C1A26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallKitCallUIAdaptee.swift; sourceTree = ""; }; + B82A0C1B26B7B45200C1BCE3 /* CallUIAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallUIAdapter.swift; sourceTree = ""; }; B82B40872399EB0E00A248E7 /* LandingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingVC.swift; sourceTree = ""; }; B82B40892399EC0600A248E7 /* FakeChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeChatView.swift; sourceTree = ""; }; B82B408B239A068800A248E7 /* RegisterVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterVC.swift; sourceTree = ""; }; @@ -2176,6 +2217,58 @@ path = "Views & Modals"; sourceTree = ""; }; + B82A0C0326B7B45200C1BCE3 /* UserInterface */ = { + isa = PBXGroup; + children = ( + B82A0C0426B7B45200C1BCE3 /* Group */, + B82A0C1026B7B45200C1BCE3 /* LocalVideoView.swift */, + B82A0C1126B7B45200C1BCE3 /* CallHeader.swift */, + B82A0C1226B7B45200C1BCE3 /* CallButton.swift */, + B82A0C1326B7B45200C1BCE3 /* CallControls.swift */, + B82A0C1426B7B45200C1BCE3 /* RemoteVideoView.h */, + B82A0C0F26B7B45200C1BCE3 /* RemoteVideoView.m */, + B82A0C1526B7B45200C1BCE3 /* Individual */, + ); + path = UserInterface; + sourceTree = ""; + }; + B82A0C0426B7B45200C1BCE3 /* Group */ = { + isa = PBXGroup; + children = ( + B82A0C0526B7B45200C1BCE3 /* GroupCallNotificationView.swift */, + B82A0C0626B7B45200C1BCE3 /* GroupCallVideoOverflow.swift */, + B82A0C0726B7B45200C1BCE3 /* GroupCallMemberSheet.swift */, + B82A0C0826B7B45200C1BCE3 /* GroupCallViewController.swift */, + B82A0C0926B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift */, + B82A0C0A26B7B45200C1BCE3 /* GroupCallVideoGrid.swift */, + B82A0C0B26B7B45200C1BCE3 /* GroupCallSwipeToastView.swift */, + B82A0C0C26B7B45200C1BCE3 /* GroupCallErrorView.swift */, + B82A0C0D26B7B45200C1BCE3 /* GroupCallTooltip.swift */, + B82A0C0E26B7B45200C1BCE3 /* GroupCallMemberView.swift */, + ); + path = Group; + sourceTree = ""; + }; + B82A0C1526B7B45200C1BCE3 /* Individual */ = { + isa = PBXGroup; + children = ( + B82A0C1626B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift */, + B82A0C1726B7B45200C1BCE3 /* IndividualCallViewController.swift */, + B82A0C1826B7B45200C1BCE3 /* CallKit */, + B82A0C1B26B7B45200C1BCE3 /* CallUIAdapter.swift */, + ); + path = Individual; + sourceTree = ""; + }; + B82A0C1826B7B45200C1BCE3 /* CallKit */ = { + isa = PBXGroup; + children = ( + B82A0C1926B7B45200C1BCE3 /* CallKitCallManager.swift */, + B82A0C1A26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift */, + ); + path = CallKit; + sourceTree = ""; + }; B835246C25C38AA20089A44F /* Conversations */ = { isa = PBXGroup; children = ( @@ -2215,13 +2308,14 @@ children = ( B822F9C326B275FA003B8CB8 /* INSTRUCTIONS.md */, B882A75026AE878300B5AB69 /* CallService.swift */, - B882A75126AE878300B5AB69 /* Group */, B882A75426AE878300B5AB69 /* CallAudioService.swift */, B882A75526AE878300B5AB69 /* OWSAudioSession+WebRTC.swift */, B882A75626AE878300B5AB69 /* SignalCall.swift */, - B882A77026AE878300B5AB69 /* Signaling */, B882A77326AE878300B5AB69 /* AudioSource.swift */, + B882A77026AE878300B5AB69 /* Signaling */, B882A77426AE878300B5AB69 /* Individual */, + B882A75126AE878300B5AB69 /* Group */, + B82A0C0326B7B45200C1BCE3 /* UserInterface */, ); path = Calls; sourceTree = ""; @@ -4881,6 +4975,7 @@ 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, 3496956E21A301A100DCFE74 /* OWSBackupExportJob.m in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, + B82A0C2C26B7B45200C1BCE3 /* IndividualCallViewController.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, B882A79226AE878300B5AB69 /* WebRTCCallMessageHandler.swift in Sources */, B882A79326AE878300B5AB69 /* TurnServerInfo.swift in Sources */, @@ -4918,6 +5013,7 @@ 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, + B82A0C2626B7B45200C1BCE3 /* RemoteVideoView.m in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, @@ -4925,6 +5021,7 @@ B882A79726AE878300B5AB69 /* IndividualCall.swift in Sources */, C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, + B82A0C1D26B7B45200C1BCE3 /* GroupCallVideoOverflow.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, D221A09A169C9E5E00537ABF /* main.m in Sources */, 3496957221A301A100DCFE74 /* OWSBackup.m in Sources */, @@ -4934,7 +5031,9 @@ 34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */, B8D0A26925E4A2C200C1835E /* Onboarding.swift in Sources */, B882A79526AE878300B5AB69 /* IndividualCallService.swift in Sources */, + B82A0C2026B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift in Sources */, 34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */, + B82A0C1C26B7B45200C1BCE3 /* GroupCallNotificationView.swift in Sources */, 450DF2051E0D74AC003D14BE /* Platform.swift in Sources */, 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, B882A77B26AE878300B5AB69 /* CallAudioService.swift in Sources */, @@ -4942,12 +5041,14 @@ B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, 346129991FD1E4DA00532771 /* SignalApp.m in Sources */, 3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */, + B82A0C2B26B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, B882A77D26AE878300B5AB69 /* SignalCall.swift in Sources */, B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */, C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */, C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */, + B82A0C2126B7B45200C1BCE3 /* GroupCallVideoGrid.swift in Sources */, B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */, 45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */, 45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */, @@ -4960,6 +5061,7 @@ C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, B882A77A26AE878300B5AB69 /* GroupCallRemoteVideoManager.swift in Sources */, B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */, + B82A0C1F26B7B45200C1BCE3 /* GroupCallViewController.swift in Sources */, 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */, @@ -4974,10 +5076,12 @@ 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, B848A4C5269EAAA200617031 /* UserDetailsSheet.swift in Sources */, 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, + B82A0C2226B7B45200C1BCE3 /* GroupCallSwipeToastView.swift in Sources */, B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */, B8544E3323D50E4900299F14 /* SNAppearance.swift in Sources */, 4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */, C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */, + B82A0C2F26B7B45200C1BCE3 /* CallUIAdapter.swift in Sources */, B882A79626AE878300B5AB69 /* OutboundIndividualCallInitiator.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */, @@ -4988,11 +5092,13 @@ 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */, B882A77926AE878300B5AB69 /* GroupCallUpdateMessageHandler.swift in Sources */, B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */, + B82A0C2926B7B45200C1BCE3 /* CallButton.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */, B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */, C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */, + B82A0C2826B7B45200C1BCE3 /* CallHeader.swift in Sources */, 3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, @@ -5000,7 +5106,10 @@ 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, 34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */, 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */, + B82A0C2D26B7B45200C1BCE3 /* CallKitCallManager.swift in Sources */, C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */, + B82A0C2426B7B45200C1BCE3 /* GroupCallTooltip.swift in Sources */, + B82A0C1E26B7B45200C1BCE3 /* GroupCallMemberSheet.swift in Sources */, 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, @@ -5008,11 +5117,13 @@ C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, + B82A0C2526B7B45200C1BCE3 /* GroupCallMemberView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */, 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, + B82A0C2326B7B45200C1BCE3 /* GroupCallErrorView.swift in Sources */, 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, 3496957021A301A100DCFE74 /* OWSBackupIO.m in Sources */, B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, @@ -5020,12 +5131,14 @@ 76EB054018170B33006006FC /* AppDelegate.m in Sources */, 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */, C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */, + B82A0C2A26B7B45200C1BCE3 /* CallControls.swift in Sources */, C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */, B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */, C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */, C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */, 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */, + B82A0C2E26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift in Sources */, B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */, B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */, B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */, @@ -5037,6 +5150,7 @@ B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, + B82A0C2726B7B45200C1BCE3 /* LocalVideoView.swift in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, diff --git a/Session/Calls/Group/GroupCallUpdateMessageHandler.swift b/Session/Calls/Group/GroupCallUpdateMessageHandler.swift index 3ba8a860a..e5e9f9ac1 100644 --- a/Session/Calls/Group/GroupCallUpdateMessageHandler.swift +++ b/Session/Calls/Group/GroupCallUpdateMessageHandler.swift @@ -32,10 +32,10 @@ class GroupCallUpdateMessageHandler: CallServiceObserver, CallObserver, Dependen func sendUpdateMessageForThread(_ thread: TSGroupThread, eraId: String?) { Logger.info("Sending call update message for thread \(thread.uniqueId)") - let updateMessage = OWSOutgoingGroupCallMessage(thread: thread, eraId: eraId) - let messagePreparer = updateMessage.asPreparer - SDSDatabaseStorage.shared.asyncWrite { writeTx in - Self.messageSenderJobQueue.add(message: messagePreparer, transaction: writeTx) + let message = GroupCallUpdateMessage() + message.eraID = eraId + Storage.write { transaction in + MessageSender.send(message, in: thread, using: transaction) } } diff --git a/Session/Calls/UserInterface/CallButton.swift b/Session/Calls/UserInterface/CallButton.swift new file mode 100644 index 000000000..2e6c2bd58 --- /dev/null +++ b/Session/Calls/UserInterface/CallButton.swift @@ -0,0 +1,153 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +class CallButton: UIButton { + var iconName: String { didSet { updateAppearance() } } + var selectedIconName: String? { didSet { updateAppearance() } } + + var currentIconName: String { + if isSelected, let selectedImageName = selectedIconName { + return selectedImageName + } + return iconName + } + + var iconColor: UIColor = .ows_white { didSet { updateAppearance() } } + var selectedIconColor: UIColor = .ows_gray75 { didSet { updateAppearance() } } + var currentIconColor: UIColor { isSelected ? selectedIconColor : iconColor } + + var unselectedBackgroundColor = UIColor.ows_whiteAlpha40 { didSet { updateAppearance() } } + var selectedBackgroundColor = UIColor.ows_white { didSet { updateAppearance() } } + + var currentBackgroundColor: UIColor { + return isSelected ? selectedBackgroundColor : unselectedBackgroundColor + } + + var text: String? { didSet { updateAppearance() } } + + override var isSelected: Bool { didSet { updateAppearance() } } + override var isHighlighted: Bool { didSet { updateAppearance() } } + + var showDropdownArrow = false { didSet { updateDropdownArrow() } } + + var isSmall = false { didSet { updateSizing() } } + + private var currentConstraints = [NSLayoutConstraint]() + + private var currentIconSize: CGFloat { isSmall ? 48 : 56 } + private var currentIconInsets: UIEdgeInsets { + var insets: UIEdgeInsets + if isSmall { + insets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + } else { + insets = UIEdgeInsets(top: 14, left: 14, bottom: 14, right: 14) + } + + if showDropdownArrow { + if CurrentAppContext().isRTL { + insets.left += 3 + insets.right -= 3 + } else { + insets.left -= 3 + insets.right += 3 + } + } + + return insets + } + + private lazy var iconView = UIImageView() + private var dropdownIconView: UIImageView? + private lazy var circleView = CircleView() + private lazy var label = UILabel() + + init(iconName: String) { + self.iconName = iconName + + super.init(frame: .zero) + + let circleViewContainer = UIView.container() + circleViewContainer.addSubview(circleView) + circleView.autoPinHeightToSuperview() + circleView.autoPinEdge(toSuperviewEdge: .leading, withInset: 0, relation: .greaterThanOrEqual) + circleView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0, relation: .greaterThanOrEqual) + circleView.autoHCenterInSuperview() + circleView.layer.shadowOffset = .zero + circleView.layer.shadowOpacity = 0.25 + circleView.layer.shadowRadius = 4 + + let stackView = UIStackView(arrangedSubviews: [circleViewContainer, label]) + stackView.axis = .vertical + stackView.spacing = 8 + stackView.isUserInteractionEnabled = false + + addSubview(stackView) + stackView.autoPinEdgesToSuperviewEdges() + + label.font = .ows_dynamicTypeSubheadline + label.textColor = Theme.darkThemePrimaryColor + label.textAlignment = .center + label.layer.shadowOffset = .zero + label.layer.shadowOpacity = 0.25 + label.layer.shadowRadius = 4 + + circleView.addSubview(iconView) + + updateAppearance() + updateSizing() + } + + private func updateAppearance() { + circleView.backgroundColor = currentBackgroundColor + iconView.setTemplateImageName(currentIconName, tintColor: currentIconColor) + dropdownIconView?.setTemplateImageName("arrow-down-12", tintColor: currentIconColor) + + if let text = text { + label.isHidden = false + label.text = text + } else { + label.isHidden = true + } + + alpha = isHighlighted ? 0.6 : 1 + } + + private func updateSizing() { + NSLayoutConstraint.deactivate(currentConstraints) + currentConstraints.removeAll() + + currentConstraints += circleView.autoSetDimensions(to: CGSize(square: currentIconSize)) + circleView.layer.shadowPath = UIBezierPath( + ovalIn: CGRect(origin: .zero, size: .square(currentIconSize)) + ).cgPath + currentConstraints += iconView.autoPinEdgesToSuperviewEdges(with: currentIconInsets) + if let dropdownIconView = dropdownIconView { + currentConstraints.append(dropdownIconView.autoPinEdge(.leading, to: .trailing, of: iconView, withOffset: isSmall ? 0 : 2)) + } + } + + private func updateDropdownArrow() { + if showDropdownArrow { + if dropdownIconView?.superview != nil { return } + let dropdownIconView = UIImageView() + self.dropdownIconView = dropdownIconView + circleView.addSubview(dropdownIconView) + + dropdownIconView.autoSetDimensions(to: CGSize(square: 12)) + dropdownIconView.autoVCenterInSuperview() + + updateSizing() + updateAppearance() + } else { + dropdownIconView?.removeFromSuperview() + dropdownIconView = nil + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Session/Calls/UserInterface/CallControls.swift b/Session/Calls/UserInterface/CallControls.swift new file mode 100644 index 000000000..0788bae12 --- /dev/null +++ b/Session/Calls/UserInterface/CallControls.swift @@ -0,0 +1,291 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +@objc +protocol CallControlsDelegate: AnyObject { + func didPressHangup(sender: UIButton) + func didPressAudioSource(sender: UIButton) + func didPressMute(sender: UIButton) + func didPressVideo(sender: UIButton) + func didPressFlipCamera(sender: UIButton) + func didPressCancel(sender: UIButton) + func didPressJoin(sender: UIButton) +} + +class CallControls: UIView { + private lazy var hangUpButton: CallButton = { + let button = createButton( + iconName: "phone-down-solid-28", + action: #selector(CallControlsDelegate.didPressHangup) + ) + button.unselectedBackgroundColor = .ows_accentRed + return button + }() + private(set) lazy var audioSourceButton = createButton( + iconName: "speaker-solid-28", + action: #selector(CallControlsDelegate.didPressAudioSource) + ) + private lazy var muteButton = createButton( + iconName: "mic-off-solid-28", + action: #selector(CallControlsDelegate.didPressMute) + ) + private lazy var videoButton = createButton( + iconName: "video-solid-28", + action: #selector(CallControlsDelegate.didPressVideo) + ) + private lazy var flipCameraButton: CallButton = { + let button = createButton( + iconName: "switch-camera-28", + action: #selector(CallControlsDelegate.didPressFlipCamera) + ) + button.selectedIconColor = button.iconColor + button.selectedBackgroundColor = button.unselectedBackgroundColor + return button + }() + + private lazy var cancelButton: UIButton = { + let button = OWSButton() + button.setTitle(CommonStrings.cancelButton, for: .normal) + button.setTitleColor(.ows_white, for: .normal) + button.setBackgroundImage(UIImage(color: .ows_whiteAlpha40), for: .normal) + button.titleLabel?.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold + button.clipsToBounds = true + button.layer.cornerRadius = 8 + button.block = { [weak self] in + self?.delegate.didPressCancel(sender: button) + } + button.contentEdgeInsets = UIEdgeInsets(top: 11, leading: 11, bottom: 11, trailing: 11) + return button + }() + + private lazy var joinButtonActivityIndicator = UIActivityIndicatorView(style: .white) + + private lazy var joinButton: UIButton = { + let button = OWSButton() + button.setTitleColor(.ows_white, for: .normal) + button.setBackgroundImage(UIImage(color: .ows_accentGreen), for: .normal) + button.titleLabel?.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold + button.clipsToBounds = true + button.layer.cornerRadius = 8 + button.block = { [weak self] in + self?.delegate.didPressJoin(sender: button) + } + button.contentEdgeInsets = UIEdgeInsets(top: 11, leading: 11, bottom: 11, trailing: 11) + button.addSubview(joinButtonActivityIndicator) + button.setTitle( + NSLocalizedString( + "GROUP_CALL_IS_FULL", + comment: "Text explaining the group call is full" + ), + for: .disabled + ) + button.setTitleColor(.ows_whiteAlpha40, for: .disabled) + joinButtonActivityIndicator.autoCenterInSuperview() + return button + }() + + private lazy var gradientView: UIView = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + UIColor.black.withAlphaComponent(0).cgColor, + UIColor.ows_blackAlpha60.cgColor + ] + let view = OWSLayerView(frame: .zero) { view in + gradientLayer.frame = view.bounds + } + view.layer.addSublayer(gradientLayer) + return view + }() + + private lazy var topStackView = createTopStackView() + private lazy var bottomStackView = createBottomStackView() + + private weak var delegate: CallControlsDelegate! + private let call: SignalCall + + init(call: SignalCall, delegate: CallControlsDelegate) { + self.call = call + self.delegate = delegate + super.init(frame: .zero) + + call.addObserverAndSyncState(observer: self) + + callService.audioService.delegate = self + + addSubview(gradientView) + gradientView.autoPinEdgesToSuperviewEdges() + + let controlsStack = UIStackView(arrangedSubviews: [topStackView, bottomStackView]) + controlsStack.axis = .vertical + controlsStack.spacing = 40 + + addSubview(controlsStack) + controlsStack.autoPinWidthToSuperview() + controlsStack.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 24) + controlsStack.autoPinEdge(toSuperviewEdge: .top, withInset: 22) + + updateControls() + } + + deinit { + call.removeObserver(self) + callService.audioService.delegate = nil + } + + func createTopStackView() -> UIStackView { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 16 + + let leadingSpacer = UIView.hStretchingSpacer() + let trailingSpacer = UIView.hStretchingSpacer() + + stackView.addArrangedSubview(leadingSpacer) + stackView.addArrangedSubview(audioSourceButton) + stackView.addArrangedSubview(flipCameraButton) + stackView.addArrangedSubview(muteButton) + stackView.addArrangedSubview(videoButton) + stackView.addArrangedSubview(hangUpButton) + stackView.addArrangedSubview(trailingSpacer) + + leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer) + + return stackView + } + + func createBottomStackView() -> UIStackView { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 8 + + let leadingSpacer = UIView.hStretchingSpacer() + let trailingSpacer = UIView.hStretchingSpacer() + + stackView.addArrangedSubview(leadingSpacer) + stackView.addArrangedSubview(cancelButton) + stackView.addArrangedSubview(joinButton) + stackView.addArrangedSubview(trailingSpacer) + + // Prefer to be big. + NSLayoutConstraint.autoSetPriority(.defaultHigh) { + cancelButton.autoSetDimension(.width, toSize: 170) + } + + cancelButton.autoMatch(.width, to: .width, of: joinButton) + leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer) + leadingSpacer.autoSetDimension(.width, toSize: 16, relation: .greaterThanOrEqual) + + return stackView + } + + private func updateControls() { + let hasExternalAudioInputs = callService.audioService.hasExternalInputs + let isLocalVideoMuted = call.groupCall.isOutgoingVideoMuted + + flipCameraButton.isHidden = isLocalVideoMuted + videoButton.isSelected = !isLocalVideoMuted + muteButton.isSelected = call.groupCall.isOutgoingAudioMuted + hangUpButton.isHidden = call.groupCall.localDeviceState.joinState != .joined + + // Use small controls if video is enabled and we have external + // audio inputs, because we have five buttons now. + [audioSourceButton, flipCameraButton, videoButton, muteButton, hangUpButton].forEach { + $0.isSmall = hasExternalAudioInputs && !isLocalVideoMuted + } + + // Audio Source Handling + if hasExternalAudioInputs, let audioSource = callService.audioService.currentAudioSource { + audioSourceButton.showDropdownArrow = true + audioSourceButton.isHidden = false + + if audioSource.isBuiltInEarPiece { + audioSourceButton.iconName = "phone-solid-28" + } else if audioSource.isBuiltInSpeaker { + audioSourceButton.iconName = "speaker-solid-28" + } else { + audioSourceButton.iconName = "speaker-bt-solid-28" + } + } else if UIDevice.current.isIPad { + // iPad *only* supports speaker mode, if there are no external + // devices connected, so we don't need to show the button unless + // we have alternate audio sources. + audioSourceButton.isHidden = true + } else { + // If there are no external audio sources, and video is enabled, + // speaker mode is always enabled so we don't need to show the button. + audioSourceButton.isHidden = !isLocalVideoMuted + + // No bluetooth audio detected + audioSourceButton.iconName = "speaker-solid-28" + audioSourceButton.showDropdownArrow = false + } + + bottomStackView.isHidden = call.groupCall.localDeviceState.joinState == .joined + + let startCallText = NSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call") + let joinCallText = NSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call") + + if call.groupCall.isFull { + joinButton.isEnabled = false + } else if call.groupCall.localDeviceState.joinState == .joining { + joinButton.isEnabled = true + joinButton.isUserInteractionEnabled = false + joinButtonActivityIndicator.startAnimating() + + joinButton.setTitle("", for: .normal) + } else { + joinButton.isEnabled = true + joinButton.isUserInteractionEnabled = true + joinButtonActivityIndicator.stopAnimating() + + let deviceCount = call.groupCall.peekInfo?.deviceCount ?? 0 + joinButton.setTitle(deviceCount == 0 ? startCallText : joinCallText, for: .normal) + } + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func createButton(iconName: String, action: Selector) -> CallButton { + let button = CallButton(iconName: iconName) + button.addTarget(delegate, action: action, for: .touchUpInside) + button.setContentHuggingHorizontalHigh() + button.setCompressionResistanceHorizontalLow() + button.alpha = 0.9 + return button + } +} + +extension CallControls: CallObserver { + func groupCallLocalDeviceStateChanged(_ call: SignalCall) { + owsAssertDebug(call.isGroupCall) + updateControls() + } + + func groupCallPeekChanged(_ call: SignalCall) { + updateControls() + } + + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + updateControls() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + updateControls() + } +} + +extension CallControls: CallAudioServiceDelegate { + func callAudioServiceDidChangeAudioSession(_ callAudioService: CallAudioService) { + updateControls() + } + + func callAudioServiceDidChangeAudioSource(_ callAudioService: CallAudioService, audioSource: AudioSource?) { + updateControls() + } +} diff --git a/Session/Calls/UserInterface/CallHeader.swift b/Session/Calls/UserInterface/CallHeader.swift new file mode 100644 index 000000000..042147053 --- /dev/null +++ b/Session/Calls/UserInterface/CallHeader.swift @@ -0,0 +1,343 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +@objc +protocol CallHeaderDelegate: AnyObject { + func didTapBackButton() + func didTapMembersButton() +} + +class CallHeader: UIView { + // MARK: - Views + + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + dateFormatter.timeZone = TimeZone(identifier: "UTC")! + dateFormatter.locale = Locale(identifier: "en_US") + return dateFormatter + }() + + private var callDurationTimer: Timer? + private let callTitleLabel = MarqueeLabel() + private let callStatusLabel = UILabel() + private let groupMembersButton = GroupMembersButton() + + private let call: SignalCall + private weak var delegate: CallHeaderDelegate! + + init(call: SignalCall, delegate: CallHeaderDelegate) { + self.call = call + self.delegate = delegate + super.init(frame: .zero) + + call.addObserverAndSyncState(observer: self) + + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + UIColor.ows_blackAlpha60.cgColor, + UIColor.black.withAlphaComponent(0).cgColor + ] + let gradientView = OWSLayerView(frame: .zero) { view in + gradientLayer.frame = view.bounds + } + gradientView.layer.addSublayer(gradientLayer) + + addSubview(gradientView) + gradientView.autoPinEdgesToSuperviewEdges() + + let hStack = UIStackView() + hStack.axis = .horizontal + hStack.spacing = 13 + hStack.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + hStack.isLayoutMarginsRelativeArrangement = true + addSubview(hStack) + hStack.autoPinWidthToSuperview() + hStack.autoPinEdge(toSuperviewMargin: .top) + hStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: 46) + + // Back button + + let backButton = UIButton() + let backButtonImage = CurrentAppContext().isRTL ? #imageLiteral(resourceName: "NavBarBackRTL") : #imageLiteral(resourceName: "NavBarBack") + backButton.setTemplateImage(backButtonImage, tintColor: .ows_white) + backButton.autoSetDimensions(to: CGSize(square: 40)) + backButton.imageEdgeInsets = UIEdgeInsets(top: -12, leading: -18, bottom: 0, trailing: 0) + backButton.addTarget(delegate, action: #selector(CallHeaderDelegate.didTapBackButton), for: .touchUpInside) + addShadow(to: backButton) + + hStack.addArrangedSubview(backButton) + + // vStack + + let vStack = UIStackView() + vStack.axis = .vertical + vStack.spacing = 4 + + hStack.addArrangedSubview(vStack) + + // Name Label + + callTitleLabel.type = .continuous + // This feels pretty slow when you're initially waiting for it, but when you're overlaying video calls, anything faster is distracting. + callTitleLabel.speed = .duration(30.0) + callTitleLabel.animationCurve = .linear + callTitleLabel.fadeLength = 10.0 + callTitleLabel.animationDelay = 5 + // Add trailing space after the name scrolls before it wraps around and scrolls back in. + callTitleLabel.trailingBuffer = ScaleFromIPhone5(80.0) + + callTitleLabel.font = UIFont.ows_dynamicTypeHeadlineClamped.ows_semibold + callTitleLabel.textAlignment = .center + callTitleLabel.textColor = UIColor.white + addShadow(to: callTitleLabel) + + vStack.addArrangedSubview(callTitleLabel) + + // Status label + + callStatusLabel.font = UIFont.ows_dynamicTypeFootnoteClamped + callStatusLabel.textAlignment = .center + callStatusLabel.textColor = UIColor.white + addShadow(to: callStatusLabel) + + vStack.addArrangedSubview(callStatusLabel) + + // Group members button + + groupMembersButton.addTarget( + delegate, + action: #selector(CallHeaderDelegate.didTapMembersButton), + for: .touchUpInside + ) + addShadow(to: groupMembersButton) + + hStack.addArrangedSubview(groupMembersButton) + + updateCallTitleLabel() + updateCallStatusLabel() + updateGroupMembersButton() + } + + deinit { call.removeObserver(self) } + + private func addShadow(to view: UIView) { + view.layer.shadowOffset = .zero + view.layer.shadowOpacity = 0.25 + view.layer.shadowRadius = 4 + } + + private func updateCallStatusLabel() { + let callStatusText: String + switch call.groupCall.localDeviceState.joinState { + case .notJoined, .joining: + callStatusText = "" + case .joined: + let callDuration = call.connectionDuration() + let callDurationDate = Date(timeIntervalSinceReferenceDate: callDuration) + var formattedDate = dateFormatter.string(from: callDurationDate) + if formattedDate.hasPrefix("00:") { + // Don't show the "hours" portion of the date format unless the + // call duration is at least 1 hour. + formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 3)...]) + } else { + // If showing the "hours" portion of the date format, strip any leading + // zeroes. + if formattedDate.hasPrefix("0") { + formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 1)...]) + } + } + callStatusText = String(format: CallStrings.callStatusFormat, formattedDate) + } + + callStatusLabel.text = callStatusText + callStatusLabel.isHidden = call.groupCall.localDeviceState.joinState != .joined || call.groupCall.remoteDeviceStates.count > 1 + } + + func updateCallTitleLabel() { + let callTitleText: String + + if call.groupCall.localDeviceState.connectionState == .reconnecting { + callTitleText = NSLocalizedString( + "GROUP_CALL_RECONNECTING", + comment: "Text indicating that the user has lost their connection to the call and we are reconnecting." + ) + } else { + var isFirstMemberPresenting = false + let memberNames: [String] = databaseStorage.read { transaction in + if self.call.groupCall.localDeviceState.joinState == .joined { + let sortedDeviceStates = self.call.groupCall.remoteDeviceStates.sortedByAddedTime + isFirstMemberPresenting = sortedDeviceStates.first?.presenting == true + return sortedDeviceStates.map { self.contactsManager.displayName(for: $0.address, transaction: transaction) } + } else { + return self.call.groupCall.peekInfo?.joinedMembers + .map { self.contactsManager.displayName(for: SignalServiceAddress(uuid: $0), transaction: transaction) } ?? [] + } + } + + switch call.groupCall.localDeviceState.joinState { + case .joined: + switch memberNames.count { + case 0: + callTitleText = NSLocalizedString( + "GROUP_CALL_NO_ONE_HERE", + comment: "Text explaining that you are the only person currently in the group call" + ) + case 1: + if isFirstMemberPresenting { + let formatString = NSLocalizedString( + "GROUP_CALL_PRESENTING_FORMAT", + comment: "Text explaining that a member is presenting. Embeds {member name}" + ) + callTitleText = String(format: formatString, memberNames[0]) + } else { + callTitleText = memberNames[0] + } + default: + if isFirstMemberPresenting { + let formatString = NSLocalizedString( + "GROUP_CALL_PRESENTING_FORMAT", + comment: "Text explaining that a member is presenting. Embeds {member name}" + ) + callTitleText = String(format: formatString, memberNames[0]) + } else { + callTitleText = "" + } + } + default: + switch memberNames.count { + case 0: + callTitleText = "" + case 1: + let formatString = NSLocalizedString( + "GROUP_CALL_ONE_PERSON_HERE_FORMAT", + comment: "Text explaining that there is one person in the group call. Embeds {member name}" + ) + callTitleText = String(format: formatString, memberNames[0]) + case 2: + let formatString = NSLocalizedString( + "GROUP_CALL_TWO_PEOPLE_HERE_FORMAT", + comment: "Text explaining that there are two people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2 }}" + ) + callTitleText = String(format: formatString, memberNames[0], memberNames[1]) + case 3: + let formatString = NSLocalizedString( + "GROUP_CALL_THREE_PEOPLE_HERE_FORMAT", + comment: "Text explaining that there are three people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2 }}" + ) + callTitleText = String(format: formatString, memberNames[0], memberNames[1]) + default: + let formatString = NSLocalizedString( + "GROUP_CALL_MANY_PEOPLE_HERE_FORMAT", + comment: "Text explaining that there are more than three people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2, %3$@ participantCount-2 }}" + ) + callTitleText = String(format: formatString, memberNames[0], memberNames[1], OWSFormat.formatInt(memberNames.count - 2)) + } + } + } + + callTitleLabel.text = callTitleText + callTitleLabel.isHidden = callTitleText.isEmpty + } + + func updateGroupMembersButton() { + let isJoined = call.groupCall.localDeviceState.joinState == .joined + let remoteMemberCount = isJoined ? call.groupCall.remoteDeviceStates.count : Int(call.groupCall.peekInfo?.deviceCount ?? 0) + groupMembersButton.updateMemberCount(remoteMemberCount + (isJoined ? 1 : 0)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension CallHeader: CallObserver { + func groupCallLocalDeviceStateChanged(_ call: SignalCall) { + owsAssertDebug(call.isGroupCall) + + if call.groupCall.localDeviceState.joinState == .joined { + if callDurationTimer == nil { + let kDurationUpdateFrequencySeconds = 1 / 20.0 + callDurationTimer = WeakTimer.scheduledTimer( + timeInterval: TimeInterval(kDurationUpdateFrequencySeconds), + target: self, + userInfo: nil, + repeats: true + ) {[weak self] _ in + self?.updateCallStatusLabel() + } + } + } else { + callDurationTimer?.invalidate() + callDurationTimer = nil + } + + updateCallTitleLabel() + updateCallStatusLabel() + updateGroupMembersButton() + } + + func groupCallPeekChanged(_ call: SignalCall) { + updateCallTitleLabel() + updateGroupMembersButton() + } + + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + updateCallTitleLabel() + updateGroupMembersButton() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + callDurationTimer?.invalidate() + callDurationTimer = nil + + updateCallTitleLabel() + updateCallStatusLabel() + updateGroupMembersButton() + } +} + +private class GroupMembersButton: UIButton { + private let iconImageView = UIImageView() + private let countLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + autoSetDimension(.height, toSize: 40) + + iconImageView.contentMode = .scaleAspectFit + iconImageView.setTemplateImage(#imageLiteral(resourceName: "group-solid-24"), tintColor: .ows_white) + addSubview(iconImageView) + iconImageView.autoPinEdge(toSuperviewEdge: .leading) + iconImageView.autoSetDimensions(to: CGSize(square: 22)) + iconImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 2) + + countLabel.font = UIFont.ows_dynamicTypeFootnoteClamped.ows_monospaced + countLabel.textColor = .ows_white + addSubview(countLabel) + countLabel.autoPinEdge(.leading, to: .trailing, of: iconImageView, withOffset: 5) + countLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: 5) + countLabel.autoAlignAxis(.horizontal, toSameAxisOf: iconImageView) + countLabel.setContentHuggingHorizontalHigh() + countLabel.setCompressionResistanceHorizontalHigh() + } + + func updateMemberCount(_ count: Int) { + countLabel.text = String(OWSFormat.formatInt(count)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.5 : 1 + } + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallErrorView.swift b/Session/Calls/UserInterface/Group/GroupCallErrorView.swift new file mode 100644 index 000000000..8725a03c5 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallErrorView.swift @@ -0,0 +1,146 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation + +class GroupCallErrorView: UIView { + + var forceCompactAppearance: Bool = false { + didSet { configure() } + } + + var iconImage: UIImage? { + didSet { + if let iconImage = iconImage { + iconView.setTemplateImage(iconImage, tintColor: .ows_white) + miniButton.setTemplateImage(iconImage, tintColor: .ows_white) + } else { + iconView.image = nil + miniButton.setImage(nil, for: .normal) + } + } + } + + var labelText: String? { + didSet { + label.text = labelText + configure() + } + } + + var userTapAction: ((GroupCallErrorView) -> Void)? + + // MARK: - Views + + private let iconView: UIImageView = UIImageView() + + private let label: UILabel = { + let label = UILabel() + label.font = UIFont.ows_dynamicTypeSubheadline + label.adjustsFontForContentSizeCategory = true + label.textAlignment = .center + label.textColor = .ows_white + label.numberOfLines = 0 + return label + }() + + private let button: UIButton = { + let buttonLabel = NSLocalizedString( + "GROUP_CALL_ERROR_DETAILS", + comment: "A button to receive more info about not seeing a participant in group call grid") + + let button = UIButton() + button.backgroundColor = .ows_gray75 + + button.contentEdgeInsets = UIEdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12) + button.layer.cornerRadius = 12 + button.clipsToBounds = true + + button.titleLabel?.textAlignment = .center + button.titleLabel?.font = UIFont.ows_dynamicTypeSubheadline.ows_semibold + button.setTitle(buttonLabel, for: .normal) + + button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) + return button + }() + + private let miniButton: UIButton = { + let button = UIButton() + button.contentVerticalAlignment = .fill + button.contentHorizontalAlignment = .fill + + button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + let stackView = UIStackView(arrangedSubviews: [ + iconView, + label, + button, + ]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .fill + + stackView.setCustomSpacing(12, after: iconView) + stackView.setCustomSpacing(16, after: label) + + insetsLayoutMarginsFromSafeArea = false + + addSubview(miniButton) + addSubview(stackView) + + stackView.autoPinWidthToSuperviewMargins() + stackView.autoVCenterInSuperview() + stackView.autoPinEdge(toSuperviewMargin: .top, relation: .greaterThanOrEqual) + stackView.autoPinEdge(toSuperviewMargin: .bottom, relation: .greaterThanOrEqual) + miniButton.autoCenterInSuperview() + + iconView.setCompressionResistanceHigh() + button.setCompressionResistanceHigh() + + iconView.autoSetDimensions(to: CGSize(width: 24, height: 24)) + button.autoSetDimension(.height, toSize: 24, relation: .greaterThanOrEqual) + miniButton.autoSetDimensions(to: CGSize(width: 24, height: 24)) + + configure() + } + + override var bounds: CGRect { + didSet { configure() } + } + + override var frame: CGRect { + didSet { configure() } + } + + private func configure() { + let isCompact = (bounds.width < 100) || (bounds.height < 100) || forceCompactAppearance + iconView.isHidden = isCompact + label.isHidden = isCompact + button.isHidden = isCompact + miniButton.isHidden = !isCompact + + layoutIfNeeded() + + // The error text is easily truncated in small cells with large dynamic type. + // If the label gets truncated, just hide it. + if !label.isHidden { + let widthBox = CGSize(width: label.bounds.width, height: .greatestFiniteMagnitude) + let labelDesiredHeight = label.sizeThatFits(widthBox).height + label.isHidden = (labelDesiredHeight > label.bounds.height) + } + } + + @objc + private func didTapButton() { + userTapAction?(self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallMemberSheet.swift b/Session/Calls/UserInterface/Group/GroupCallMemberSheet.swift new file mode 100644 index 000000000..2ca2c6e33 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallMemberSheet.swift @@ -0,0 +1,350 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +@objc +class GroupCallMemberSheet: InteractiveSheetViewController { + override var interactiveScrollViews: [UIScrollView] { [tableView] } + let tableView = UITableView(frame: .zero, style: .grouped) + let call: SignalCall + + init(call: SignalCall) { + self.call = call + super.init() + call.addObserverAndSyncState(observer: self) + } + + public required init() { + fatalError("init() has not been implemented") + } + + deinit { call.removeObserver(self) } + + // MARK: - + + override public func viewDidLoad() { + super.viewDidLoad() + + if UIAccessibility.isReduceTransparencyEnabled { + contentView.backgroundColor = .ows_blackAlpha80 + } else { + let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + contentView.addSubview(blurEffectView) + blurEffectView.autoPinEdgesToSuperviewEdges() + contentView.backgroundColor = .ows_blackAlpha40 + } + + tableView.dataSource = self + tableView.delegate = self + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.tableHeaderView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: CGFloat.leastNormalMagnitude))) + contentView.addSubview(tableView) + tableView.autoPinEdgesToSuperviewEdges() + + tableView.register(GroupCallMemberCell.self, forCellReuseIdentifier: GroupCallMemberCell.reuseIdentifier) + tableView.register(GroupCallEmptyCell.self, forCellReuseIdentifier: GroupCallEmptyCell.reuseIdentifier) + + updateMembers() + } + + // MARK: - + + struct JoinedMember { + let address: SignalServiceAddress + let displayName: String + let comparableName: String + let isAudioMuted: Bool? + let isVideoMuted: Bool? + let isPresenting: Bool? + } + + private var sortedMembers = [JoinedMember]() + func updateMembers() { + let unsortedMembers: [JoinedMember] = databaseStorage.read { transaction in + var members = [JoinedMember]() + + if self.call.groupCall.localDeviceState.joinState == .joined { + members += self.call.groupCall.remoteDeviceStates.values.map { member in + let displayName: String + let comparableName: String + if member.address.isLocalAddress { + displayName = NSLocalizedString( + "GROUP_CALL_YOU_ON_ANOTHER_DEVICE", + comment: "Text describing the local user in the group call members sheet when connected from another device." + ) + comparableName = displayName + } else { + displayName = self.contactsManager.displayName(for: member.address, transaction: transaction) + comparableName = self.contactsManager.comparableName(for: member.address, transaction: transaction) + } + + return JoinedMember( + address: member.address, + displayName: displayName, + comparableName: comparableName, + isAudioMuted: member.audioMuted, + isVideoMuted: member.videoMuted, + isPresenting: member.presenting + ) + } + + guard let localAddress = self.tsAccountManager.localAddress else { return members } + + let displayName = NSLocalizedString( + "GROUP_CALL_YOU", + comment: "Text describing the local user as a participant in a group call." + ) + let comparableName = displayName + + members.append(JoinedMember( + address: localAddress, + displayName: displayName, + comparableName: comparableName, + isAudioMuted: self.call.groupCall.isOutgoingAudioMuted, + isVideoMuted: self.call.groupCall.isOutgoingVideoMuted, + isPresenting: false + )) + } else { + // If we're not yet in the call, `remoteDeviceStates` will not exist. + // We can get the list of joined members still, provided we are connected. + members += self.call.groupCall.peekInfo?.joinedMembers.map { uuid in + let address = SignalServiceAddress(uuid: uuid) + let displayName = self.contactsManager.displayName(for: address, transaction: transaction) + let comparableName = self.contactsManager.comparableName(for: address, transaction: transaction) + + return JoinedMember( + address: address, + displayName: displayName, + comparableName: comparableName, + isAudioMuted: nil, + isVideoMuted: nil, + isPresenting: nil + ) + } ?? [] + } + + return members + } + + sortedMembers = unsortedMembers.sorted { $0.comparableName.caseInsensitiveCompare($1.comparableName) == .orderedAscending } + + tableView.reloadData() + } +} + +extension GroupCallMemberSheet: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sortedMembers.count > 0 ? sortedMembers.count : 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard !sortedMembers.isEmpty else { + return tableView.dequeueReusableCell(withIdentifier: GroupCallEmptyCell.reuseIdentifier, for: indexPath) + } + + let cell = tableView.dequeueReusableCell(withIdentifier: GroupCallMemberCell.reuseIdentifier, for: indexPath) + + guard let memberCell = cell as? GroupCallMemberCell else { + owsFailDebug("unexpected cell type") + return cell + } + + guard let member = sortedMembers[safe: indexPath.row] else { + owsFailDebug("missing member") + return cell + } + + memberCell.configure(item: member) + + return memberCell + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let label = UILabel() + label.font = UIFont.ows_dynamicTypeSubheadlineClamped.ows_semibold + label.textColor = Theme.darkThemePrimaryColor + + if sortedMembers.count > 1 { + let formatString = NSLocalizedString( + "GROUP_CALL_MANY_IN_THIS_CALL_FORMAT", + comment: "String indicating how many people are current in the call" + ) + label.text = String(format: formatString, sortedMembers.count) + } else if sortedMembers.count > 0 { + label.text = NSLocalizedString( + "GROUP_CALL_ONE_IN_THIS_CALL", + comment: "String indicating one person is currently in the call" + ) + } else { + label.text = nil + } + + let labelContainer = UIView() + labelContainer.layoutMargins = UIEdgeInsets(top: 13, left: 16, bottom: 13, right: 16) + labelContainer.addSubview(label) + label.autoPinEdgesToSuperviewMargins() + return labelContainer + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return .leastNormalMagnitude + } +} + +// MARK: - + +extension GroupCallMemberSheet: CallObserver { + func groupCallLocalDeviceStateChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateMembers() + } + + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateMembers() + } + + func groupCallPeekChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateMembers() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateMembers() + } +} + +private class GroupCallMemberCell: UITableViewCell { + static let reuseIdentifier = "GroupCallMemberCell" + + let avatarView = ConversationAvatarView(diameterPoints: 36, + localUserDisplayMode: .asUser) + let nameLabel = UILabel() + let videoMutedIndicator = UIImageView() + let audioMutedIndicator = UIImageView() + let presentingIndicator = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + backgroundColor = .clear + selectionStyle = .none + + layoutMargins = UIEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + + avatarView.autoSetDimensions(to: CGSize(square: 36)) + + nameLabel.font = .ows_dynamicTypeBody + + audioMutedIndicator.contentMode = .scaleAspectFit + audioMutedIndicator.setTemplateImage(#imageLiteral(resourceName: "mic-off-solid-28"), tintColor: .ows_white) + audioMutedIndicator.autoSetDimensions(to: CGSize(square: 16)) + audioMutedIndicator.setContentHuggingHorizontalHigh() + let audioMutedWrapper = UIView() + audioMutedWrapper.addSubview(audioMutedIndicator) + audioMutedIndicator.autoPinEdgesToSuperviewEdges() + + videoMutedIndicator.contentMode = .scaleAspectFit + videoMutedIndicator.setTemplateImage(#imageLiteral(resourceName: "video-off-solid-28"), tintColor: .ows_white) + videoMutedIndicator.autoSetDimensions(to: CGSize(square: 16)) + videoMutedIndicator.setContentHuggingHorizontalHigh() + + presentingIndicator.contentMode = .scaleAspectFit + presentingIndicator.setTemplateImage(#imageLiteral(resourceName: "share-screen-solid-28"), tintColor: .ows_white) + presentingIndicator.autoSetDimensions(to: CGSize(square: 16)) + presentingIndicator.setContentHuggingHorizontalHigh() + + // We share a wrapper for video muted and presenting states + // as they render in the same column. + let videoMutedAndPresentingWrapper = UIView() + videoMutedAndPresentingWrapper.addSubview(videoMutedIndicator) + videoMutedIndicator.autoPinEdgesToSuperviewEdges() + + videoMutedAndPresentingWrapper.addSubview(presentingIndicator) + presentingIndicator.autoPinEdgesToSuperviewEdges() + + let stackView = UIStackView(arrangedSubviews: [ + avatarView, + UIView.spacer(withWidth: 8), + nameLabel, + UIView.spacer(withWidth: 16), + videoMutedAndPresentingWrapper, + UIView.spacer(withWidth: 16), + audioMutedWrapper + ]) + stackView.axis = .horizontal + stackView.alignment = .center + contentView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(item: GroupCallMemberSheet.JoinedMember) { + nameLabel.textColor = Theme.darkThemePrimaryColor + + videoMutedIndicator.isHidden = item.isVideoMuted != true || item.isPresenting == true + audioMutedIndicator.isHidden = item.isAudioMuted != true + presentingIndicator.isHidden = item.isPresenting != true + + nameLabel.text = item.displayName + + avatarView.configureWithSneakyTransaction(address: item.address) + } +} + +private class GroupCallEmptyCell: UITableViewCell { + static let reuseIdentifier = "GroupCallEmptyCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + backgroundColor = .clear + selectionStyle = .none + + layoutMargins = UIEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + + let imageView = UIImageView(image: #imageLiteral(resourceName: "sad-cat")) + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.autoSetDimensions(to: CGSize(square: 160)) + imageView.autoHCenterInSuperview() + imageView.autoPinTopToSuperviewMargin(withInset: 32) + + let label = UILabel() + label.font = .ows_dynamicTypeSubheadlineClamped + label.textColor = Theme.darkThemePrimaryColor + label.text = NSLocalizedString("GROUP_CALL_NOBODY_IS_IN_YET", + comment: "Text explaining to the user that nobody has joined this call yet.") + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.textAlignment = .center + contentView.addSubview(label) + label.autoPinWidthToSuperviewMargins() + label.autoPinBottomToSuperviewMargin() + label.autoPinEdge(.top, to: .bottom, of: imageView, withOffset: 16) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallMemberView.swift b/Session/Calls/UserInterface/Group/GroupCallMemberView.swift new file mode 100644 index 000000000..29763b682 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallMemberView.swift @@ -0,0 +1,449 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +protocol GroupCallMemberViewDelegate: AnyObject { + func memberView(_: GroupCallMemberView, userRequestedInfoAboutError: GroupCallMemberView.ErrorState) +} + +class GroupCallMemberView: UIView { + weak var delegate: GroupCallMemberViewDelegate? + let noVideoView = UIView() + + let backgroundAvatarView = UIImageView() + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + let muteIndicatorImage = UIImageView() + + lazy var muteLeadingConstraint = muteIndicatorImage.autoPinEdge(toSuperviewEdge: .leading, withInset: muteInsets) + lazy var muteBottomConstraint = muteIndicatorImage.autoPinEdge(toSuperviewEdge: .bottom, withInset: muteInsets) + lazy var muteHeightConstraint = muteIndicatorImage.autoSetDimension(.height, toSize: muteHeight) + + var muteInsets: CGFloat { + layoutIfNeeded() + + if width > 102 { + return 9 + } else { + return 4 + } + } + + var muteHeight: CGFloat { + layoutIfNeeded() + + if width > 200 && UIDevice.current.isIPad { + return 20 + } else { + return 16 + } + } + + init() { + super.init(frame: .zero) + + backgroundColor = .ows_gray90 + clipsToBounds = true + + addSubview(noVideoView) + noVideoView.autoPinEdgesToSuperviewEdges() + + let overlayView = UIView() + overlayView.backgroundColor = .ows_blackAlpha40 + noVideoView.addSubview(overlayView) + overlayView.autoPinEdgesToSuperviewEdges() + + backgroundAvatarView.contentMode = .scaleAspectFill + noVideoView.addSubview(backgroundAvatarView) + backgroundAvatarView.autoPinEdgesToSuperviewEdges() + + noVideoView.addSubview(blurView) + blurView.autoPinEdgesToSuperviewEdges() + + muteIndicatorImage.contentMode = .scaleAspectFit + muteIndicatorImage.setTemplateImage(#imageLiteral(resourceName: "mic-off-solid-28"), tintColor: .ows_white) + addSubview(muteIndicatorImage) + muteIndicatorImage.autoMatch(.width, to: .height, of: muteIndicatorImage) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + enum ErrorState { + case blocked(String) + case noMediaKeys(String) + } +} + +class GroupCallLocalMemberView: GroupCallMemberView { + let videoView = LocalVideoView() + + let videoOffIndicatorImage = UIImageView() + let videoOffLabel = UILabel() + + var videoOffIndicatorWidth: CGFloat { + if width > 102 { + return 28 + } else { + return 16 + } + } + + override var bounds: CGRect { + didSet { updateDimensions() } + } + + override var frame: CGRect { + didSet { updateDimensions() } + } + + lazy var videoOffIndicatorWidthConstraint = videoOffIndicatorImage.autoSetDimension(.width, toSize: videoOffIndicatorWidth) + + lazy var callFullLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.font = .ows_dynamicTypeSubheadline + label.textAlignment = .center + label.textColor = Theme.darkThemePrimaryColor + return label + }() + + lazy var callFullStack: UIStackView = { + let callFullStack = UIStackView() + callFullStack.axis = .vertical + callFullStack.spacing = 8 + + let imageView = UIImageView(image: #imageLiteral(resourceName: "sad-cat")) + imageView.contentMode = .scaleAspectFit + imageView.autoSetDimensions(to: CGSize(square: 200)) + callFullStack.addArrangedSubview(imageView) + + let titleLabel = UILabel() + titleLabel.text = NSLocalizedString( + "GROUP_CALL_IS_FULL", + comment: "Text explaining the group call is full" + ) + titleLabel.font = UIFont.ows_dynamicTypeSubheadline.ows_semibold + titleLabel.textAlignment = .center + titleLabel.textColor = Theme.darkThemePrimaryColor + callFullStack.addArrangedSubview(titleLabel) + + callFullStack.addArrangedSubview(callFullLabel) + + return callFullStack + }() + + override init() { + super.init() + + videoOffIndicatorImage.contentMode = .scaleAspectFit + videoOffIndicatorImage.setTemplateImage(#imageLiteral(resourceName: "video-off-solid-28"), tintColor: .ows_white) + noVideoView.addSubview(videoOffIndicatorImage) + videoOffIndicatorImage.autoMatch(.height, to: .width, of: videoOffIndicatorImage) + videoOffIndicatorImage.autoCenterInSuperview() + + videoOffLabel.font = .ows_dynamicTypeSubheadline + videoOffLabel.text = NSLocalizedString("CALLING_MEMBER_VIEW_YOUR_CAMERA_IS_OFF", + comment: "Indicates to the user that their camera is currently off.") + videoOffLabel.textAlignment = .center + videoOffLabel.textColor = Theme.darkThemePrimaryColor + noVideoView.addSubview(videoOffLabel) + videoOffLabel.autoPinWidthToSuperview() + videoOffLabel.autoPinEdge(.top, to: .bottom, of: videoOffIndicatorImage, withOffset: 10) + + videoView.contentMode = .scaleAspectFill + insertSubview(videoView, belowSubview: muteIndicatorImage) + videoView.frame = bounds + + addSubview(callFullStack) + callFullStack.autoAlignAxis(.horizontal, toSameAxisOf: self, withOffset: -30) + callFullStack.autoPinWidthToSuperview(withMargin: 16) + + layer.shadowOffset = .zero + layer.shadowOpacity = 0.25 + layer.shadowRadius = 4 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var hasBeenConfigured = false + func configure(call: SignalCall, isFullScreen: Bool = false) { + hasBeenConfigured = true + + videoView.isHidden = call.groupCall.isOutgoingVideoMuted + videoView.captureSession = call.videoCaptureController.captureSession + noVideoView.isHidden = !videoView.isHidden + + if isFullScreen, + call.groupCall.isFull, + case .notJoined = call.groupCall.localDeviceState.joinState { + + let text: String + if let maxDevices = call.groupCall.maxDevices { + let formatString = NSLocalizedString( + "GROUP_CALL_HAS_MAX_DEVICES_FORMAT", + comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}." + ) + text = String(format: formatString, maxDevices) + } else { + text = NSLocalizedString( + "GROUP_CALL_HAS_MAX_DEVICES_UNKNOWN_COUNT", + comment: "An error displayed to the user when the group call ends because it has exceeded the max devices." + ) + } + + callFullLabel.text = text + callFullStack.isHidden = false + videoOffLabel.isHidden = true + videoOffIndicatorImage.isHidden = true + } else { + callFullStack.isHidden = true + videoOffLabel.isHidden = !videoView.isHidden || !isFullScreen + videoOffIndicatorImage.isHidden = !videoView.isHidden + } + + guard let localAddress = tsAccountManager.localAddress else { + return owsFailDebug("missing local address") + } + + backgroundAvatarView.image = profileManager.localProfileAvatarImage() + + muteIndicatorImage.isHidden = isFullScreen || !call.groupCall.isOutgoingAudioMuted + muteLeadingConstraint.constant = muteInsets + muteBottomConstraint.constant = -muteInsets + muteHeightConstraint.constant = muteHeight + + videoOffIndicatorWidthConstraint.constant = videoOffIndicatorWidth + + noVideoView.backgroundColor = AvatarTheme.forAddress(localAddress).backgroundColor + + layer.cornerRadius = isFullScreen ? 0 : 10 + clipsToBounds = true + } + + private func updateDimensions() { + guard hasBeenConfigured else { return } + videoView.frame = bounds + muteLeadingConstraint.constant = muteInsets + muteBottomConstraint.constant = -muteInsets + muteHeightConstraint.constant = muteHeight + videoOffIndicatorWidthConstraint.constant = videoOffIndicatorWidth + } +} + +class GroupCallRemoteMemberView: GroupCallMemberView { + private weak var videoView: GroupCallRemoteVideoView? + + var deferredReconfigTimer: Timer? + let errorView = GroupCallErrorView() + let avatarView = ConversationAvatarView(diameterPoints: 0, + localUserDisplayMode: .asUser) + let spinner = UIActivityIndicatorView(style: .whiteLarge) + lazy var avatarWidthConstraint = avatarView.autoSetDimension(.width, toSize: CGFloat(avatarDiameter)) + + var isCallMinimized: Bool = false { + didSet { + // Currently only updated for the speaker view, since that's the only visible cell + // while minimized. + errorView.forceCompactAppearance = isCallMinimized + errorView.isUserInteractionEnabled = !isCallMinimized + } + } + + override var bounds: CGRect { + didSet { updateDimensions() } + } + + override var frame: CGRect { + didSet { updateDimensions() } + } + + var avatarDiameter: UInt { + layoutIfNeeded() + + if width > 180 { + return 112 + } else if width > 102 { + return 96 + } else if width > 36 { + return UInt(width) - 36 + } else { + return 16 + } + } + + let mode: Mode + enum Mode: Equatable { + case videoGrid, videoOverflow, speaker + } + + init(mode: Mode) { + self.mode = mode + super.init() + + noVideoView.insertSubview(avatarView, belowSubview: muteIndicatorImage) + noVideoView.insertSubview(errorView, belowSubview: muteIndicatorImage) + noVideoView.insertSubview(spinner, belowSubview: muteIndicatorImage) + + avatarView.autoCenterInSuperview() + errorView.autoPinEdgesToSuperviewEdges() + spinner.autoCenterInSuperview() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var hasBeenConfigured = false + func configure(call: SignalCall, device: RemoteDeviceState) { + hasBeenConfigured = true + deferredReconfigTimer?.invalidate() + + let profileImage = databaseStorage.read { transaction -> UIImage? in + avatarView.configure(address: device.address, + diameterPoints: avatarDiameter, + localUserDisplayMode: .asUser, + transaction: transaction) + avatarWidthConstraint.constant = CGFloat(avatarDiameter) + + return self.contactsManagerImpl.avatarImage(forAddress: device.address, + shouldValidate: true, + transaction: transaction) + } + + backgroundAvatarView.image = profileImage + + muteIndicatorImage.isHidden = mode == .speaker || device.audioMuted != true + muteLeadingConstraint.constant = muteInsets + muteBottomConstraint.constant = -muteInsets + muteHeightConstraint.constant = muteHeight + + noVideoView.backgroundColor = AvatarTheme.forAddress(device.address).backgroundColor + + configureRemoteVideo(device: device) + let isRemoteDeviceBlocked = blockingManager.isAddressBlocked(device.address) + let errorDeferralInterval: TimeInterval = 5.0 + let addedDate = Date(millisecondsSince1970: device.addedTime) + let connectionDuration = -addedDate.timeIntervalSinceNow + + // Hide these views. They'll be unhidden below. + [errorView, avatarView, videoView, spinner].forEach { $0?.isHidden = true } + + if !device.mediaKeysReceived, !isRemoteDeviceBlocked, connectionDuration < errorDeferralInterval { + // No media keys, but that's expected since we just joined the call. + // Schedule a timer to re-check and show a spinner in the meantime + spinner.isHidden = false + if !spinner.isAnimating { spinner.startAnimating() } + + let configuredDemuxId = device.demuxId + let scheduledInterval = errorDeferralInterval - connectionDuration + deferredReconfigTimer = Timer.scheduledTimer( + withTimeInterval: scheduledInterval, + repeats: false, + block: { [weak self] _ in + guard let self = self else { return } + guard call.isGroupCall, let groupCall = call.groupCall else { return } + guard let updatedState = groupCall.remoteDeviceStates.values + .first(where: { $0.demuxId == configuredDemuxId }) else { return } + self.configure(call: call, device: updatedState) + }) + + } else if !device.mediaKeysReceived { + // No media keys. Display error view + errorView.isHidden = false + configureErrorView(for: device.address, isBlocked: isRemoteDeviceBlocked) + + } else if let videoView = videoView, device.videoTrack != nil { + // We have a video track! If we don't know the mute state, show both. + // Otherwise, show one or the other. + videoView.isHidden = (device.videoMuted == true) + avatarView.isHidden = (device.videoMuted == false) + + } else { + // No video. Display avatar + avatarView.isHidden = false + } + } + + func clearConfiguration() { + deferredReconfigTimer?.invalidate() + + cleanupVideoViews() + + noVideoView.backgroundColor = .ows_black + backgroundAvatarView.image = nil + avatarView.image = nil + + [errorView, spinner, muteIndicatorImage].forEach { $0.isHidden = true } + } + + private func updateDimensions() { + guard hasBeenConfigured else { return } + videoView?.frame = bounds + muteLeadingConstraint.constant = muteInsets + muteBottomConstraint.constant = -muteInsets + muteHeightConstraint.constant = muteHeight + avatarWidthConstraint.constant = CGFloat(avatarDiameter) + } + + func cleanupVideoViews() { + if videoView?.superview == self { videoView?.removeFromSuperview() } + videoView = nil + } + + func configureRemoteVideo(device: RemoteDeviceState) { + if videoView?.superview == self { videoView?.removeFromSuperview() } + let newVideoView = callService.groupCallRemoteVideoManager.remoteVideoView(for: device, mode: mode) + insertSubview(newVideoView, belowSubview: muteIndicatorImage) + newVideoView.frame = bounds + newVideoView.isScreenShare = device.sharingScreen == true + videoView = newVideoView + owsAssertDebug(videoView != nil, "Missing remote video view") + } + + func configureErrorView(for address: String, isBlocked: Bool) { + let displayName: String + if address.isLocalAddress { + displayName = NSLocalizedString( + "GROUP_CALL_YOU_ON_ANOTHER_DEVICE", + comment: "Text describing the local user in the group call members sheet when connected from another device.") + } else { + displayName = self.contactsManager.displayName(for: address) + } + + let blockFormat = NSLocalizedString( + "GROUP_CALL_BLOCKED_USER_FORMAT", + comment: "String displayed in group call grid cell when a user is blocked. Embeds {user's name}") + let missingKeyFormat = NSLocalizedString( + "GROUP_CALL_MISSING_MEDIA_KEYS_FORMAT", + comment: "String displayed in cell when media from a user can't be displayed in group call grid. Embeds {user's name}") + + let labelFormat = isBlocked ? blockFormat : missingKeyFormat + let label = String(format: labelFormat, arguments: [displayName]) + let image = isBlocked ? UIImage(named: "block-24") : UIImage(named: "error-solid-24") + + errorView.iconImage = image + errorView.labelText = label + errorView.userTapAction = { [weak self] _ in + guard let self = self else { return } + + if isBlocked { + self.delegate?.memberView(self, userRequestedInfoAboutError: .blocked(address)) + } else { + self.delegate?.memberView(self, userRequestedInfoAboutError: .noMediaKeys(address)) + } + } + } +} + +extension RemoteDeviceState { + var address: SignalServiceAddress { + return SignalServiceAddress(uuid: userId) + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallNotificationView.swift b/Session/Calls/UserInterface/Group/GroupCallNotificationView.swift new file mode 100644 index 000000000..f89a3a789 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallNotificationView.swift @@ -0,0 +1,268 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +class GroupCallNotificationView: UIView { + private let call: SignalCall + + private struct ActiveMember: Hashable { + let demuxId: UInt32 + let uuid: UUID + var address: String { return "" } + } + private var activeMembers = Set() + private var membersPendingJoinNotification = Set() + private var membersPendingLeaveNotification = Set() + + init(call: SignalCall) { + self.call = call + super.init(frame: .zero) + + call.addObserverAndSyncState(observer: self) + + isUserInteractionEnabled = false + } + + deinit { call.removeObserver(self) } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var hasJoined = false + private func updateActiveMembers() { + let newActiveMembers = Set(call.groupCall.remoteDeviceStates.values.map { + ActiveMember(demuxId: $0.demuxId, uuid: $0.userId) + }) + + if hasJoined { + let joinedMembers = newActiveMembers.subtracting(activeMembers) + let leftMembers = activeMembers.subtracting(newActiveMembers) + + membersPendingJoinNotification.subtract(leftMembers) + membersPendingJoinNotification.formUnion(joinedMembers) + + membersPendingLeaveNotification.subtract(joinedMembers) + membersPendingLeaveNotification.formUnion(leftMembers) + } else { + hasJoined = call.groupCall.localDeviceState.joinState == .joined + } + + activeMembers = newActiveMembers + + presentNextNotificationIfNecessary() + } + + private var isPresentingNotification = false + private func presentNextNotificationIfNecessary() { + guard !isPresentingNotification else { return } + + guard let bannerView: BannerView = { + if membersPendingJoinNotification.count > 0 { + callService.audioService.playJoinSound() + let addresses = membersPendingJoinNotification.map { $0.address } + membersPendingJoinNotification.removeAll() + return BannerView(addresses: addresses, action: .join) + } else if membersPendingLeaveNotification.count > 0 { + callService.audioService.playLeaveSound() + let addresses = membersPendingLeaveNotification.map { $0.address } + membersPendingLeaveNotification.removeAll() + return BannerView(addresses: addresses, action: .leave) + } else { + return nil + } + }() else { return } + + isPresentingNotification = true + + addSubview(bannerView) + bannerView.autoHCenterInSuperview() + + // Prefer to be full width, but don't exceed the maximum width + bannerView.autoSetDimension(.width, toSize: 512, relation: .lessThanOrEqual) + bannerView.autoMatch( + .width, + to: .width, + of: self, + withOffset: -(layoutMargins.left + layoutMargins.right), + relation: .lessThanOrEqual + ) + NSLayoutConstraint.autoSetPriority(.defaultHigh) { + bannerView.autoPinWidthToSuperviewMargins() + } + + let onScreenConstraint = bannerView.autoPinEdge(toSuperviewMargin: .top) + onScreenConstraint.isActive = false + + let offScreenConstraint = bannerView.autoPinEdge(.bottom, to: .top, of: self) + + layoutIfNeeded() + + firstly(on: .main) { + UIView.animate(.promise, duration: 0.35) { + offScreenConstraint.isActive = false + onScreenConstraint.isActive = true + + self.layoutIfNeeded() + } + }.then(on: .main) { _ in + UIView.animate(.promise, duration: 0.35, delay: 2, options: .curveEaseInOut) { + onScreenConstraint.isActive = false + offScreenConstraint.isActive = true + + self.layoutIfNeeded() + } + }.done(on: .main) { _ in + bannerView.removeFromSuperview() + self.isPresentingNotification = false + self.presentNextNotificationIfNecessary() + } + } +} + +extension GroupCallNotificationView: CallObserver { + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateActiveMembers() + } + + func groupCallPeekChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateActiveMembers() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + hasJoined = false + activeMembers.removeAll() + membersPendingJoinNotification.removeAll() + membersPendingLeaveNotification.removeAll() + + updateActiveMembers() + } +} + +private class BannerView: UIView { + enum Action: Equatable { case join, leave } + + init(addresses: [String], action: Action) { + super.init(frame: .zero) + + owsAssertDebug(!addresses.isEmpty) + + autoSetDimension(.height, toSize: 64, relation: .greaterThanOrEqual) + layer.cornerRadius = 8 + clipsToBounds = true + + if UIAccessibility.isReduceTransparencyEnabled { + backgroundColor = .ows_blackAlpha80 + } else { + let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + addSubview(blurEffectView) + blurEffectView.autoPinEdgesToSuperviewEdges() + backgroundColor = .ows_blackAlpha40 + } + + let displayNames = databaseStorage.read { transaction in + return addresses.map { address in + return ( + displayName: self.contactsManager.displayName(for: address, transaction: transaction), + comparableName: self.contactsManager.comparableName(for: address, transaction: transaction) + ) + } + }.sorted { $0.comparableName.caseInsensitiveCompare($1.comparableName) == .orderedAscending } + .map { $0.displayName } + + let actionText: String + if displayNames.count > 2 { + let formatText = action == .join + ? NSLocalizedString( + "GROUP_CALL_NOTIFICATION_MANY_JOINED_FORMAT", + comment: "Copy explaining that many new users have joined the group call. Embeds {first member name}, {second member name}, {number of additional members}" + ) + : NSLocalizedString( + "GROUP_CALL_NOTIFICATION_MANY_LEFT_FORMAT", + comment: "Copy explaining that many users have left the group call. Embeds {first member name}, {second member name}, {number of additional members}" + ) + actionText = String(format: formatText, displayNames[0], displayNames[1], displayNames.count - 2) + } else if displayNames.count > 1 { + let formatText = action == .join + ? NSLocalizedString( + "GROUP_CALL_NOTIFICATION_TWO_JOINED_FORMAT", + comment: "Copy explaining that two users have joined the group call. Embeds {first member name}, {second member name}" + ) + : NSLocalizedString( + "GROUP_CALL_NOTIFICATION_TWO_LEFT_FORMAT", + comment: "Copy explaining that two users have left the group call. Embeds {first member name}, {second member name}" + ) + actionText = String(format: formatText, displayNames[0], displayNames[1]) + } else { + let formatText = action == .join + ? NSLocalizedString( + "GROUP_CALL_NOTIFICATION_ONE_JOINED_FORMAT", + comment: "Copy explaining that a user has joined the group call. Embeds {member name}" + ) + : NSLocalizedString( + "GROUP_CALL_NOTIFICATION_ONE_LEFT_FORMAT", + comment: "Copy explaining that a user has left the group call. Embeds {member name}" + ) + actionText = String(format: formatText, displayNames[0]) + } + + let hStack = UIStackView() + hStack.spacing = 12 + hStack.axis = .horizontal + hStack.isLayoutMarginsRelativeArrangement = true + hStack.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + + addSubview(hStack) + hStack.autoPinEdgesToSuperviewEdges() + + if addresses.count == 1, let address = addresses.first { + let avatarContainer = UIView() + hStack.addArrangedSubview(avatarContainer) + avatarContainer.autoSetDimension(.width, toSize: 40) + + let avatarView = UIImageView() + avatarView.layer.cornerRadius = 20 + avatarView.clipsToBounds = true + avatarContainer.addSubview(avatarView) + avatarView.autoPinWidthToSuperview() + avatarView.autoVCenterInSuperview() + avatarView.autoMatch(.height, to: .width, of: avatarView) + + if address.isLocalAddress, + let avatarImage = profileManager.localProfileAvatarImage() { + avatarView.image = avatarImage + } else { + let avatar = Self.avatarBuilder.avatarImageWithSneakyTransaction(forAddress: address, + diameterPoints: 40, + localUserDisplayMode: .asUser) + avatarView.image = avatar + } + } + + let label = UILabel() + hStack.addArrangedSubview(label) + label.setCompressionResistanceHorizontalHigh() + label.numberOfLines = 0 + label.font = UIFont.ows_dynamicTypeSubheadlineClamped.ows_semibold + label.textColor = .ows_white + label.text = actionText + + hStack.addArrangedSubview(.hStretchingSpacer()) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallSwipeToastView.swift b/Session/Calls/UserInterface/Group/GroupCallSwipeToastView.swift new file mode 100644 index 000000000..26ab76f05 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallSwipeToastView.swift @@ -0,0 +1,54 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +class GroupCallSwipeToastView: UIView { + + private let imageView: UIImageView = { + let view = UIImageView() + view.setTemplateImageName("arrow-up-20", tintColor: .ows_white) + return view + }() + + private let label: UILabel = { + let label = UILabel() + label.font = UIFont.ows_dynamicTypeBody2 + label.textColor = .ows_gray05 + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + }() + + var text: String? { + get { label.text } + set { label.text = newValue } + } + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 8 + clipsToBounds = true + isUserInteractionEnabled = false + + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + addSubview(blurView) + + let stackView = UIStackView(arrangedSubviews: [ + imageView, + label + ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 8 + addSubview(stackView) + + blurView.autoPinEdgesToSuperviewEdges() + stackView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallTooltip.swift b/Session/Calls/UserInterface/Group/GroupCallTooltip.swift new file mode 100644 index 000000000..cb8afc55c --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallTooltip.swift @@ -0,0 +1,32 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public class GroupCallTooltip: TooltipView { + @objc + public class func present(fromView: UIView, + widthReferenceView: UIView, + tailReferenceView: UIView, + wasTappedBlock: (() -> Void)?) -> GroupCallTooltip { + return GroupCallTooltip(fromView: fromView, widthReferenceView: widthReferenceView, tailReferenceView: tailReferenceView, wasTappedBlock: wasTappedBlock) + } + + public override func bubbleContentView() -> UIView { + let label = UILabel() + label.text = NSLocalizedString( + "GROUP_CALL_START_TOOLTIP", + comment: "Tooltip highlighting group calls." + ) + label.font = UIFont.ows_dynamicTypeSubheadline + label.textColor = UIColor.ows_white + + return horizontalStack(forSubviews: [label]) + } + + public override var bubbleColor: UIColor { .ows_accentGreen } + + public override var tailDirection: TooltipView.TailDirection { .up } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallVideoGrid.swift b/Session/Calls/UserInterface/Group/GroupCallVideoGrid.swift new file mode 100644 index 000000000..bda08d742 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallVideoGrid.swift @@ -0,0 +1,203 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +class GroupCallVideoGrid: UICollectionView { + weak var memberViewDelegate: GroupCallMemberViewDelegate? + let layout: GroupCallVideoGridLayout + let call: SignalCall + init(call: SignalCall) { + self.call = call + self.layout = GroupCallVideoGridLayout() + + super.init(frame: .zero, collectionViewLayout: layout) + + call.addObserverAndSyncState(observer: self) + layout.delegate = self + + register(GroupCallVideoGridCell.self, forCellWithReuseIdentifier: GroupCallVideoGridCell.reuseIdentifier) + dataSource = self + delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { call.removeObserver(self) } +} + +extension GroupCallVideoGrid: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let cell = cell as? GroupCallVideoGridCell else { return } + cell.cleanupVideoViews() + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let cell = cell as? GroupCallVideoGridCell else { return } + guard let remoteDevice = gridRemoteDeviceStates[safe: indexPath.row] else { + return owsFailDebug("missing member address") + } + cell.configureRemoteVideo(device: remoteDevice) + } +} + +extension GroupCallVideoGrid: UICollectionViewDataSource { + var gridRemoteDeviceStates: [RemoteDeviceState] { + let remoteDeviceStates = call.groupCall.remoteDeviceStates.sortedBySpeakerTime + return Array(remoteDeviceStates[0.. Int { + return gridRemoteDeviceStates.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GroupCallVideoGridCell.reuseIdentifier, + for: indexPath + ) as! GroupCallVideoGridCell + + guard let remoteDevice = gridRemoteDeviceStates[safe: indexPath.row] else { + owsFailDebug("missing member address") + return cell + } + + cell.setMemberViewDelegate(memberViewDelegate) + cell.configure(call: call, device: remoteDevice) + return cell + } +} + +extension GroupCallVideoGrid: CallObserver { + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + reloadData() + } + + func groupCallPeekChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + reloadData() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + reloadData() + } +} + +extension GroupCallVideoGrid: GroupCallVideoGridLayoutDelegate { + var maxColumns: Int { + if CurrentAppContext().frame.width > 1080 { + return 4 + } else if CurrentAppContext().frame.width > 768 { + return 3 + } else { + return 2 + } + } + + var maxRows: Int { + if CurrentAppContext().frame.height > 1024 { + return 4 + } else { + return 3 + } + } + + var maxItems: Int { maxColumns * maxRows } + + func deviceState(for index: Int) -> RemoteDeviceState? { + return gridRemoteDeviceStates[safe: index] + } +} + +class GroupCallVideoGridCell: UICollectionViewCell { + static let reuseIdentifier = "GroupCallVideoGridCell" + private let memberView = GroupCallRemoteMemberView(mode: .videoGrid) + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.addSubview(memberView) + memberView.autoPinEdgesToSuperviewEdges() + + contentView.layer.cornerRadius = 10 + contentView.clipsToBounds = true + } + + func configure(call: SignalCall, device: RemoteDeviceState) { + memberView.configure(call: call, device: device) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func cleanupVideoViews() { + memberView.cleanupVideoViews() + } + + func configureRemoteVideo(device: RemoteDeviceState) { + memberView.configureRemoteVideo(device: device) + } + + func setMemberViewDelegate(_ delegate: GroupCallMemberViewDelegate?) { + memberView.delegate = delegate + } +} + +extension Sequence where Element: RemoteDeviceState { + /// The first person to join the call is the first item in the list. + /// Members that are presenting are always put at the top of the list. + var sortedByAddedTime: [RemoteDeviceState] { + return sorted { lhs, rhs in + if lhs.presenting != rhs.presenting { + return lhs.presenting ?? false + } else if lhs.mediaKeysReceived != rhs.mediaKeysReceived { + return lhs.mediaKeysReceived + } else if lhs.addedTime != rhs.addedTime { + return lhs.addedTime < rhs.addedTime + } else { + return lhs.demuxId < rhs.demuxId + } + } + } + + /// The most recent speaker is the first item in the list. + /// Members that are presenting are always put at the top of the list. + var sortedBySpeakerTime: [RemoteDeviceState] { + return sorted { lhs, rhs in + if lhs.presenting != rhs.presenting { + return lhs.presenting ?? false + } else if lhs.mediaKeysReceived != rhs.mediaKeysReceived { + return lhs.mediaKeysReceived + } else if lhs.speakerTime != rhs.speakerTime { + return lhs.speakerTime > rhs.speakerTime + } else { + return lhs.demuxId < rhs.demuxId + } + } + } +} + +extension Dictionary where Value: RemoteDeviceState { + /// The first person to join the call is the first item in the list. + var sortedByAddedTime: [RemoteDeviceState] { + return values.sortedByAddedTime + } + + /// The most recent speaker is the first item in the list. + var sortedBySpeakerTime: [RemoteDeviceState] { + return values.sortedBySpeakerTime + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallVideoGridLayout.swift b/Session/Calls/UserInterface/Group/GroupCallVideoGridLayout.swift new file mode 100644 index 000000000..584320627 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallVideoGridLayout.swift @@ -0,0 +1,158 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +protocol GroupCallVideoGridLayoutDelegate: AnyObject { + var maxColumns: Int { get } + var maxRows: Int { get } + var maxItems: Int { get } + + func deviceState(for index: Int) -> RemoteDeviceState? +} + +class GroupCallVideoGridLayout: UICollectionViewLayout { + + public weak var delegate: GroupCallVideoGridLayoutDelegate? + + private var itemAttributesMap = [Int: UICollectionViewLayoutAttributes]() + + private var contentSize = CGSize.zero + + // MARK: Initializers and Factory Methods + + @available(*, unavailable, message: "use other constructor instead.") + required init?(coder aDecoder: NSCoder) { + notImplemented() + } + + override init() { + super.init() + } + + // MARK: Methods + + override func invalidateLayout() { + super.invalidateLayout() + + itemAttributesMap.removeAll() + } + + override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { + super.invalidateLayout(with: context) + + itemAttributesMap.removeAll() + } + + override func prepare() { + super.prepare() + + guard let collectionView = collectionView else { return } + guard let delegate = delegate else { return } + + let vInset: CGFloat = 6 + let hInset: CGFloat = 6 + let vSpacing: CGFloat = 6 + let hSpacing: CGFloat = 6 + + let maxColumns = delegate.maxColumns + let maxRows = delegate.maxRows + + let numberOfItems = min(collectionView.numberOfItems(inSection: 0), delegate.maxItems) + + guard numberOfItems > 0 else { return } + + // We evenly distribute items across rows, up to the max + // column count. If an item is alone on a row, it should + // expand across all columns. + + let possibleGrids = (1...maxColumns).reduce( + into: [(rows: Int, columns: Int)]() + ) { result, columns in + let rows = Int(ceil(CGFloat(numberOfItems) / CGFloat(columns))) + if let previousRows = result.last?.rows, previousRows == rows { return } + result.append((rows, columns)) + }.filter { $0.columns <= maxColumns && $0.rows <= maxRows } + .sorted { lhs, rhs in + // We prefer to render square grids (e.g. 2x2, 3x3, etc.) but it's + // not always possible depending on how many items we have available. + // If a square aspect ratio is not possible, we'll defer to having more + // rows than columns. + let lhsDistanceFromSquare = CGFloat(lhs.rows) / CGFloat(lhs.columns) - 1 + let rhsDistanceFromSquare = CGFloat(rhs.rows) / CGFloat(rhs.columns) - 1 + + if lhsDistanceFromSquare >= 0 && rhsDistanceFromSquare >= 0 { + return lhsDistanceFromSquare < rhsDistanceFromSquare + } else { + return lhsDistanceFromSquare > rhsDistanceFromSquare + } + } + + guard let (numberOfRows, numberOfColumns) = possibleGrids.first else { return owsFailDebug("missing grid") } + + let totalViewWidth = collectionView.width + let totalViewHeight = collectionView.height + + let verticalSpacersWidth = (2 * vInset) + (vSpacing * (CGFloat(numberOfRows) - 1)) + let verticalCellSpace = totalViewHeight - verticalSpacersWidth + + let rowHeight = verticalCellSpace / CGFloat(numberOfRows) + + // The last row may have less columns than the previous rows, + // if there is an odd number of videos. Each row should always + // expand the full width of the collection view. + var columnWidthPerRow = [CGFloat]() + for row in 1...numberOfRows { + let numberOfColumnsForRow: Int + if row == numberOfRows { + numberOfColumnsForRow = numberOfItems - ((row - 1) * numberOfColumns) + } else { + numberOfColumnsForRow = numberOfColumns + } + + let horizontalSpacersWidth = (2 * hInset) + (hSpacing * (CGFloat(numberOfColumnsForRow) - 1)) + let horizontalCellSpace = totalViewWidth - horizontalSpacersWidth + let columnWidth = horizontalCellSpace / CGFloat(numberOfColumnsForRow) + + columnWidthPerRow.append(columnWidth) + } + + for index in 0.. [UICollectionViewLayoutAttributes]? { + return itemAttributesMap.values.filter { itemAttributes in + return itemAttributes.frame.intersects(rect) + } + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + return itemAttributesMap[indexPath.row] + } + + override var collectionViewContentSize: CGSize { + return contentSize + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + return true + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallVideoOverflow.swift b/Session/Calls/UserInterface/Group/GroupCallVideoOverflow.swift new file mode 100644 index 000000000..dffad6da7 --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallVideoOverflow.swift @@ -0,0 +1,258 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +protocol GroupCallVideoOverflowDelegate: AnyObject { + var firstOverflowMemberIndex: Int { get } + func updateVideoOverflowTrailingConstraint() +} + +class GroupCallVideoOverflow: UICollectionView { + weak var memberViewDelegate: GroupCallMemberViewDelegate? + weak var overflowDelegate: GroupCallVideoOverflowDelegate? + let call: SignalCall + + class var itemHeight: CGFloat { + return UIDevice.current.isIPad ? 96 : 72 + } + + private var hasInitialized = false + + private var isAnyRemoteDeviceScreenSharing = false { + didSet { + guard oldValue != isAnyRemoteDeviceScreenSharing else { return } + updateOrientationOverride() + } + } + + init(call: SignalCall, delegate: GroupCallVideoOverflowDelegate) { + self.call = call + self.overflowDelegate = delegate + + let layout = UICollectionViewFlowLayout() + layout.itemSize = CGSize(square: Self.itemHeight) + layout.minimumLineSpacing = 4 + layout.scrollDirection = .horizontal + + super.init(frame: .zero, collectionViewLayout: layout) + + backgroundColor = .clear + alpha = 0 + + showsHorizontalScrollIndicator = false + + contentInset = UIEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + + // We want the collection view contents to render in the + // inverse of the type direction. + semanticContentAttribute = CurrentAppContext().isRTL ? .forceLeftToRight : .forceRightToLeft + + autoSetDimension(.height, toSize: Self.itemHeight) + + register(GroupCallVideoOverflowCell.self, forCellWithReuseIdentifier: GroupCallVideoOverflowCell.reuseIdentifier) + dataSource = self + self.delegate = self + + call.addObserverAndSyncState(observer: self) + hasInitialized = true + + NotificationCenter.default.addObserver( + self, + selector: #selector(updateOrientationOverride), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { call.removeObserver(self) } + + private enum OrientationOverride { + case landscapeLeft + case landscapeRight + } + private var orientationOverride: OrientationOverride? { + didSet { + guard orientationOverride != oldValue else { return } + reloadData() + } + } + + @objc + func updateOrientationOverride() { + // If we're on iPhone and screen sharing, we want to allow + // the user to change the orientation. We fake this by + // manually transforming the cells. + guard !UIDevice.current.isIPad && isAnyRemoteDeviceScreenSharing else { + orientationOverride = nil + return + } + + switch UIDevice.current.orientation { + case .faceDown, .faceUp, .unknown: + // Do nothing, assume the last orientation was already applied. + break + case .portrait, .portraitUpsideDown: + // Clear any override + orientationOverride = nil + case .landscapeLeft: + orientationOverride = .landscapeLeft + case .landscapeRight: + orientationOverride = .landscapeRight + @unknown default: + break + } + } + + private var isAnimating = false + private var hadVisibleCells = false + override func reloadData() { + guard !isAnimating else { return } + + defer { + if hasInitialized { overflowDelegate?.updateVideoOverflowTrailingConstraint() } + } + + let hasVisibleCells = overflowedRemoteDeviceStates.count > 0 + + if hasVisibleCells != hadVisibleCells { + hadVisibleCells = hasVisibleCells + isAnimating = true + if hasVisibleCells { super.reloadData() } + UIView.animate( + withDuration: 0.15, + animations: { self.alpha = hasVisibleCells ? 1 : 0 } + ) { _ in + self.isAnimating = false + self.reloadData() + } + } else { + super.reloadData() + } + } +} + +extension GroupCallVideoOverflow: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let cell = cell as? GroupCallVideoOverflowCell else { return } + cell.cleanupVideoViews() + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let cell = cell as? GroupCallVideoOverflowCell else { return } + guard let remoteDevice = overflowedRemoteDeviceStates[safe: indexPath.row] else { + return owsFailDebug("missing member address") + } + cell.configureRemoteVideo(device: remoteDevice) + + if let orientationOverride = orientationOverride { + switch orientationOverride { + case .landscapeRight: + cell.transform = .init(rotationAngle: -.halfPi) + case .landscapeLeft: + cell.transform = .init(rotationAngle: .halfPi) + } + } else { + cell.transform = .identity + } + } +} + +extension GroupCallVideoOverflow: UICollectionViewDataSource { + var overflowedRemoteDeviceStates: [RemoteDeviceState] { + guard let firstOverflowMemberIndex = overflowDelegate?.firstOverflowMemberIndex else { return [] } + + let joinedRemoteDeviceStates = call.groupCall.remoteDeviceStates.sortedBySpeakerTime + + guard joinedRemoteDeviceStates.count > firstOverflowMemberIndex else { return [] } + + // We reverse this as we're rendering in the inverted direction. + return Array(joinedRemoteDeviceStates[firstOverflowMemberIndex.. Int { + return overflowedRemoteDeviceStates.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GroupCallVideoOverflowCell.reuseIdentifier, + for: indexPath + ) as! GroupCallVideoOverflowCell + + guard let remoteDevice = overflowedRemoteDeviceStates[safe: indexPath.row] else { + owsFailDebug("missing member address") + return cell + } + + cell.setMemberViewDelegate(memberViewDelegate) + cell.configure(call: call, device: remoteDevice) + return cell + } +} + +extension GroupCallVideoOverflow: CallObserver { + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + isAnyRemoteDeviceScreenSharing = call.groupCall.remoteDeviceStates.values.first { $0.sharingScreen == true } != nil + + reloadData() + } + + func groupCallPeekChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + reloadData() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + reloadData() + } +} + +class GroupCallVideoOverflowCell: UICollectionViewCell { + static let reuseIdentifier = "GroupCallVideoOverflowCell" + private let memberView = GroupCallRemoteMemberView(mode: .videoOverflow) + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.addSubview(memberView) + memberView.autoPinEdgesToSuperviewEdges() + + contentView.layer.cornerRadius = 10 + contentView.clipsToBounds = true + } + + func configure(call: SignalCall, device: RemoteDeviceState) { + memberView.configure(call: call, device: device) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func cleanupVideoViews() { + memberView.cleanupVideoViews() + } + + func configureRemoteVideo(device: RemoteDeviceState) { + memberView.configureRemoteVideo(device: device) + } + + func setMemberViewDelegate(_ delegate: GroupCallMemberViewDelegate?) { + memberView.delegate = delegate + } +} diff --git a/Session/Calls/UserInterface/Group/GroupCallViewController.swift b/Session/Calls/UserInterface/Group/GroupCallViewController.swift new file mode 100644 index 000000000..fe481a44d --- /dev/null +++ b/Session/Calls/UserInterface/Group/GroupCallViewController.swift @@ -0,0 +1,851 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalRingRTC + +// TODO: Eventually add 1:1 call support to this view +// and replace CallViewController +class GroupCallViewController: UIViewController { + private let thread: TSGroupThread? + private let call: SignalCall + private var groupCall: GroupCall { call.groupCall } + private lazy var callControls = CallControls(call: call, delegate: self) + private lazy var callHeader = CallHeader(call: call, delegate: self) + private lazy var notificationView = GroupCallNotificationView(call: call) + + private lazy var videoGrid = GroupCallVideoGrid(call: call) + private lazy var videoOverflow = GroupCallVideoOverflow(call: call, delegate: self) + + private let localMemberView = GroupCallLocalMemberView() + private let speakerView = GroupCallRemoteMemberView(mode: .speaker) + + private var didUserEverSwipeToSpeakerView = true + private var didUserEverSwipeToScreenShare = true + private let swipeToastView = GroupCallSwipeToastView() + + private var speakerPage = UIView() + + private let scrollView = UIScrollView() + + private var isCallMinimized = false { + didSet { speakerView.isCallMinimized = isCallMinimized } + } + + private var isAutoScrollingToScreenShare = false + private var isAnyRemoteDeviceScreenSharing = false { + didSet { + guard oldValue != isAnyRemoteDeviceScreenSharing else { return } + + // Scroll to speaker view when presenting begins. + if isAnyRemoteDeviceScreenSharing { + isAutoScrollingToScreenShare = true + scrollView.setContentOffset(CGPoint(x: 0, y: speakerPage.frame.origin.y), animated: true) + } + } + } + + lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTouchRootView)) + lazy var videoOverflowTopConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .top) + lazy var videoOverflowTrailingConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .trailing) + + var shouldRemoteVideoControlsBeHidden = false { + didSet { updateCallUI() } + } + var hasUnresolvedSafetyNumberMismatch = false + + private static let keyValueStore = SDSKeyValueStore(collection: "GroupCallViewController") + private static let didUserSwipeToSpeakerViewKey = "didUserSwipeToSpeakerView" + private static let didUserSwipeToScreenShareKey = "didUserSwipeToScreenShare" + + init(call: SignalCall) { + // TODO: Eventually unify UI for group and individual calls + owsAssertDebug(call.isGroupCall) + + self.call = call + self.thread = call.thread as? TSGroupThread + + super.init(nibName: nil, bundle: nil) + + call.addObserverAndSyncState(observer: self) + + videoGrid.memberViewDelegate = self + videoOverflow.memberViewDelegate = self + speakerView.delegate = self + localMemberView.delegate = self + + SDSDatabaseStorage.shared.asyncRead { readTx in + self.didUserEverSwipeToSpeakerView = Self.keyValueStore.getBool( + Self.didUserSwipeToSpeakerViewKey, + defaultValue: false, + transaction: readTx + ) + self.didUserEverSwipeToScreenShare = Self.keyValueStore.getBool( + Self.didUserSwipeToScreenShareKey, + defaultValue: false, + transaction: readTx + ) + } completion: { + self.updateSwipeToastView() + } + } + + @discardableResult + @objc(presentLobbyForThread:) + class func presentLobby(thread: TSGroupThread) -> Bool { + guard let frontmostViewController = UIApplication.shared.frontmostViewController else { + owsFailDebug("could not identify frontmostViewController") + return false + } + + frontmostViewController.ows_ask(forMicrophonePermissions: { granted in + guard granted == true else { + Logger.warn("aborting due to missing microphone permissions.") + frontmostViewController.ows_showNoMicrophonePermissionActionSheet() + return + } + + frontmostViewController.ows_ask(forCameraPermissions: { granted in + guard granted else { + Logger.warn("aborting due to missing camera permissions.") + return + } + + guard let groupCall = Self.callService.buildAndConnectGroupCallIfPossible( + thread: thread + ) else { + return owsFailDebug("Failed to build group call") + } + + // Dismiss the group calling megaphone once someone opens the lobby. + ExperienceUpgradeManager.clearExperienceUpgradeWithSneakyTransaction(.groupCallsMegaphone) + + // Dismiss the group call tooltip + self.preferences.setWasGroupCallTooltipShown() + + let vc = GroupCallViewController(call: groupCall) + vc.modalTransitionStyle = .crossDissolve + + OWSWindowManager.shared.startCall(vc) + }) + }) + + return true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = UIView() + view.clipsToBounds = true + + view.backgroundColor = .ows_black + + scrollView.delegate = self + view.addSubview(scrollView) + scrollView.isPagingEnabled = true + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.alwaysBounceVertical = false + scrollView.autoPinEdgesToSuperviewEdges() + + view.addSubview(callHeader) + callHeader.autoPinWidthToSuperview() + callHeader.autoPinEdge(toSuperviewEdge: .top) + + view.addSubview(notificationView) + notificationView.autoPinEdgesToSuperviewEdges() + + view.addSubview(callControls) + callControls.autoPinWidthToSuperview() + callControls.autoPinEdge(toSuperviewEdge: .bottom) + + view.addSubview(videoOverflow) + videoOverflow.autoPinEdge(toSuperviewEdge: .leading) + + scrollView.addSubview(videoGrid) + scrollView.addSubview(speakerPage) + + scrollView.addSubview(swipeToastView) + swipeToastView.autoPinEdge(.bottom, to: .bottom, of: videoGrid, withOffset: -22) + swipeToastView.autoHCenterInSuperview() + swipeToastView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual) + swipeToastView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual) + + view.addGestureRecognizer(tapGesture) + + updateCallUI() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + let wasOnSpeakerPage = scrollView.contentOffset.y >= view.height + + coordinator.animate(alongsideTransition: { _ in + self.updateCallUI(size: size) + self.videoGrid.reloadData() + self.videoOverflow.reloadData() + self.scrollView.contentOffset = wasOnSpeakerPage ? CGPoint(x: 0, y: size.height) : .zero + }, completion: nil) + } + + private var hasAppeared = false + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + guard !hasAppeared else { return } + hasAppeared = true + + guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else { + return owsFailDebug("failed to snapshot rootViewController") + } + + view.superview?.insertSubview(splitViewSnapshot, belowSubview: view) + splitViewSnapshot.autoPinEdgesToSuperviewEdges() + + view.transform = .scale(1.5) + view.alpha = 0 + + UIView.animate(withDuration: 0.2, animations: { + self.view.alpha = 1 + self.view.transform = .identity + }) { _ in + splitViewSnapshot.removeFromSuperview() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if hasUnresolvedSafetyNumberMismatch { + resolveSafetyNumberMismatch() + } + } + + private var hasOverflowMembers: Bool { videoGrid.maxItems < groupCall.remoteDeviceStates.count } + + private func updateScrollViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) { + view.layoutIfNeeded() + + let size = size ?? view.frame.size + + if groupCall.remoteDeviceStates.count < 2 || groupCall.localDeviceState.joinState != .joined { + videoGrid.frame = .zero + videoGrid.isHidden = true + speakerPage.frame = CGRect( + x: 0, + y: 0, + width: size.width, + height: size.height + ) + scrollView.contentSize = size + scrollView.contentOffset = .zero + scrollView.isScrollEnabled = false + } else { + let wasVideoGridHidden = videoGrid.isHidden + + scrollView.isScrollEnabled = true + videoGrid.isHidden = false + videoGrid.frame = CGRect( + x: 0, + y: view.safeAreaInsets.top, + width: size.width, + height: size.height - view.safeAreaInsets.top - (controlsAreHidden ? 16 : callControls.height) - (hasOverflowMembers ? videoOverflow.height + 32 : 0) + ) + speakerPage.frame = CGRect( + x: 0, + y: size.height, + width: size.width, + height: size.height + ) + scrollView.contentSize = CGSize(width: size.width, height: size.height * 2) + + if wasVideoGridHidden { + scrollView.contentOffset = .zero + } + } + } + + func updateVideoOverflowTrailingConstraint() { + var trailingConstraintConstant = -(GroupCallVideoOverflow.itemHeight * ReturnToCallViewController.pipSize.aspectRatio + 4) + if view.width + trailingConstraintConstant > videoOverflow.contentSize.width { + trailingConstraintConstant += 16 + } + videoOverflowTrailingConstraint.constant = trailingConstraintConstant + view.layoutIfNeeded() + } + + private func updateMemberViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) { + view.layoutIfNeeded() + + let size = size ?? view.frame.size + + let yMax = (controlsAreHidden ? size.height - 16 : callControls.frame.minY) - 16 + + videoOverflowTopConstraint.constant = yMax - videoOverflow.height + + updateVideoOverflowTrailingConstraint() + + localMemberView.removeFromSuperview() + speakerView.removeFromSuperview() + + switch groupCall.localDeviceState.joinState { + case .joined: + if groupCall.remoteDeviceStates.count > 0 { + speakerPage.addSubview(speakerView) + speakerView.autoPinEdgesToSuperviewEdges() + + view.addSubview(localMemberView) + + if groupCall.remoteDeviceStates.count > 1 { + let pipWidth = GroupCallVideoOverflow.itemHeight * ReturnToCallViewController.pipSize.aspectRatio + let pipHeight = GroupCallVideoOverflow.itemHeight + localMemberView.frame = CGRect( + x: size.width - pipWidth - 16, + y: videoOverflow.frame.origin.y, + width: pipWidth, + height: pipHeight + ) + } else { + let pipWidth = ReturnToCallViewController.pipSize.width + let pipHeight = ReturnToCallViewController.pipSize.height + + localMemberView.frame = CGRect( + x: size.width - pipWidth - 16, + y: yMax - pipHeight, + width: pipWidth, + height: pipHeight + ) + } + } else { + speakerPage.addSubview(localMemberView) + localMemberView.frame = CGRect(origin: .zero, size: size) + } + case .notJoined, .joining: + speakerPage.addSubview(localMemberView) + localMemberView.frame = CGRect(origin: .zero, size: size) + } + } + + func updateSwipeToastView() { + let isSpeakerViewAvailable = groupCall.remoteDeviceStates.count >= 2 && groupCall.localDeviceState.joinState == .joined + guard isSpeakerViewAvailable else { + swipeToastView.isHidden = true + return + } + + if isAnyRemoteDeviceScreenSharing { + if didUserEverSwipeToScreenShare { + swipeToastView.isHidden = true + return + } + } else if didUserEverSwipeToSpeakerView { + swipeToastView.isHidden = true + return + } + + swipeToastView.alpha = 1.0 - (scrollView.contentOffset.y / view.height) + swipeToastView.text = isAnyRemoteDeviceScreenSharing + ? NSLocalizedString( + "GROUP_CALL_SCREEN_SHARE_TOAST", + comment: "Toast view text informing user about swiping to screen share" + ) + : NSLocalizedString( + "GROUP_CALL_SPEAKER_VIEW_TOAST", + comment: "Toast view text informing user about swiping to speaker view" + ) + + if scrollView.contentOffset.y >= view.height { + swipeToastView.isHidden = true + + if isAnyRemoteDeviceScreenSharing { + if !isAutoScrollingToScreenShare { + didUserEverSwipeToScreenShare = true + SDSDatabaseStorage.shared.asyncWrite { writeTx in + Self.keyValueStore.setBool(true, key: Self.didUserSwipeToScreenShareKey, transaction: writeTx) + } + } + } else { + didUserEverSwipeToSpeakerView = true + SDSDatabaseStorage.shared.asyncWrite { writeTx in + Self.keyValueStore.setBool(true, key: Self.didUserSwipeToSpeakerViewKey, transaction: writeTx) + } + } + + } else if swipeToastView.isHidden { + swipeToastView.alpha = 0 + swipeToastView.isHidden = false + UIView.animate(withDuration: 0.2, delay: 3.0, options: []) { + self.swipeToastView.alpha = 1 + } + + } + } + + func updateCallUI(size: CGSize? = nil) { + let localDevice = groupCall.localDeviceState + + localMemberView.configure( + call: call, + isFullScreen: localDevice.joinState != .joined || groupCall.remoteDeviceStates.isEmpty + ) + + if let speakerState = groupCall.remoteDeviceStates.sortedBySpeakerTime.first { + speakerView.configure( + call: call, + device: speakerState + ) + } else { + speakerView.clearConfiguration() + } + + // Setting the speakerphone before we join the call will fail, + // but we can re-apply the setting here in case it did not work. + if groupCall.isOutgoingVideoMuted && !callService.audioService.hasExternalInputs { + callService.audioService.requestSpeakerphone(isEnabled: callControls.audioSourceButton.isSelected) + } + + guard !isCallMinimized else { return } + + let hideRemoteControls = shouldRemoteVideoControlsBeHidden && !groupCall.remoteDeviceStates.isEmpty + let remoteControlsAreHidden = callControls.isHidden && callHeader.isHidden + if hideRemoteControls != remoteControlsAreHidden { + callControls.isHidden = false + callHeader.isHidden = false + + UIView.animate(withDuration: 0.15, animations: { + self.callControls.alpha = hideRemoteControls ? 0 : 1 + self.callHeader.alpha = hideRemoteControls ? 0 : 1 + + self.updateMemberViewFrames(size: size, controlsAreHidden: hideRemoteControls) + self.updateScrollViewFrames(size: size, controlsAreHidden: hideRemoteControls) + self.view.layoutIfNeeded() + }) { _ in + self.callControls.isHidden = hideRemoteControls + self.callHeader.isHidden = hideRemoteControls + } + } else { + updateMemberViewFrames(size: size, controlsAreHidden: hideRemoteControls) + updateScrollViewFrames(size: size, controlsAreHidden: hideRemoteControls) + } + + scheduleControlTimeoutIfNecessary() + updateSwipeToastView() + } + + func dismissCall() { + callService.terminate(call: call) + + guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else { + OWSWindowManager.shared.endCall(self) + return owsFailDebug("failed to snapshot rootViewController") + } + + view.superview?.insertSubview(splitViewSnapshot, belowSubview: view) + splitViewSnapshot.autoPinEdgesToSuperviewEdges() + + UIView.animate(withDuration: 0.2, animations: { + self.view.alpha = 0 + }) { _ in + splitViewSnapshot.removeFromSuperview() + OWSWindowManager.shared.endCall(self) + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override var prefersHomeIndicatorAutoHidden: Bool { + return true + } + + // MARK: - Video control timeout + + @objc func didTouchRootView(sender: UIGestureRecognizer) { + shouldRemoteVideoControlsBeHidden = !shouldRemoteVideoControlsBeHidden + } + + private var controlTimeoutTimer: Timer? + private func scheduleControlTimeoutIfNecessary() { + if groupCall.remoteDeviceStates.isEmpty || shouldRemoteVideoControlsBeHidden { + controlTimeoutTimer?.invalidate() + controlTimeoutTimer = nil + } + + guard controlTimeoutTimer == nil else { return } + controlTimeoutTimer = .weakScheduledTimer( + withTimeInterval: 5, + target: self, + selector: #selector(timeoutControls), + userInfo: nil, + repeats: false + ) + } + + @objc + private func timeoutControls() { + controlTimeoutTimer?.invalidate() + controlTimeoutTimer = nil + + guard !isCallMinimized && !groupCall.remoteDeviceStates.isEmpty && !shouldRemoteVideoControlsBeHidden else { return } + shouldRemoteVideoControlsBeHidden = true + } +} + +extension GroupCallViewController: CallViewControllerWindowReference { + var localVideoViewReference: UIView { localMemberView } + var remoteVideoViewReference: UIView { speakerView } + + var remoteVideoAddress: String { + guard let firstMember = groupCall.remoteDeviceStates.sortedByAddedTime.first else { + return tsAccountManager.localAddress! + } + return firstMember.address + } + + @objc + public func returnFromPip(pipWindow: UIWindow) { + // The call "pip" uses our remote and local video views since only + // one `AVCaptureVideoPreviewLayer` per capture session is supported. + // We need to re-add them when we return to this view. + guard speakerView.superview != speakerPage && localMemberView.superview != view else { + return owsFailDebug("unexpectedly returned to call while we own the video views") + } + + guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else { + return owsFailDebug("failed to snapshot rootViewController") + } + + guard let pipSnapshot = pipWindow.snapshotView(afterScreenUpdates: false) else { + return owsFailDebug("failed to snapshot pip") + } + + isCallMinimized = false + shouldRemoteVideoControlsBeHidden = false + + animateReturnFromPip(pipSnapshot: pipSnapshot, pipFrame: pipWindow.frame, splitViewSnapshot: splitViewSnapshot) + } + + private func animateReturnFromPip(pipSnapshot: UIView, pipFrame: CGRect, splitViewSnapshot: UIView) { + guard let window = view.window else { return owsFailDebug("missing window") } + view.superview?.insertSubview(splitViewSnapshot, belowSubview: view) + splitViewSnapshot.autoPinEdgesToSuperviewEdges() + + let originalContentOffset = scrollView.contentOffset + + view.frame = pipFrame + view.addSubview(pipSnapshot) + pipSnapshot.autoPinEdgesToSuperviewEdges() + + view.layoutIfNeeded() + + UIView.animate(withDuration: 0.2, animations: { + pipSnapshot.alpha = 0 + self.view.frame = window.frame + self.updateCallUI() + self.scrollView.contentOffset = originalContentOffset + self.view.layoutIfNeeded() + }) { _ in + splitViewSnapshot.removeFromSuperview() + pipSnapshot.removeFromSuperview() + + if self.hasUnresolvedSafetyNumberMismatch { + self.resolveSafetyNumberMismatch() + } + } + } + + func resolveSafetyNumberMismatch() { + if !isCallMinimized, CurrentAppContext().isAppForegroundAndActive() { + presentSafetyNumberChangeSheetIfNecessary { [weak self] success in + guard let self = self else { return } + if success { + self.groupCall.resendMediaKeys() + self.hasUnresolvedSafetyNumberMismatch = false + } else { + self.dismissCall() + } + } + } else { + let notificationPresenter = AppEnvironment.shared.notificationPresenter + notificationPresenter.notifyForGroupCallSafetyNumberChange(inThread: call.thread) + } + } + + func presentSafetyNumberChangeSheetIfNecessary(completion: @escaping (Bool) -> Void) { + let localDeviceHasNotJoined = groupCall.localDeviceState.joinState == .notJoined + let currentParticipantAddresses = groupCall.remoteDeviceStates.map { $0.value.address } + + // If we haven't joined the call yet, we want to alert for all members of the group + // If we are in the call, we only care about safety numbers for the active call participants + let addressesToAlert = call.thread.recipientAddresses.filter { memberAddress in + let isUntrusted = Self.identityManager.untrustedIdentityForSending(to: memberAddress) != nil + let isMemberInCall = currentParticipantAddresses.contains(memberAddress) + + // We want to alert for safety number changes of all members if we haven't joined yet + // If we're already in the call, we only care about active call participants + return isUntrusted && (isMemberInCall || localDeviceHasNotJoined) + } + + // There are no unverified addresses that we're currently concerned about. No need to show a sheet + guard addressesToAlert.count > 0 else { return completion(true) } + + let startCallString = NSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call") + let joinCallString = NSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call") + let continueCallString = NSLocalizedString("GROUP_CALL_CONTINUE_BUTTON", comment: "Button to continue an ongoing group call") + let leaveCallString = NSLocalizedString("GROUP_CALL_LEAVE_BUTTON", comment: "Button to leave a group call") + let cancelString = CommonStrings.cancelButton + + let approveText: String + let denyText: String + if localDeviceHasNotJoined { + let deviceCount = call.groupCall.peekInfo?.deviceCount ?? 0 + approveText = deviceCount > 0 ? joinCallString : startCallString + denyText = cancelString + } else { + approveText = continueCallString + denyText = leaveCallString + } + + let sheet = SafetyNumberConfirmationSheet( + addressesToConfirm: addressesToAlert, + confirmationText: approveText, + cancelText: denyText, + theme: .translucentDark) { didApprove in + + if didApprove { + SDSDatabaseStorage.shared.asyncWrite { writeTx in + let identityManager = Self.identityManager + for address in addressesToAlert { + guard let identityKey = identityManager.identityKey(for: address, transaction: writeTx) else { return } + let currentState = identityManager.verificationState(for: address, transaction: writeTx) + let newState = (currentState == .noLongerVerified) ? .default : currentState + + identityManager.setVerificationState(newState, + identityKey: identityKey, + address: address, + isUserInitiatedChange: true, + transaction: writeTx) + } + } completion: { + completion(true) + } + + } else { + completion(false) + } + } + sheet.allowsDismissal = localDeviceHasNotJoined + present(sheet, animated: true, completion: nil) + } +} + +extension GroupCallViewController: CallObserver { + func groupCallLocalDeviceStateChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateCallUI() + } + + func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + isAnyRemoteDeviceScreenSharing = call.groupCall.remoteDeviceStates.values.first { $0.sharingScreen == true } != nil + + updateCallUI() + } + + func groupCallPeekChanged(_ call: SignalCall) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + updateCallUI() + } + + func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { + AssertIsOnMainThread() + owsAssertDebug(call.isGroupCall) + + defer { updateCallUI() } + + guard reason != .deviceExplicitlyDisconnected else { return } + + let title: String + + if reason == .hasMaxDevices { + if let maxDevices = groupCall.maxDevices { + let formatString = NSLocalizedString( + "GROUP_CALL_HAS_MAX_DEVICES_FORMAT", + comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}." + ) + title = String(format: formatString, maxDevices) + } else { + title = NSLocalizedString( + "GROUP_CALL_HAS_MAX_DEVICES_UNKNOWN_COUNT", + comment: "An error displayed to the user when the group call ends because it has exceeded the max devices." + ) + } + } else { + owsFailDebug("Group call ended with reason \(reason)") + title = NSLocalizedString( + "GROUP_CALL_UNEXPECTEDLY_ENDED", + comment: "An error displayed to the user when the group call unexpectedly ends." + ) + } + + let actionSheet = ActionSheetController(title: title) + actionSheet.addAction(ActionSheetAction( + title: CommonStrings.okButton, + style: .default, + handler: { [weak self] _ in + guard reason == .hasMaxDevices else { return } + self?.dismissCall() + } + )) + presentActionSheet(actionSheet) + } + + func callMessageSendFailedUntrustedIdentity(_ call: SignalCall) { + AssertIsOnMainThread() + guard call == self.call else { return owsFailDebug("Unexpected call \(call)") } + + if !hasUnresolvedSafetyNumberMismatch { + hasUnresolvedSafetyNumberMismatch = true + resolveSafetyNumberMismatch() + } + } +} + +extension GroupCallViewController: CallControlsDelegate { + func didPressHangup(sender: UIButton) { + dismissCall() + } + + func didPressAudioSource(sender: UIButton) { + if callService.audioService.hasExternalInputs { + callService.audioService.presentRoutePicker() + } else { + sender.isSelected = !sender.isSelected + callService.audioService.requestSpeakerphone(isEnabled: sender.isSelected) + } + } + + func didPressMute(sender: UIButton) { + sender.isSelected = !sender.isSelected + callService.updateIsLocalAudioMuted(isLocalAudioMuted: sender.isSelected) + } + + func didPressVideo(sender: UIButton) { + sender.isSelected = !sender.isSelected + + callService.updateIsLocalVideoMuted(isLocalVideoMuted: !sender.isSelected) + + // When turning off video, default speakerphone to on. + if !sender.isSelected && !callService.audioService.hasExternalInputs { + callControls.audioSourceButton.isSelected = true + callService.audioService.requestSpeakerphone(isEnabled: true) + } + } + + func didPressFlipCamera(sender: UIButton) { + sender.isSelected = !sender.isSelected + callService.updateCameraSource(call: call, isUsingFrontCamera: !sender.isSelected) + } + + func didPressCancel(sender: UIButton) { + dismissCall() + } + + func didPressJoin(sender: UIButton) { + presentSafetyNumberChangeSheetIfNecessary { [weak self] success in + guard let self = self else { return } + if success { + self.callService.joinGroupCallIfNecessary(self.call) + } + } + } +} + +extension GroupCallViewController: CallHeaderDelegate { + func didTapBackButton() { + if groupCall.localDeviceState.joinState == .joined { + isCallMinimized = true + OWSWindowManager.shared.leaveCallView() + } else { + dismissCall() + } + } + + func didTapMembersButton() { + let sheet = GroupCallMemberSheet(call: call) + present(sheet, animated: true) + } +} + +extension GroupCallViewController: GroupCallVideoOverflowDelegate { + var firstOverflowMemberIndex: Int { + if scrollView.contentOffset.y >= view.height { + return 1 + } else { + return videoGrid.maxItems + } + } +} + +extension GroupCallViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + // If we changed pages, update the overflow view. + if scrollView.contentOffset.y == 0 || scrollView.contentOffset.y == view.height { + videoOverflow.reloadData() + updateCallUI() + } + + if isAutoScrollingToScreenShare { + isAutoScrollingToScreenShare = scrollView.contentOffset.y != speakerView.frame.origin.y + } + + updateSwipeToastView() + } +} + +extension GroupCallViewController: GroupCallMemberViewDelegate { + func memberView(_ view: GroupCallMemberView, userRequestedInfoAboutError error: GroupCallMemberView.ErrorState) { + let title: String + let message: String + + switch error { + case let .blocked(address): + message = NSLocalizedString( + "GROUP_CALL_BLOCKED_ALERT_MESSAGE", + comment: "Message body for alert explaining that a group call participant is blocked") + + let titleFormat = NSLocalizedString( + "GROUP_CALL_BLOCKED_ALERT_TITLE_FORMAT", + comment: "Title for alert explaining that a group call participant is blocked. Embeds {{ user's name }}") + let displayName = contactsManager.displayName(for: address) + title = String(format: titleFormat, displayName) + + case let .noMediaKeys(address): + message = NSLocalizedString( + "GROUP_CALL_NO_KEYS_ALERT_MESSAGE", + comment: "Message body for alert explaining that a group call participant cannot be displayed because of missing keys") + + let titleFormat = NSLocalizedString( + "GROUP_CALL_NO_KEYS_ALERT_TITLE_FORMAT", + comment: "Title for alert explaining that a group call participant cannot be displayed because of missing keys. Embeds {{ user's name }}") + let displayName = contactsManager.displayName(for: address) + title = String(format: titleFormat, displayName) + } + + let actionSheet = ActionSheetController(title: title, message: message, theme: .translucentDark) + actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton)) + presentActionSheet(actionSheet) + } +} diff --git a/Session/Calls/UserInterface/Individual/CallKit/CallKitCallManager.swift b/Session/Calls/UserInterface/Individual/CallKit/CallKitCallManager.swift new file mode 100644 index 000000000..990bfa642 --- /dev/null +++ b/Session/Calls/UserInterface/Individual/CallKit/CallKitCallManager.swift @@ -0,0 +1,151 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import UIKit +import CallKit + +/** + * Requests actions from CallKit + * + * @Discussion: + * Based on SpeakerboxCallManager, from the Apple CallKit Example app. Though, it's responsibilities are mostly + * mirrored (and delegated from) CallKitCallUIAdaptee. + * TODO: Would it simplify things to merge this into CallKitCallUIAdaptee? + */ +final class CallKitCallManager: NSObject { + + let callController = CXCallController() + let showNamesOnCallScreen: Bool + + @objc + static let kAnonymousCallHandlePrefix = "Signal:" + + required init(showNamesOnCallScreen: Bool) { + AssertIsOnMainThread() + + self.showNamesOnCallScreen = showNamesOnCallScreen + super.init() + + // We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings + } + + // MARK: Actions + + func startCall(_ call: SignalCall) { + let handle: CXHandle + + if showNamesOnCallScreen { + let type: CXHandle.HandleType + let value: String + if let phoneNumber = call.individualCall.remoteAddress.phoneNumber { + type = .phoneNumber + value = phoneNumber + } else { + type = .generic + value = call.individualCall.remoteAddress.stringForDisplay + } + handle = CXHandle(type: type, value: value) + } else { + let callKitId = CallKitCallManager.kAnonymousCallHandlePrefix + call.individualCall.localId.uuidString + handle = CXHandle(type: .generic, value: callKitId) + CallKitIdStore.setAddress(call.individualCall.remoteAddress, forCallKitId: callKitId) + } + + let startCallAction = CXStartCallAction(call: call.individualCall.localId, handle: handle) + + startCallAction.isVideo = call.individualCall.hasLocalVideo + + let transaction = CXTransaction() + transaction.addAction(startCallAction) + + requestTransaction(transaction) + } + + func localHangup(call: SignalCall) { + let endCallAction = CXEndCallAction(call: call.individualCall.localId) + let transaction = CXTransaction() + transaction.addAction(endCallAction) + + requestTransaction(transaction) + } + + func setHeld(call: SignalCall, onHold: Bool) { + let setHeldCallAction = CXSetHeldCallAction(call: call.individualCall.localId, onHold: onHold) + let transaction = CXTransaction() + transaction.addAction(setHeldCallAction) + + requestTransaction(transaction) + } + + func setIsMuted(call: SignalCall, isMuted: Bool) { + let muteCallAction = CXSetMutedCallAction(call: call.individualCall.localId, muted: isMuted) + let transaction = CXTransaction() + transaction.addAction(muteCallAction) + + requestTransaction(transaction) + } + + func answer(call: SignalCall) { + let answerCallAction = CXAnswerCallAction(call: call.individualCall.localId) + let transaction = CXTransaction() + transaction.addAction(answerCallAction) + + requestTransaction(transaction) + } + + private func requestTransaction(_ transaction: CXTransaction) { + callController.request(transaction) { error in + if let error = error { + Logger.error("Error requesting transaction: \(error)") + } else { + Logger.debug("Requested transaction successfully") + } + } + } + + // MARK: Call Management + + private(set) var calls = [SignalCall]() + + func callWithLocalId(_ localId: UUID) -> SignalCall? { + guard let index = calls.firstIndex(where: { $0.individualCall.localId == localId }) else { + return nil + } + return calls[index] + } + + func addCall(_ call: SignalCall) { + Logger.verbose("call: \(call)") + owsAssertDebug(call.isIndividualCall) + call.individualCall.wasReportedToSystem = true + calls.append(call) + } + + func removeCall(_ call: SignalCall) { + Logger.verbose("call: \(call)") + owsAssertDebug(call.isIndividualCall) + call.individualCall.wasRemovedFromSystem = true + guard calls.removeFirst(where: { $0 === call }) != nil else { + Logger.warn("no call matching: \(call) to remove") + return + } + } + + func removeAllCalls() { + Logger.verbose("") + calls.forEach { $0.individualCall.wasRemovedFromSystem = true } + calls.removeAll() + } +} + +fileprivate extension Array { + + mutating func removeFirst(where predicate: (Element) throws -> Bool) rethrows -> Element? { + guard let index = try firstIndex(where: predicate) else { + return nil + } + + return remove(at: index) + } +} diff --git a/Session/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift b/Session/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift new file mode 100644 index 000000000..ec3d983c4 --- /dev/null +++ b/Session/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift @@ -0,0 +1,455 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit +import CallKit +import AVFoundation + +/** + * Connects user interface to the CallService using CallKit. + * + * User interface is routed to the CallManager which requests CXCallActions, and if the CXProvider accepts them, + * their corresponding consequences are implmented in the CXProviderDelegate methods, e.g. using the CallService + */ +final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { + + private let callManager: CallKitCallManager + private let showNamesOnCallScreen: Bool + private let provider: CXProvider + private let audioActivity: AudioActivity + + // CallKit handles incoming ringer stop/start for us. Yay! + let hasManualRinger = false + + // Instantiating more than one CXProvider can cause us to miss call transactions, so + // we maintain the provider across Adaptees using a singleton pattern + private static var _sharedProvider: CXProvider? + class func sharedProvider(useSystemCallLog: Bool) -> CXProvider { + let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog) + + if let sharedProvider = self._sharedProvider { + sharedProvider.configuration = configuration + return sharedProvider + } else { + SwiftSingletons.register(self) + let provider = CXProvider(configuration: configuration) + _sharedProvider = provider + return provider + } + } + + // The app's provider configuration, representing its CallKit capabilities + class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { + let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application") + let providerConfiguration = CXProviderConfiguration(localizedName: localizedName) + + providerConfiguration.supportsVideo = true + + // Default maximumCallGroups is 2. We previously overrode this value to be 1. + // + // The terminology can be confusing. Even though we don't currently support "group calls" + // *every* call is in a call group. Our call groups all just happen to be "groups" with 1 + // call in them. + // + // maximumCallGroups limits how many different calls CallKit can know about at one time. + // Exceeding this limit will cause CallKit to error when reporting an additional call. + // + // Generally for us, the number of call groups is 1 or 0, *however* when handling a rapid + // sequence of offers and hangups, due to the async nature of CXTransactions, there can + // be a brief moment where the old limit of 1 caused CallKit to fail the newly reported + // call, even though we were properly requesting hangup of the old call before reporting the + // new incoming call. + // + // Specifically after 10 or so rapid fire call/hangup/call/hangup, eventually an incoming + // call would fail to report due to CXErrorCodeRequestTransactionErrorMaximumCallGroupsReached + // + // ...so that's why we no longer use the non-default value of 1, which I assume was only ever + // set to 1 out of confusion. + // providerConfiguration.maximumCallGroups = 1 + + providerConfiguration.maximumCallsPerCallGroup = 1 + + providerConfiguration.supportedHandleTypes = [.phoneNumber, .generic] + + let iconMaskImage = #imageLiteral(resourceName: "signal-logo-128") + providerConfiguration.iconTemplateImageData = iconMaskImage.pngData() + + // We don't set the ringtoneSound property, so that we use either the + // default iOS ringtone OR the custom ringtone associated with this user's + // system contact. + providerConfiguration.includesCallsInRecents = useSystemCallLog + + return providerConfiguration + } + + init(showNamesOnCallScreen: Bool, useSystemCallLog: Bool) { + AssertIsOnMainThread() + + Logger.debug("") + + self.callManager = CallKitCallManager(showNamesOnCallScreen: showNamesOnCallScreen) + + self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog) + + self.audioActivity = AudioActivity(audioDescription: "[CallKitCallUIAdaptee]", behavior: .call) + self.showNamesOnCallScreen = showNamesOnCallScreen + + super.init() + + // We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings + + self.provider.setDelegate(self, queue: nil) + } + + // MARK: CallUIAdaptee + + func startOutgoingCall(call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + // make sure we don't terminate audio session during call + _ = self.audioSession.startAudioActivity(call.audioActivity) + + // Add the new outgoing call to the app's list of calls. + // So we can find it in the provider delegate callbacks. + callManager.addCall(call) + callManager.startCall(call) + } + + // Called from CallService after call has ended to clean up any remaining CallKit call state. + func failCall(_ call: SignalCall, error: SignalCall.CallError) { + AssertIsOnMainThread() + Logger.info("") + + switch error { + case .timeout(description: _): + provider.reportCall(with: call.individualCall.localId, endedAt: Date(), reason: CXCallEndedReason.unanswered) + default: + provider.reportCall(with: call.individualCall.localId, endedAt: Date(), reason: CXCallEndedReason.failed) + } + + callManager.removeCall(call) + } + + func reportIncomingCall(_ call: SignalCall, callerName: String, completion: @escaping (Error?) -> Void) { + AssertIsOnMainThread() + Logger.info("") + + // Construct a CXCallUpdate describing the incoming call, including the caller. + let update = CXCallUpdate() + + if showNamesOnCallScreen { + update.localizedCallerName = contactsManager.displayName(for: call.individualCall.remoteAddress) + if let phoneNumber = call.individualCall.remoteAddress.phoneNumber { + update.remoteHandle = CXHandle(type: .phoneNumber, value: phoneNumber) + } + } else { + let callKitId = CallKitCallManager.kAnonymousCallHandlePrefix + call.individualCall.localId.uuidString + update.remoteHandle = CXHandle(type: .generic, value: callKitId) + CallKitIdStore.setAddress(call.individualCall.remoteAddress, forCallKitId: callKitId) + update.localizedCallerName = NSLocalizedString("CALLKIT_ANONYMOUS_CONTACT_NAME", comment: "The generic name used for calls if CallKit privacy is enabled") + } + + update.hasVideo = call.individualCall.offerMediaType == .video + + disableUnsupportedFeatures(callUpdate: update) + + // Report the incoming call to the system + provider.reportNewIncomingCall(with: call.individualCall.localId, update: update) { error in + /* + Only add incoming call to the app's list of calls if the call was allowed (i.e. there was no error) + since calls may be "denied" for various legitimate reasons. See CXErrorCodeIncomingCallError. + */ + guard error == nil else { + completion(error) + Logger.error("failed to report new incoming call, error: \(error!)") + return + } + + completion(nil) + + self.showCall(call) + self.callManager.addCall(call) + } + } + + func answerCall(localId: UUID) { + AssertIsOnMainThread() + Logger.info("") + + owsFailDebug("CallKit should answer calls via system call screen, not via notifications.") + } + + func answerCall(_ call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + callManager.answer(call: call) + } + + private var ignoreFirstUnuteAfterRemoteAnswer = false + func recipientAcceptedCall(_ call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + self.provider.reportOutgoingCall(with: call.individualCall.localId, connectedAt: nil) + + let update = CXCallUpdate() + disableUnsupportedFeatures(callUpdate: update) + + provider.reportCall(with: call.individualCall.localId, updated: update) + + // When we tell CallKit about the call, it tries + // to unmute the call. We can work around this + // by ignoring the next "unmute" request from + // CallKit after the call is answered. + ignoreFirstUnuteAfterRemoteAnswer = call.individualCall.isMuted + } + + func localHangupCall(localId: UUID) { + AssertIsOnMainThread() + + owsFailDebug("CallKit should decline calls via system call screen, not via notifications.") + } + + func localHangupCall(_ call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + callManager.localHangup(call: call) + } + + func remoteDidHangupCall(_ call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + provider.reportCall(with: call.individualCall.localId, endedAt: nil, reason: CXCallEndedReason.remoteEnded) + callManager.removeCall(call) + } + + func remoteBusy(_ call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + provider.reportCall(with: call.individualCall.localId, endedAt: nil, reason: CXCallEndedReason.unanswered) + callManager.removeCall(call) + } + + func didAnswerElsewhere(call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + provider.reportCall(with: call.individualCall.localId, endedAt: nil, reason: .answeredElsewhere) + callManager.removeCall(call) + } + + func didDeclineElsewhere(call: SignalCall) { + AssertIsOnMainThread() + Logger.info("") + + provider.reportCall(with: call.individualCall.localId, endedAt: nil, reason: .declinedElsewhere) + callManager.removeCall(call) + } + + func setIsMuted(call: SignalCall, isMuted: Bool) { + AssertIsOnMainThread() + Logger.info("") + + callManager.setIsMuted(call: call, isMuted: isMuted) + } + + func setHasLocalVideo(call: SignalCall, hasLocalVideo: Bool) { + AssertIsOnMainThread() + Logger.debug("") + + let update = CXCallUpdate() + update.hasVideo = hasLocalVideo + + // Update the CallKit UI. + provider.reportCall(with: call.individualCall.localId, updated: update) + + self.callService.updateIsLocalVideoMuted(isLocalVideoMuted: !hasLocalVideo) + } + + // MARK: CXProviderDelegate + + func providerDidReset(_ provider: CXProvider) { + AssertIsOnMainThread() + Logger.info("") + + // End any ongoing calls if the provider resets, and remove them from the app's list of calls, + // since they are no longer valid. + callService.individualCallService.handleCallKitProviderReset() + + // Remove all calls from the app's list of calls. + callManager.removeAllCalls() + } + + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + AssertIsOnMainThread() + + Logger.info("CXStartCallAction") + + guard let call = callManager.callWithLocalId(action.callUUID) else { + Logger.error("unable to find call") + return + } + + // We can't wait for long before fulfilling the CXAction, else CallKit will show a "Failed Call". We don't + // actually need to wait for the outcome of the handleOutgoingCall promise, because it handles any errors by + // manually failing the call. + self.callService.individualCallService.handleOutgoingCall(call) + + action.fulfill() + self.provider.reportOutgoingCall(with: call.individualCall.localId, startedConnectingAt: nil) + + // Update the name used in the CallKit UI for outgoing calls when the user prefers not to show names + // in ther notifications + if !showNamesOnCallScreen { + let update = CXCallUpdate() + update.localizedCallerName = NSLocalizedString("CALLKIT_ANONYMOUS_CONTACT_NAME", + comment: "The generic name used for calls if CallKit privacy is enabled") + provider.reportCall(with: call.individualCall.localId, updated: update) + } + } + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + AssertIsOnMainThread() + + Logger.info("Received \(#function) CXAnswerCallAction") + // Retrieve the instance corresponding to the action's call UUID + guard let call = callManager.callWithLocalId(action.callUUID) else { + owsFailDebug("call as unexpectedly nil") + action.fail() + return + } + + self.callService.individualCallService.handleAcceptCall(call) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + AssertIsOnMainThread() + + Logger.info("Received \(#function) CXEndCallAction") + guard let call = callManager.callWithLocalId(action.callUUID) else { + Logger.error("trying to end unknown call with localId: \(action.callUUID)") + action.fail() + return + } + + self.callService.individualCallService.handleLocalHangupCall(call) + + // Signal to the system that the action has been successfully performed. + action.fulfill() + + // Remove the ended call from the app's list of calls. + self.callManager.removeCall(call) + } + + public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { + AssertIsOnMainThread() + + Logger.info("Received \(#function) CXSetHeldCallAction") + guard let call = callManager.callWithLocalId(action.callUUID) else { + action.fail() + return + } + + // Update the IndividualCall's underlying hold state. + self.callService.individualCallService.setIsOnHold(call: call, isOnHold: action.isOnHold) + + // Signal to the system that the action has been successfully performed. + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + AssertIsOnMainThread() + + Logger.info("Received \(#function) CXSetMutedCallAction") + guard nil != callManager.callWithLocalId(action.callUUID) else { + Logger.info("Failing CXSetMutedCallAction for unknown (ended?) call: \(action.callUUID)") + action.fail() + return + } + + defer { ignoreFirstUnuteAfterRemoteAnswer = false } + guard !ignoreFirstUnuteAfterRemoteAnswer || action.isMuted else { return } + + self.callService.updateIsLocalAudioMuted(isLocalAudioMuted: action.isMuted) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXSetGroupCallAction) { + AssertIsOnMainThread() + + Logger.warn("unimplemented \(#function) for CXSetGroupCallAction") + } + + public func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) { + AssertIsOnMainThread() + + Logger.warn("unimplemented \(#function) for CXPlayDTMFCallAction") + } + + func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { + AssertIsOnMainThread() + + if #available(iOS 13, *), let muteAction = action as? CXSetMutedCallAction { + guard callManager.callWithLocalId(muteAction.callUUID) != nil else { + // When a call is over, if it was muted, CallKit "helpfully" attempts to unmute the + // call with "CXSetMutedCallAction", presumably to help us clean up state. + // + // That is, it calls func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) + // + // We don't need this - we have our own mechanism for coallescing audio state, so + // we acknowledge the action, but perform a no-op. + // + // However, regardless of fulfilling or failing the action, the action "times out" + // on iOS13. CallKit similarly "auto unmutes" ended calls on iOS12, but on iOS12 + // it doesn't timeout. + // + // Presumably this is a regression in iOS13 - so we ignore it. + // #RADAR FB7568405 + Logger.info("ignoring timeout for CXSetMutedCallAction for ended call: \(muteAction.callUUID)") + return + } + } + + owsFailDebug("Timed out while performing \(action)") + } + + func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + AssertIsOnMainThread() + + Logger.debug("Received") + + _ = self.audioSession.startAudioActivity(self.audioActivity) + self.audioSession.isRTCAudioEnabled = true + } + + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + AssertIsOnMainThread() + + Logger.debug("Received") + self.audioSession.isRTCAudioEnabled = false + self.audioSession.endAudioActivity(self.audioActivity) + } + + // MARK: - Util + + private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) { + // Call Holding is failing to restart audio when "swapping" calls on the CallKit screen + // until user returns to in-app call screen. + callUpdate.supportsHolding = false + + // Not yet supported + callUpdate.supportsGrouping = false + callUpdate.supportsUngrouping = false + + // Is there any reason to support this? + callUpdate.supportsDTMF = false + } +} diff --git a/Session/Calls/UserInterface/Individual/CallUIAdapter.swift b/Session/Calls/UserInterface/Individual/CallUIAdapter.swift new file mode 100644 index 000000000..32ce1ffc6 --- /dev/null +++ b/Session/Calls/UserInterface/Individual/CallUIAdapter.swift @@ -0,0 +1,310 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import PromiseKit +import CallKit +import WebRTC + +protocol CallUIAdaptee { + var notificationPresenter: NotificationPresenter { get } + var callService: CallService { get } + var hasManualRinger: Bool { get } + + func startOutgoingCall(call: SignalCall) + func reportIncomingCall(_ call: SignalCall, callerName: String, completion: @escaping (Error?) -> Void) + func reportMissedCall(_ call: SignalCall, callerName: String) + func answerCall(localId: UUID) + func answerCall(_ call: SignalCall) + func recipientAcceptedCall(_ call: SignalCall) + func localHangupCall(localId: UUID) + func localHangupCall(_ call: SignalCall) + func remoteDidHangupCall(_ call: SignalCall) + func remoteBusy(_ call: SignalCall) + func didAnswerElsewhere(call: SignalCall) + func didDeclineElsewhere(call: SignalCall) + func failCall(_ call: SignalCall, error: SignalCall.CallError) + func setIsMuted(call: SignalCall, isMuted: Bool) + func setHasLocalVideo(call: SignalCall, hasLocalVideo: Bool) + func startAndShowOutgoingCall(address: SignalServiceAddress, hasLocalVideo: Bool) +} + +// Shared default implementations +extension CallUIAdaptee { + + internal func showCall(_ call: SignalCall) { + AssertIsOnMainThread() + + let callViewController = IndividualCallViewController(call: call) + callViewController.modalTransitionStyle = .crossDissolve + + OWSWindowManager.shared.startCall(callViewController) + } + + internal func reportMissedCall(_ call: SignalCall, callerName: String) { + AssertIsOnMainThread() + + notificationPresenter.presentMissedCall(call.individualCall, callerName: callerName) + } + + internal func startAndShowOutgoingCall(address: SignalServiceAddress, hasLocalVideo: Bool) { + AssertIsOnMainThread() + + guard let call = self.callService.buildOutgoingIndividualCallIfPossible( + address: address, + hasVideo: hasLocalVideo + ) else { + // @integration This is not unexpected, it could happen if Bob tries + // to start an outgoing call at the same moment Alice has already + // sent him an Offer that is being processed. + Logger.info("found an existing call when trying to start outgoing call: \(address)") + return + } + + Logger.debug("") + + startOutgoingCall(call: call) + call.individualCall.hasLocalVideo = hasLocalVideo + self.showCall(call) + } +} + +/** + * Notify the user of call related activities. + * Driven by either a CallKit or System notifications adaptee + */ +@objc +public class CallUIAdapter: NSObject, CallServiceObserver { + + lazy var nonCallKitAdaptee = NonCallKitCallUIAdaptee() + + lazy var callKitAdaptee: CallKitCallUIAdaptee? = { + if Platform.isSimulator { + // CallKit doesn't seem entirely supported in simulator. + // e.g. you can't receive calls in the call screen. + // So we use the non-CallKit call UI. + Logger.info("not using callkit adaptee for simulator.") + return nil + } else if CallUIAdapter.isCallkitDisabledForLocale { + Logger.info("not using callkit adaptee due to locale.") + return nil + } else { + Logger.info("using callkit adaptee for iOS11+") + let showNames = preferences.notificationPreviewType() != .noNameNoPreview + let useSystemCallLog = preferences.isSystemCallLogEnabled() + + return CallKitCallUIAdaptee(showNamesOnCallScreen: showNames, + useSystemCallLog: useSystemCallLog) + } + }() + + var defaultAdaptee: CallUIAdaptee { callKitAdaptee ?? nonCallKitAdaptee } + + func adaptee(for call: SignalCall) -> CallUIAdaptee { + switch call.individualCall.callAdapterType { + case .nonCallKit: return nonCallKitAdaptee + case .default: return defaultAdaptee + } + } + + public required override init() { + AssertIsOnMainThread() + + super.init() + + // We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings + AppReadiness.runNowOrWhenAppDidBecomeReadySync { + self.callService.addObserverAndSyncState(observer: self) + } + } + + @objc + public static var isCallkitDisabledForLocale: Bool { + let locale = Locale.current + guard let regionCode = locale.regionCode else { + if !Platform.isSimulator { owsFailDebug("Missing region code.") } + return false + } + + // Apple has stopped approving apps that use CallKit functionality in mainland China. + // When the "CN" region is enabled, this check simply switches to the same pre-CallKit + // interface that is still used by everyone on iOS 9. + // + // For further reference: https://forums.developer.apple.com/thread/103083 + return regionCode == "CN" + } + + // MARK: + + internal func reportIncomingCall(_ call: SignalCall, thread: TSContactThread) { + AssertIsOnMainThread() + + Logger.info("remoteAddress: \(call.individualCall.remoteAddress)") + + // make sure we don't terminate audio session during call + _ = audioSession.startAudioActivity(call.audioActivity) + + let callerName = self.contactsManager.displayName(for: call.individualCall.remoteAddress) + + Logger.verbose("callerName: \(callerName)") + + adaptee(for: call).reportIncomingCall(call, callerName: callerName) { error in + AssertIsOnMainThread() + + guard let error = error else { return } + owsFailDebug("Failed to report incoming call with error \(error)") + + let nsError = error as NSError + Logger.warn("nsError: \(nsError.domain), \(nsError.code)") + if nsError.domain == CXErrorCodeIncomingCallError.errorDomain { + switch nsError.code { + case CXErrorCodeIncomingCallError.unknown.rawValue: + Logger.warn("unknown") + case CXErrorCodeIncomingCallError.unentitled.rawValue: + Logger.warn("unentitled") + case CXErrorCodeIncomingCallError.callUUIDAlreadyExists.rawValue: + Logger.warn("callUUIDAlreadyExists") + case CXErrorCodeIncomingCallError.filteredByDoNotDisturb.rawValue: + Logger.warn("filteredByDoNotDisturb") + case CXErrorCodeIncomingCallError.filteredByBlockList.rawValue: + Logger.warn("filteredByBlockList") + default: + Logger.warn("Unknown CXErrorCodeIncomingCallError") + } + } + + self.callService.handleFailedCall(failedCall: call, error: error) + } + } + + internal func reportMissedCall(_ call: SignalCall) { + AssertIsOnMainThread() + + let callerName = self.contactsManager.displayName(for: call.individualCall.remoteAddress) + adaptee(for: call).reportMissedCall(call, callerName: callerName) + } + + internal func startOutgoingCall(call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).startOutgoingCall(call: call) + } + + @objc public func answerCall(localId: UUID) { + AssertIsOnMainThread() + + guard let call = self.callService.currentCall else { + owsFailDebug("No current call.") + return + } + + guard call.individualCall.localId == localId else { + owsFailDebug("localId does not match current call") + return + } + + adaptee(for: call).answerCall(localId: localId) + } + + internal func answerCall(_ call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).answerCall(call) + } + + @objc public func startAndShowOutgoingCall(address: SignalServiceAddress, hasLocalVideo: Bool) { + AssertIsOnMainThread() + + defaultAdaptee.startAndShowOutgoingCall(address: address, hasLocalVideo: hasLocalVideo) + } + + internal func recipientAcceptedCall(_ call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).recipientAcceptedCall(call) + } + + internal func remoteDidHangupCall(_ call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).remoteDidHangupCall(call) + } + + internal func remoteBusy(_ call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).remoteBusy(call) + } + + internal func didAnswerElsewhere(call: SignalCall) { + adaptee(for: call).didAnswerElsewhere(call: call) + } + + internal func didDeclineElsewhere(call: SignalCall) { + adaptee(for: call).didDeclineElsewhere(call: call) + } + + internal func localHangupCall(localId: UUID) { + AssertIsOnMainThread() + + guard let call = self.callService.currentCall else { + owsFailDebug("No current call.") + return + } + + guard call.individualCall.localId == localId else { + owsFailDebug("localId does not match current call") + return + } + + adaptee(for: call).localHangupCall(localId: localId) + } + + internal func localHangupCall(_ call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).localHangupCall(call) + } + + internal func failCall(_ call: SignalCall, error: SignalCall.CallError) { + AssertIsOnMainThread() + + adaptee(for: call).failCall(call, error: error) + } + + internal func showCall(_ call: SignalCall) { + AssertIsOnMainThread() + + adaptee(for: call).showCall(call) + } + + internal func setIsMuted(call: SignalCall, isMuted: Bool) { + AssertIsOnMainThread() + + // With CallKit, muting is handled by a CXAction, so it must go through the adaptee + adaptee(for: call).setIsMuted(call: call, isMuted: isMuted) + } + + internal func setHasLocalVideo(call: SignalCall, hasLocalVideo: Bool) { + AssertIsOnMainThread() + + adaptee(for: call).setHasLocalVideo(call: call, hasLocalVideo: hasLocalVideo) + } + + internal func setCameraSource(call: SignalCall, isUsingFrontCamera: Bool) { + AssertIsOnMainThread() + + callService.updateCameraSource(call: call, isUsingFrontCamera: isUsingFrontCamera) + } + + // MARK: - CallServiceObserver + + internal func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) { + AssertIsOnMainThread() + + guard let call = newValue, call.isIndividualCall else { return } + + callService.audioService.handleRinging = adaptee(for: call).hasManualRinger + } +} diff --git a/Session/Calls/UserInterface/Individual/IndividualCallViewController.swift b/Session/Calls/UserInterface/Individual/IndividualCallViewController.swift new file mode 100644 index 000000000..42620201b --- /dev/null +++ b/Session/Calls/UserInterface/Individual/IndividualCallViewController.swift @@ -0,0 +1,1344 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import WebRTC +import PromiseKit +import SignalRingRTC + +// TODO: Add category so that button handlers can be defined where button is created. +// TODO: Ensure buttons enabled & disabled as necessary. +class IndividualCallViewController: OWSViewController, CallObserver, CallAudioServiceDelegate { + + // MARK: - Properties + + let thread: TSContactThread + let call: SignalCall + var hasDismissed = false + + // MARK: - Views + + private lazy var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + private lazy var backgroundAvatarView = UIImageView() + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + dateFormatter.timeZone = TimeZone(identifier: "UTC")! + dateFormatter.locale = Locale(identifier: "en_US") + return dateFormatter + }() + + private var callDurationTimer: Timer? + + // MARK: - Gradient Views + + private lazy var topGradientView: UIView = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + UIColor.ows_blackAlpha60.cgColor, + UIColor.black.withAlphaComponent(0).cgColor + ] + let view = OWSLayerView(frame: .zero) { view in + gradientLayer.frame = view.bounds + } + view.layer.addSublayer(gradientLayer) + return view + }() + + private lazy var bottomContainerView = UIView.container() + + private lazy var bottomGradientView: UIView = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + UIColor.black.withAlphaComponent(0).cgColor, + UIColor.ows_blackAlpha60.cgColor + ] + let view = OWSLayerView(frame: .zero) { view in + gradientLayer.frame = view.bounds + } + view.layer.addSublayer(gradientLayer) + return view + }() + + let gradientMargin: CGFloat = 46 + + // MARK: - Contact Views + + private lazy var contactNameLabel = MarqueeLabel() + private lazy var contactAvatarView = ConversationAvatarView(diameterPoints: 200, + localUserDisplayMode: .asUser) + private lazy var contactAvatarContainerView = UIView.container() + private lazy var callStatusLabel = UILabel() + private lazy var backButton = UIButton() + + // MARK: - Ongoing Audio Call Controls + + private lazy var ongoingAudioCallControls = UIStackView( + arrangedSubviews: [ + UIView.hStretchingSpacer(), + audioModeSourceButton, + audioModeVideoButton, + audioModeMuteButton, + audioModeHangUpButton, + UIView.hStretchingSpacer() + ] + ) + + private lazy var audioModeHangUpButton = createButton(iconName: "phone-down-solid-28", action: #selector(didPressHangup)) + private lazy var audioModeSourceButton = createButton(iconName: "speaker-solid-28", action: #selector(didPressAudioSource)) + private lazy var audioModeMuteButton = createButton(iconName: "mic-off-solid-28", action: #selector(didPressMute)) + private lazy var audioModeVideoButton = createButton(iconName: "video-solid-28", action: #selector(didPressVideo)) + + // MARK: - Ongoing Video Call Controls + + private lazy var ongoingVideoCallControls = UIStackView( + arrangedSubviews: [ + UIView.hStretchingSpacer(), + videoModeAudioSourceButton, + videoModeFlipCameraButton, + videoModeVideoButton, + videoModeMuteButton, + videoModeHangUpButton, + UIView.hStretchingSpacer() + ] + ) + + private lazy var videoModeHangUpButton = createButton(iconName: "phone-down-solid-28", action: #selector(didPressHangup)) + private lazy var videoModeAudioSourceButton = createButton(iconName: "speaker-solid-28", action: #selector(didPressAudioSource)) + private lazy var videoModeMuteButton = createButton(iconName: "mic-off-solid-28", action: #selector(didPressMute)) + private lazy var videoModeVideoButton = createButton(iconName: "video-solid-28", action: #selector(didPressVideo)) + private lazy var videoModeFlipCameraButton = createButton(iconName: "switch-camera-28", action: #selector(didPressFlipCamera)) + + // MARK: - Incoming Audio Call Controls + + private lazy var incomingAudioCallControls = UIStackView( + arrangedSubviews: [ + UIView.hStretchingSpacer(), + audioDeclineIncomingButton, + UIView.spacer(withWidth: 124), + audioAnswerIncomingButton, + UIView.hStretchingSpacer() + ] + ) + + private lazy var audioAnswerIncomingButton = createButton(iconName: "phone-solid-28", action: #selector(didPressAnswerCall)) + private lazy var audioDeclineIncomingButton = createButton(iconName: "phone-down-solid-28", action: #selector(didPressDeclineCall)) + + // MARK: - Incoming Video Call Controls + + private lazy var incomingVideoCallControls = UIStackView( + arrangedSubviews: [ + videoAnswerIncomingAudioOnlyButton, + incomingVideoCallBottomControls + ] + ) + + private lazy var incomingVideoCallBottomControls = UIStackView( + arrangedSubviews: [ + UIView.hStretchingSpacer(), + videoDeclineIncomingButton, + UIView.spacer(withWidth: 124), + videoAnswerIncomingButton, + UIView.hStretchingSpacer() + ] + ) + + private lazy var videoAnswerIncomingButton = createButton(iconName: "video-solid-28", action: #selector(didPressAnswerCall)) + private lazy var videoAnswerIncomingAudioOnlyButton = createButton(iconName: "video-off-solid-28", action: #selector(didPressAnswerCall)) + private lazy var videoDeclineIncomingButton = createButton(iconName: "phone-down-solid-28", action: #selector(didPressDeclineCall)) + + // MARK: - Video Views + + private lazy var remoteVideoView = RemoteVideoView() + private weak var remoteVideoTrack: RTCVideoTrack? + + private lazy var localVideoView: LocalVideoView = { + let localVideoView = LocalVideoView() + localVideoView.captureSession = call.videoCaptureController.captureSession + return localVideoView + }() + + // MARK: - Gestures + + lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTouchRootView)) + lazy var panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleLocalVideoPan)) + + var shouldRemoteVideoControlsBeHidden = false { + didSet { + updateCallUI() + } + } + + // MARK: - Audio Source + + var hasAlternateAudioSources: Bool { + Logger.info("available audio sources: \(allAudioSources)") + // internal mic and speakerphone will be the first two, any more than one indicates e.g. an attached bluetooth device. + + // TODO is this sufficient? Are their devices w/ bluetooth but no external speaker? e.g. ipod? + return allAudioSources.count > 2 + } + + var allAudioSources: Set = Set() + + var appropriateAudioSources: Set { + if call.individualCall.hasLocalVideo { + let appropriateForVideo = allAudioSources.filter { audioSource in + if audioSource.isBuiltInSpeaker { + return true + } else { + guard let portDescription = audioSource.portDescription else { + owsFailDebug("Only built in speaker should be lacking a port description.") + return false + } + + // Don't use receiver when video is enabled. Only bluetooth or speaker + return portDescription.portType != AVAudioSession.Port.builtInMic + } + } + return Set(appropriateForVideo) + } else { + return allAudioSources + } + } + + // MARK: - Initializers + + required init(call: SignalCall) { + // TODO: Eventually unify UI for group and individual calls + owsAssertDebug(call.isIndividualCall) + self.call = call + self.thread = TSContactThread.getOrCreateThread(contactSessionID: call.individualCall.publicKey) + super.init() + + allAudioSources = Set(callService.audioService.availableInputs) + + self.shouldUseTheme = false + } + + deinit { + // These views might be in the return to call PIP's hierarchy, + // we want to remove them so they are free'd when the call ends + remoteVideoView.removeFromSuperview() + localVideoView.removeFromSuperview() + } + + // MARK: - View Lifecycle + + @objc func didBecomeActive() { + if self.isViewLoaded { + shouldRemoteVideoControlsBeHidden = false + } + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.updateLocalVideoLayout() + }, completion: nil) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + callDurationTimer?.invalidate() + callDurationTimer = nil + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + updateCallUI() + } + + override func loadView() { + view = UIView() + view.clipsToBounds = true + view.backgroundColor = UIColor.black + view.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + + createViews() + createViewConstraints() + } + + override func viewDidLoad() { + super.viewDidLoad() + + contactNameLabel.text = contactsManager.displayName(for: thread.contactAddress) + updateAvatarImage() + + NotificationCenter.default.addObserver( + self, + selector: #selector(updateAvatarImage), + name: .OWSContactsManagerSignalAccountsDidChange, + object: nil + ) + + // Subscribe for future call updates + call.addObserverAndSyncState(observer: self) + + assert(callService.audioService.delegate == nil) + callService.audioService.delegate = self + + NotificationCenter.default.addObserver(self, + selector: #selector(didBecomeActive), + name: .OWSApplicationDidBecomeActive, + object: nil) + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return UIDevice.current.isIPad ? .all : .portrait + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + // MARK: - Create Views + + func createViews() { + view.isUserInteractionEnabled = true + + view.addGestureRecognizer(tapGesture) + localVideoView.addGestureRecognizer(panGesture) + panGesture.delegate = self + tapGesture.require(toFail: panGesture) + + // The callee's avatar is rendered behind the blurred background. + backgroundAvatarView.contentMode = .scaleAspectFill + backgroundAvatarView.isUserInteractionEnabled = false + view.addSubview(backgroundAvatarView) + backgroundAvatarView.autoPinEdgesToSuperviewEdges() + + // Dark blurred background. + blurView.isUserInteractionEnabled = false + view.addSubview(blurView) + blurView.autoPinEdgesToSuperviewEdges() + + // Create the video views first, as they are under the other views. + createVideoViews() + + view.addSubview(topGradientView) + topGradientView.autoPinWidthToSuperview() + topGradientView.autoPinEdge(toSuperviewEdge: .top) + + view.addSubview(bottomContainerView) + bottomContainerView.autoPinWidthToSuperview() + bottomContainerView.autoPinEdge(toSuperviewEdge: .bottom) + + bottomContainerView.addSubview(bottomGradientView) + bottomGradientView.autoPinWidthToSuperview() + bottomGradientView.autoPinEdge(toSuperviewEdge: .bottom) + + createContactViews() + createOngoingCallControls() + createIncomingCallControls() + } + + @objc func didTouchRootView(sender: UIGestureRecognizer) { + if !remoteVideoView.isHidden { + shouldRemoteVideoControlsBeHidden = !shouldRemoteVideoControlsBeHidden + } + } + + func createVideoViews() { + remoteVideoView.isUserInteractionEnabled = false + remoteVideoView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "remoteVideoView") + remoteVideoView.isHidden = true + remoteVideoView.isGroupCall = false + view.addSubview(remoteVideoView) + + // We want the local video view to use the aspect ratio of the screen, so we change it to "aspect fill". + localVideoView.contentMode = .scaleAspectFill + localVideoView.clipsToBounds = true + localVideoView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "localVideoView") + localVideoView.isHidden = true + view.addSubview(localVideoView) + } + + func createContactViews() { + + let backButtonImage = CurrentAppContext().isRTL ? #imageLiteral(resourceName: "NavBarBackRTL") : #imageLiteral(resourceName: "NavBarBack") + backButton.setImage(backButtonImage, for: .normal) + backButton.autoSetDimensions(to: CGSize(square: 40)) + backButton.addTarget(self, action: #selector(didTapLeaveCall(sender:)), for: .touchUpInside) + topGradientView.addSubview(backButton) + + // marquee config + contactNameLabel.type = .continuous + // This feels pretty slow when you're initially waiting for it, but when you're overlaying video calls, anything faster is distracting. + contactNameLabel.speed = .duration(30.0) + contactNameLabel.animationCurve = .linear + contactNameLabel.fadeLength = 10.0 + contactNameLabel.animationDelay = 5 + // Add trailing space after the name scrolls before it wraps around and scrolls back in. + contactNameLabel.trailingBuffer = ScaleFromIPhone5(80.0) + + // label config + contactNameLabel.font = UIFont.ows_dynamicTypeTitle1 + contactNameLabel.textAlignment = .center + contactNameLabel.textColor = UIColor.white + contactNameLabel.layer.shadowOffset = .zero + contactNameLabel.layer.shadowOpacity = 0.25 + contactNameLabel.layer.shadowRadius = 4 + + topGradientView.addSubview(contactNameLabel) + + callStatusLabel.font = UIFont.ows_dynamicTypeBody + callStatusLabel.textAlignment = .center + callStatusLabel.textColor = UIColor.white + callStatusLabel.layer.shadowOffset = .zero + callStatusLabel.layer.shadowOpacity = 0.25 + callStatusLabel.layer.shadowRadius = 4 + + topGradientView.addSubview(callStatusLabel) + + contactAvatarContainerView.addSubview(contactAvatarView) + view.insertSubview(contactAvatarContainerView, belowSubview: localVideoView) + + backButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "leaveCallViewButton") + contactNameLabel.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "contactNameLabel") + callStatusLabel.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "callStatusLabel") + contactAvatarView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "contactAvatarView") + } + + func createOngoingCallControls() { + audioModeSourceButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_AUDIO_SOURCE_LABEL", + comment: "Accessibility label for selection the audio source") + + audioModeHangUpButton.unselectedBackgroundColor = .ows_accentRed + audioModeHangUpButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_HANGUP_LABEL", + comment: "Accessibility label for hang up call") + + audioModeMuteButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_MUTE_LABEL", + comment: "Accessibility label for muting the microphone") + + audioModeVideoButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_SWITCH_TO_VIDEO_LABEL", comment: "Accessibility label to switch to video call") + + videoModeAudioSourceButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_AUDIO_SOURCE_LABEL", + comment: "Accessibility label for selection the audio source") + + videoModeHangUpButton.unselectedBackgroundColor = .ows_accentRed + videoModeHangUpButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_HANGUP_LABEL", + comment: "Accessibility label for hang up call") + + videoModeMuteButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_MUTE_LABEL", comment: "Accessibility label for muting the microphone") + videoModeMuteButton.alpha = 0.9 + + videoModeFlipCameraButton.selectedIconColor = videoModeFlipCameraButton.iconColor + videoModeFlipCameraButton.selectedBackgroundColor = videoModeFlipCameraButton.unselectedBackgroundColor + videoModeFlipCameraButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_SWITCH_CAMERA_DIRECTION", comment: "Accessibility label to toggle front- vs. rear-facing camera") + videoModeFlipCameraButton.alpha = 0.9 + + videoModeVideoButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_SWITCH_TO_AUDIO_LABEL", comment: "Accessibility label to switch to audio only") + videoModeVideoButton.alpha = 0.9 + + ongoingAudioCallControls.spacing = 16 + ongoingAudioCallControls.axis = .horizontal + bottomGradientView.addSubview(ongoingAudioCallControls) + + ongoingVideoCallControls.spacing = 16 + ongoingVideoCallControls.axis = .horizontal + bottomGradientView.addSubview(ongoingVideoCallControls) + + // Ensure that the controls are always horizontally centered + for stackView in [ongoingAudioCallControls, ongoingVideoCallControls] { + guard let leadingSpacer = stackView.arrangedSubviews.first, let trailingSpacer = stackView.arrangedSubviews.last else { + return owsFailDebug("failed to get spacers") + } + leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer) + } + + audioModeHangUpButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioHangUpButton") + audioModeSourceButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioSourceButton") + audioModeMuteButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioModeMuteButton") + audioModeVideoButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioModeVideoButton") + + videoModeHangUpButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoHangUpButton") + videoModeAudioSourceButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoAudioSourceButton") + videoModeMuteButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoModeMuteButton") + videoModeFlipCameraButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoModeFlipCameraButton") + videoModeVideoButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoModeVideoButton") + } + + func presentAudioSourcePicker() { + AssertIsOnMainThread() + + guard !callService.audioService.presentRoutePicker() else { return } + + // Fallback to action sheet based picker, which is buggy + owsFailDebug("Failed to present native route picker, maybe a new iOS version broke it?") + + let actionSheetController = ActionSheetController(title: nil, message: nil) + + let dismissAction = ActionSheetAction(title: CommonStrings.dismissButton, style: .cancel) + actionSheetController.addAction(dismissAction) + + let currentAudioSource = callService.audioService.currentAudioSource + for audioSource in self.appropriateAudioSources { + let routeAudioAction = ActionSheetAction(title: audioSource.localizedName, style: .default) { _ in + self.callService.audioService.currentAudioSource = audioSource + } + + // create checkmark for active audio source. + if currentAudioSource == audioSource { + routeAudioAction.trailingIcon = .checkCircle + } + + actionSheetController.addAction(routeAudioAction) + } + + // Note: It's critical that we present from this view and + // not the "frontmost view controller" since this view may + // reside on a separate window. + presentActionSheet(actionSheetController) + } + + @objc + func updateAvatarImage() { + databaseStorage.read { transaction in + contactAvatarView.configure(thread: thread, transaction: transaction) + backgroundAvatarView.image = contactsManagerImpl.avatarImage(forAddress: thread.contactAddress, + shouldValidate: true, + transaction: transaction) + } + } + + func createIncomingCallControls() { + audioAnswerIncomingButton.text = NSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL", + comment: "label for accepting incoming calls") + audioAnswerIncomingButton.unselectedBackgroundColor = .ows_accentGreen + audioAnswerIncomingButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL", + comment: "label for accepting incoming calls") + + audioDeclineIncomingButton.text = NSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL", + comment: "label for declining incoming calls") + audioDeclineIncomingButton.unselectedBackgroundColor = .ows_accentRed + audioDeclineIncomingButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL", + comment: "label for declining incoming calls") + + incomingAudioCallControls.axis = .horizontal + incomingAudioCallControls.alignment = .center + bottomGradientView.addSubview(incomingAudioCallControls) + + audioAnswerIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioAnswerIncomingButton") + audioDeclineIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioDeclineIncomingButton") + + videoAnswerIncomingButton.text = NSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL", + comment: "label for accepting incoming calls") + videoAnswerIncomingButton.unselectedBackgroundColor = .ows_accentGreen + videoAnswerIncomingButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL", + comment: "label for accepting incoming calls") + + videoAnswerIncomingAudioOnlyButton.text = NSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_AUDIO_ONLY_LABEL", + comment: "label for accepting incoming video calls as audio only") + videoAnswerIncomingAudioOnlyButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_AUDIO_ONLY_LABEL", + comment: "label for accepting incoming video calls as audio only") + + videoDeclineIncomingButton.text = NSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL", + comment: "label for declining incoming calls") + videoDeclineIncomingButton.unselectedBackgroundColor = .ows_accentRed + videoDeclineIncomingButton.accessibilityLabel = NSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL", + comment: "label for declining incoming calls") + + incomingVideoCallBottomControls.axis = .horizontal + incomingVideoCallBottomControls.alignment = .center + + incomingVideoCallControls.axis = .vertical + incomingVideoCallControls.spacing = 20 + bottomContainerView.addSubview(incomingVideoCallControls) + + // Ensure that the controls are always horizontally centered + for stackView in [incomingAudioCallControls, incomingVideoCallBottomControls] { + guard let leadingSpacer = stackView.arrangedSubviews.first, let trailingSpacer = stackView.arrangedSubviews.last else { + return owsFailDebug("failed to get spacers") + } + leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer) + } + + videoAnswerIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoAnswerIncomingButton") + videoAnswerIncomingAudioOnlyButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoAnswerIncomingAudioOnlyButton") + videoDeclineIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoDeclineIncomingButton") + } + + private func createButton(iconName: String, action: Selector) -> CallButton { + let button = CallButton(iconName: iconName) + button.addTarget(self, action: action, for: .touchUpInside) + button.setContentHuggingHorizontalHigh() + button.setCompressionResistanceHorizontalLow() + return button + } + + // MARK: - Layout + + func createViewConstraints() { + + let contactVSpacing: CGFloat = 3 + let bottomMargin = ScaleFromIPhone5To7Plus(23, 41) + let avatarMargin = ScaleFromIPhone5To7Plus(25, 50) + + backButton.autoPinEdge(toSuperviewEdge: .leading) + + backButton.autoPinEdge(toSuperviewMargin: .top) + contactNameLabel.autoPinEdge(toSuperviewMargin: .top) + + contactNameLabel.autoPinEdge(.leading, to: .trailing, of: backButton, withOffset: 8, relation: .greaterThanOrEqual) + contactNameLabel.autoHCenterInSuperview() + contactNameLabel.setContentHuggingVerticalHigh() + contactNameLabel.setCompressionResistanceHigh() + + callStatusLabel.autoPinEdge(.top, to: .bottom, of: contactNameLabel, withOffset: contactVSpacing) + callStatusLabel.autoPinEdge(toSuperviewEdge: .bottom, withInset: gradientMargin) + callStatusLabel.autoHCenterInSuperview() + callStatusLabel.setContentHuggingVerticalHigh() + callStatusLabel.setCompressionResistanceHigh() + + remoteVideoView.autoPinEdgesToSuperviewEdges() + + contactAvatarContainerView.autoPinEdge(.top, to: .bottom, of: callStatusLabel, withOffset: +avatarMargin) + contactAvatarContainerView.autoPinEdge(.bottom, to: .top, of: ongoingAudioCallControls, withOffset: -avatarMargin) + contactAvatarContainerView.autoPinWidthToSuperview(withMargin: avatarMargin) + + contactAvatarView.autoCenterInSuperview() + + ongoingAudioCallControls.autoPinEdge(toSuperviewEdge: .top, withInset: gradientMargin) + incomingVideoCallControls.autoPinEdge(toSuperviewEdge: .top) + + for controls in [incomingVideoCallControls, incomingAudioCallControls, ongoingAudioCallControls, ongoingVideoCallControls] { + controls.autoPinWidthToSuperviewMargins() + controls.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomMargin) + controls.setContentHuggingVerticalHigh() + } + } + + internal func updateRemoteVideoLayout() { + remoteVideoView.isHidden = !self.hasRemoteVideoTrack + updateCallUI() + } + + private var lastLocalVideoBoundingRect: CGRect = .zero + private var localVideoBoundingRect: CGRect { + view.layoutIfNeeded() + + var rect = view.frame + rect.origin.x += view.layoutMargins.left + rect.size.width -= view.layoutMargins.left + view.layoutMargins.right + + let topInset = shouldRemoteVideoControlsBeHidden + ? view.layoutMargins.top + : topGradientView.height - gradientMargin + 14 + let bottomInset = shouldRemoteVideoControlsBeHidden + ? view.layoutMargins.bottom + : bottomGradientView.height - gradientMargin + 14 + rect.origin.y += topInset + rect.size.height -= topInset + bottomInset + + lastLocalVideoBoundingRect = rect + + return rect + } + + private var isRenderingLocalVanityVideo: Bool { + return [.idle, .dialing, .remoteRinging, .localRinging].contains(call.individualCall.state) && !localVideoView.isHidden + } + + private var previousOrigin: CGPoint! + private func updateLocalVideoLayout() { + guard localVideoView.superview == view else { return } + + guard !call.individualCall.isEnded else { return } + + guard !isRenderingLocalVanityVideo else { + view.bringSubviewToFront(topGradientView) + view.bringSubviewToFront(bottomContainerView) + view.layoutIfNeeded() + localVideoView.frame = view.frame + return + } + + guard !localVideoView.isHidden else { return } + + view.bringSubviewToFront(localVideoView) + + let pipSize = ReturnToCallViewController.pipSize + let lastBoundingRect = lastLocalVideoBoundingRect + let boundingRect = localVideoBoundingRect + + // Prefer to start in the top right + if previousOrigin == nil { + previousOrigin = CGPoint( + x: boundingRect.maxX - pipSize.width, + y: boundingRect.minY + ) + + // If the bounding rect has gotten bigger, and we were at the top or + // bottom edge move the pip so it stays at the top or bottom edge. + } else if boundingRect.minY < lastBoundingRect.minY && previousOrigin.y == lastBoundingRect.minY { + previousOrigin.y = boundingRect.minY + } else if boundingRect.maxY > lastBoundingRect.maxY && previousOrigin.y + pipSize.height == lastBoundingRect.maxY { + previousOrigin.y += boundingRect.maxY - lastBoundingRect.maxY + } + + let newFrame = CGRect(origin: previousOrigin, size: pipSize).pinnedToVerticalEdge(of: localVideoBoundingRect) + previousOrigin = newFrame.origin + + UIView.animate(withDuration: 0.25) { self.localVideoView.frame = newFrame } + } + + private var startingTranslation: CGPoint? + @objc func handleLocalVideoPan(sender: UIPanGestureRecognizer) { + switch sender.state { + case .began, .changed: + let translation = sender.translation(in: localVideoView) + sender.setTranslation(.zero, in: localVideoView) + + localVideoView.frame.origin.y += translation.y + localVideoView.frame.origin.x += translation.x + case .ended, .cancelled, .failed: + localVideoView.animateDecelerationToVerticalEdge( + withDuration: 0.35, + velocity: sender.velocity(in: localVideoView), + boundingRect: localVideoBoundingRect + ) { _ in self.previousOrigin = self.localVideoView.frame.origin } + default: + break + } + } + + // MARK: - Methods + + func showCallFailed(error: Error) { + // TODO Show something in UI. + Logger.error("call failed with error: \(error)") + } + + // MARK: - View State + + func localizedTextForCallState() -> String { + assert(Thread.isMainThread) + + switch call.individualCall.state { + case .idle, .remoteHangup, .remoteHangupNeedPermission, .localHangup: + return NSLocalizedString("IN_CALL_TERMINATED", comment: "Call setup status label") + case .dialing: + return NSLocalizedString("IN_CALL_CONNECTING", comment: "Call setup status label") + case .remoteRinging: + return NSLocalizedString("IN_CALL_RINGING", comment: "Call setup status label") + case .localRinging: + switch call.individualCall.offerMediaType { + case .audio: + return NSLocalizedString("IN_CALL_RINGING_AUDIO", comment: "Call setup status label") + case .video: + return NSLocalizedString("IN_CALL_RINGING_VIDEO", comment: "Call setup status label") + } + case .answering: + return NSLocalizedString("IN_CALL_SECURING", comment: "Call setup status label") + case .connected: + let callDuration = call.connectionDuration() + let callDurationDate = Date(timeIntervalSinceReferenceDate: callDuration) + var formattedDate = dateFormatter.string(from: callDurationDate) + if formattedDate.hasPrefix("00:") { + // Don't show the "hours" portion of the date format unless the + // call duration is at least 1 hour. + formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 3)...]) + } else { + // If showing the "hours" portion of the date format, strip any leading + // zeroes. + if formattedDate.hasPrefix("0") { + formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 1)...]) + } + } + return formattedDate + case .reconnecting: + return NSLocalizedString("IN_CALL_RECONNECTING", comment: "Call setup status label") + case .remoteBusy: + return NSLocalizedString("END_CALL_RESPONDER_IS_BUSY", comment: "Call setup status label") + case .localFailure: + if let error = call.error { + switch error { + case .timeout(description: _): + if self.call.individualCall.direction == .outgoing { + return NSLocalizedString("CALL_SCREEN_STATUS_NO_ANSWER", comment: "Call setup status label after outgoing call times out") + } + default: + break + } + } + + return NSLocalizedString("END_CALL_UNCATEGORIZED_FAILURE", comment: "Call setup status label") + case .answeredElsewhere: + return NSLocalizedString("IN_CALL_ENDED_BECAUSE_ANSWERED_ELSEWHERE", comment: "Call screen label when call was canceled on this device because the call recipient answered on another device.") + case .declinedElsewhere: + return NSLocalizedString("IN_CALL_ENDED_BECAUSE_DECLINED_ELSEWHERE", comment: "Call screen label when call was canceled on this device because the call recipient declined on another device.") + case .busyElsewhere: + owsFailDebug("busy elsewhere triggered on call screen, this should never happen") + return NSLocalizedString("IN_CALL_ENDED_BECAUSE_BUSY_ELSEWHERE", comment: "Call screen label when call was canceled on this device because the call recipient has a call in progress on another device.") + } + } + + var isBlinkingReconnectLabel = false + func updateCallStatusLabel() { + assert(Thread.isMainThread) + + let text = String(format: CallStrings.callStatusFormat, + localizedTextForCallState()) + self.callStatusLabel.text = text + + // Handle reconnecting blinking + if case .reconnecting = call.individualCall.state { + if !isBlinkingReconnectLabel { + isBlinkingReconnectLabel = true + UIView.animate(withDuration: 0.7, delay: 0, options: [.autoreverse, .repeat], + animations: { + self.callStatusLabel.alpha = 0.2 + }, completion: nil) + } else { + // already blinking + } + } else { + // We're no longer in a reconnecting state, either the call failed or we reconnected. + // Stop the blinking animation + if isBlinkingReconnectLabel { + self.callStatusLabel.layer.removeAllAnimations() + self.callStatusLabel.alpha = 1 + isBlinkingReconnectLabel = false + } + } + } + + func updateCallUI() { + assert(Thread.isMainThread) + updateCallStatusLabel() + + // Marquee scrolling is distracting during a video call, disable it. + contactNameLabel.labelize = call.individualCall.hasLocalVideo + + audioModeMuteButton.isSelected = call.individualCall.isMuted + videoModeMuteButton.isSelected = call.individualCall.isMuted + audioModeVideoButton.isSelected = call.individualCall.hasLocalVideo + videoModeVideoButton.isSelected = call.individualCall.hasLocalVideo + + localVideoView.isHidden = !call.individualCall.hasLocalVideo + + updateRemoteVideoTrack( + remoteVideoTrack: call.individualCall.isRemoteVideoEnabled ? call.individualCall.remoteVideoTrack : nil + ) + + // Show Incoming vs. Ongoing call controls + if call.individualCall.state == .localRinging { + let isVideoOffer = call.individualCall.offerMediaType == .video + incomingVideoCallControls.isHidden = !isVideoOffer + incomingAudioCallControls.isHidden = isVideoOffer + ongoingVideoCallControls.isHidden = true + ongoingAudioCallControls.isHidden = true + } else { + incomingVideoCallControls.isHidden = true + incomingAudioCallControls.isHidden = true + ongoingVideoCallControls.isHidden = !call.individualCall.hasLocalVideo + ongoingAudioCallControls.isHidden = call.individualCall.hasLocalVideo + } + + // Rework control state if remote video is available. + let hasRemoteVideo = !remoteVideoView.isHidden + remoteVideoView.isFullScreen = true + remoteVideoView.isScreenShare = call.individualCall.isRemoteSharingScreen + contactAvatarView.isHidden = hasRemoteVideo || isRenderingLocalVanityVideo + + // Layout controls immediately to avoid spurious animation. + for controls in [incomingVideoCallControls, incomingAudioCallControls, ongoingAudioCallControls, ongoingVideoCallControls] { + controls.layoutIfNeeded() + } + + // Also hide other controls if user has tapped to hide them. + let hideRemoteControls = shouldRemoteVideoControlsBeHidden && !remoteVideoView.isHidden + let remoteControlsAreHidden = bottomContainerView.isHidden && topGradientView.isHidden + if hideRemoteControls != remoteControlsAreHidden { + self.bottomContainerView.isHidden = false + self.topGradientView.isHidden = false + + UIView.animate(withDuration: 0.15, animations: { + self.bottomContainerView.alpha = hideRemoteControls ? 0 : 1 + self.topGradientView.alpha = hideRemoteControls ? 0 : 1 + }) { _ in + self.bottomContainerView.isHidden = hideRemoteControls + self.topGradientView.isHidden = hideRemoteControls + } + } + + let videoControls = [videoModeAudioSourceButton, videoModeFlipCameraButton, videoModeVideoButton, videoModeMuteButton, videoModeHangUpButton] + + // Audio Source Handling (bluetooth) + if self.hasAlternateAudioSources, let audioSource = callService.audioService.currentAudioSource { + audioModeSourceButton.isHidden = false + videoModeAudioSourceButton.isHidden = false + + videoModeAudioSourceButton.isHidden = !call.individualCall.hasLocalVideo + videoModeAudioSourceButton.showDropdownArrow = true + audioModeSourceButton.isHidden = call.individualCall.hasLocalVideo + audioModeSourceButton.showDropdownArrow = true + + // Use small controls, because we have 5 buttons now. + videoControls.forEach { $0.isSmall = true } + + if audioSource.isBuiltInEarPiece { + audioModeSourceButton.iconName = "phone-solid-28" + videoModeAudioSourceButton.iconName = "phone-solid-28" + } else if audioSource.isBuiltInSpeaker { + audioModeSourceButton.iconName = "speaker-solid-28" + videoModeAudioSourceButton.iconName = "speaker-solid-28" + } else { + audioModeSourceButton.iconName = "speaker-bt-solid-28" + videoModeAudioSourceButton.iconName = "speaker-bt-solid-28" + } + } else if UIDevice.current.isIPad { + // iPad *only* supports speaker mode, if there are no external + // devices connected, so we don't need to show the button unless + // we have alternate audio sources. + audioModeSourceButton.isHidden = true + videoModeAudioSourceButton.isHidden = true + } else { + audioModeSourceButton.isHidden = false + videoModeAudioSourceButton.isHidden = false + + // No bluetooth audio detected + audioModeSourceButton.iconName = "speaker-solid-28" + audioModeSourceButton.showDropdownArrow = false + + videoModeAudioSourceButton.iconName = "speaker-solid-28" + videoModeAudioSourceButton.showDropdownArrow = false + + videoControls.forEach { $0.isSmall = false } + videoModeAudioSourceButton.isHidden = true + } + + // Update local video + localVideoView.layer.cornerRadius = isRenderingLocalVanityVideo ? 0 : 8 + updateLocalVideoLayout() + + // Dismiss Handling + switch call.individualCall.state { + case .remoteHangupNeedPermission: + displayNeedPermissionErrorAndDismiss() + case .remoteHangup, .remoteBusy, .localFailure, .answeredElsewhere, .declinedElsewhere, .busyElsewhere: + Logger.debug("dismissing after delay because new state is \(call.individualCall.state)") + dismissIfPossible(shouldDelay: true) + case .localHangup: + Logger.debug("dismissing immediately from local hangup") + dismissIfPossible(shouldDelay: false) + default: break + } + + if call.individualCall.state == .connected { + if callDurationTimer == nil { + let kDurationUpdateFrequencySeconds = 1 / 20.0 + callDurationTimer = WeakTimer.scheduledTimer(timeInterval: TimeInterval(kDurationUpdateFrequencySeconds), + target: self, + userInfo: nil, + repeats: true) {[weak self] _ in + self?.updateCallDuration() + } + } + } else { + callDurationTimer?.invalidate() + callDurationTimer = nil + } + + scheduleControlTimeoutIfNecessary() + } + + func displayNeedPermissionErrorAndDismiss() { + guard !hasDismissed else { return } + + hasDismissed = true + + callService.audioService.delegate = nil + + contactNameLabel.removeFromSuperview() + callStatusLabel.removeFromSuperview() + incomingAudioCallControls.removeFromSuperview() + incomingVideoCallControls.removeFromSuperview() + ongoingAudioCallControls.removeFromSuperview() + ongoingVideoCallControls.removeFromSuperview() + backButton.removeFromSuperview() + + let needPermissionStack = UIStackView() + needPermissionStack.axis = .vertical + needPermissionStack.spacing = 20 + + view.addSubview(needPermissionStack) + needPermissionStack.autoPinWidthToSuperview(withMargin: 16) + needPermissionStack.autoVCenterInSuperview() + + needPermissionStack.addArrangedSubview(contactAvatarContainerView) + contactAvatarContainerView.autoSetDimension(.height, toSize: 200) + + let shortName = SDSDatabaseStorage.shared.read { + return self.contactsManager.shortDisplayName( + for: self.thread.contactAddress, + transaction: $0 + ) + } + + let needPermissionLabel = UILabel() + needPermissionLabel.text = String( + format: NSLocalizedString("CALL_VIEW_NEED_PERMISSION_ERROR_FORMAT", + comment: "Error displayed on the 'call' view when the callee needs to grant permission before we can call them. Embeds {callee short name}."), + shortName + ) + needPermissionLabel.numberOfLines = 0 + needPermissionLabel.lineBreakMode = .byWordWrapping + needPermissionLabel.textAlignment = .center + needPermissionLabel.textColor = Theme.darkThemePrimaryColor + needPermissionLabel.font = .ows_dynamicTypeBody + needPermissionStack.addArrangedSubview(needPermissionLabel) + + let okayButton = OWSFlatButton() + okayButton.useDefaultCornerRadius() + okayButton.setTitle(title: CommonStrings.okayButton, font: UIFont.ows_dynamicTypeBody.ows_semibold, titleColor: Theme.accentBlueColor) + okayButton.setBackgroundColors(upColor: .ows_gray05) + okayButton.contentEdgeInsets = UIEdgeInsets(top: 13, left: 34, bottom: 13, right: 34) + + okayButton.setPressedBlock { [weak self] in + self?.dismissImmediately(completion: nil) + } + + let okayButtonContainer = UIView() + okayButtonContainer.addSubview(okayButton) + okayButton.autoPinHeightToSuperview() + okayButton.autoHCenterInSuperview() + + needPermissionStack.addArrangedSubview(okayButtonContainer) + + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in + self?.dismissImmediately(completion: nil) + } + } + + func updateCallDuration() { + updateCallStatusLabel() + } + + // MARK: - Video control timeout + + private var controlTimeoutTimer: Timer? + private func scheduleControlTimeoutIfNecessary() { + if remoteVideoView.isHidden || shouldRemoteVideoControlsBeHidden { + controlTimeoutTimer?.invalidate() + controlTimeoutTimer = nil + } + + guard controlTimeoutTimer == nil else { return } + controlTimeoutTimer = .weakScheduledTimer( + withTimeInterval: 5, + target: self, + selector: #selector(timeoutControls), + userInfo: nil, + repeats: false + ) + } + + @objc + private func timeoutControls() { + controlTimeoutTimer?.invalidate() + controlTimeoutTimer = nil + + guard !remoteVideoView.isHidden && !shouldRemoteVideoControlsBeHidden else { return } + shouldRemoteVideoControlsBeHidden = true + } + + // MARK: - Actions + + /** + * Ends a connected call. Do not confuse with `didPressDeclineCall`. + */ + @objc func didPressHangup(sender: UIButton) { + Logger.info("") + + individualCallUIAdapter.localHangupCall(call) + + dismissIfPossible(shouldDelay: false) + } + + @objc func didPressMute(sender: UIButton) { + Logger.info("") + let isMuted = !sender.isSelected + + individualCallUIAdapter.setIsMuted(call: call, isMuted: isMuted) + } + + @objc func didPressAudioSource(sender button: UIButton) { + Logger.info("") + + if self.hasAlternateAudioSources { + presentAudioSourcePicker() + } else { + didPressSpeakerphone(sender: button) + } + } + + func didPressSpeakerphone(sender button: UIButton) { + Logger.info("") + + button.isSelected = !button.isSelected + callService.audioService.requestSpeakerphone(isEnabled: button.isSelected) + } + + func didPressTextMessage(sender button: UIButton) { + Logger.info("") + + dismissIfPossible(shouldDelay: false) + } + + @objc func didPressAnswerCall(sender: UIButton) { + Logger.info("") + + individualCallUIAdapter.answerCall(call) + + // Answer without video. + if sender == videoAnswerIncomingAudioOnlyButton { + individualCallUIAdapter.setHasLocalVideo(call: call, hasLocalVideo: false) + } + + // We should always be unmuted when we answer an incoming call. + // Explicitly setting it so will cause us to prompt for + // microphone permissions if necessary. + individualCallUIAdapter.setIsMuted(call: call, isMuted: false) + } + + @objc func didPressVideo(sender: UIButton) { + Logger.info("") + let hasLocalVideo = !sender.isSelected + + individualCallUIAdapter.setHasLocalVideo(call: call, hasLocalVideo: hasLocalVideo) + } + + @objc func didPressFlipCamera(sender: UIButton) { + sender.isSelected = !sender.isSelected + + let isUsingFrontCamera = !sender.isSelected + Logger.info("with isUsingFrontCamera: \(isUsingFrontCamera)") + + individualCallUIAdapter.setCameraSource(call: call, isUsingFrontCamera: isUsingFrontCamera) + } + + /** + * Denies an incoming not-yet-connected call, Do not confuse with `didPressHangup`. + */ + @objc func didPressDeclineCall(sender: UIButton) { + Logger.info("") + + individualCallUIAdapter.localHangupCall(call) + + dismissIfPossible(shouldDelay: false) + } + + @objc func didTapLeaveCall(sender: UIButton) { + OWSWindowManager.shared.leaveCallView() + } + + // MARK: - CallObserver + + internal func individualCallStateDidChange(_ call: SignalCall, state: CallState) { + AssertIsOnMainThread() + Logger.info("new call status: \(state)") + + self.updateCallUI() + } + + internal func individualCallLocalVideoMuteDidChange(_ call: SignalCall, isVideoMuted: Bool) { + AssertIsOnMainThread() + self.updateCallUI() + } + + internal func individualCallLocalAudioMuteDidChange(_ call: SignalCall, isAudioMuted: Bool) { + AssertIsOnMainThread() + self.updateCallUI() + } + + func individualCallHoldDidChange(_ call: SignalCall, isOnHold: Bool) { + AssertIsOnMainThread() + self.updateCallUI() + } + + func individualCallRemoteVideoMuteDidChange(_ call: SignalCall, isVideoMuted: Bool) { + AssertIsOnMainThread() + updateRemoteVideoTrack(remoteVideoTrack: isVideoMuted ? nil : call.individualCall.remoteVideoTrack) + } + + func individualCallRemoteSharingScreenDidChange(_ call: SignalCall, isRemoteSharingScreen: Bool) { + AssertIsOnMainThread() + self.updateCallUI() + } + + // MARK: - CallAudioServiceDelegate + + func callAudioServiceDidChangeAudioSession(_ callAudioService: CallAudioService) { + AssertIsOnMainThread() + + // Which sources are available depends on the state of your Session. + // When the audio session is not yet in PlayAndRecord none are available + // Then if we're in speakerphone, bluetooth isn't available. + // So we accrue all possible audio sources in a set, and that list lives as longs as the CallViewController + // The downside of this is that if you e.g. unpair your bluetooth mid call, it will still appear as an option + // until your next call. + // FIXME: There's got to be a better way, but this is where I landed after a bit of work, and seems to work + // pretty well in practice. + let availableInputs = callAudioService.availableInputs + self.allAudioSources.formUnion(availableInputs) + updateCallUI() + } + + func callAudioServiceDidChangeAudioSource(_ callAudioService: CallAudioService, audioSource: AudioSource?) { + // You might have switched to a newly available route (like a bluetooth device connecting), + // update the UI to reflect it. + let availableInputs = callAudioService.availableInputs + self.allAudioSources.formUnion(availableInputs) + updateCallUI() + + guard let audioSource = audioSource else { + audioModeSourceButton.isSelected = false + videoModeAudioSourceButton.isSelected = false + return + } + + audioModeSourceButton.isSelected = !audioSource.isBuiltInEarPiece + videoModeAudioSourceButton.isSelected = !audioSource.isBuiltInEarPiece + } + + // MARK: - Video + + var hasRemoteVideoTrack: Bool { + return self.remoteVideoTrack != nil + } + + internal func updateRemoteVideoTrack(remoteVideoTrack: RTCVideoTrack?) { + AssertIsOnMainThread() + + guard self.remoteVideoTrack != remoteVideoTrack else { + Logger.debug("ignoring redundant update") + return + } + + self.remoteVideoTrack?.remove(remoteVideoView) + self.remoteVideoTrack = nil + remoteVideoView.renderFrame(nil) + self.remoteVideoTrack = remoteVideoTrack + self.remoteVideoTrack?.add(remoteVideoView) + + shouldRemoteVideoControlsBeHidden = false + + if remoteVideoTrack != nil { + playRemoteEnabledVideoHapticFeedback() + } + + updateRemoteVideoLayout() + } + + // MARK: Video Haptics + + let feedbackGenerator = NotificationHapticFeedback() + var lastHapticTime: TimeInterval = CACurrentMediaTime() + func playRemoteEnabledVideoHapticFeedback() { + let currentTime = CACurrentMediaTime() + guard currentTime - lastHapticTime > 5 else { + Logger.debug("ignoring haptic feedback since it's too soon") + return + } + feedbackGenerator.notificationOccurred(.success) + lastHapticTime = currentTime + } + + // MARK: - Dismiss + + internal func dismissIfPossible(shouldDelay: Bool, completion: (() -> Void)? = nil) { + callService.audioService.delegate = nil + + if hasDismissed { + // Don't dismiss twice. + return + } else if shouldDelay { + hasDismissed = true + + if UIApplication.shared.applicationState == .active { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let strongSelf = self else { return } + strongSelf.dismissImmediately(completion: completion) + } + } else { + dismissImmediately(completion: completion) + } + } else { + hasDismissed = true + dismissImmediately(completion: completion) + } + } + + internal func dismissImmediately(completion: (() -> Void)?) { + OWSWindowManager.shared.endCall(self) + completion?() + } +} + +extension IndividualCallViewController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return !localVideoView.isHidden && localVideoView.superview == view && call.individualCall.state == .connected + } +} + +extension IndividualCallViewController: CallViewControllerWindowReference { + var remoteVideoViewReference: UIView { remoteVideoView } + var localVideoViewReference: UIView { localVideoView } + var remoteVideoAddress: SignalServiceAddress { thread.contactAddress } + + @objc + public func returnFromPip(pipWindow: UIWindow) { + // The call "pip" uses our remote and local video views since only + // one `AVCaptureVideoPreviewLayer` per capture session is supported. + // We need to re-add them when we return to this view. + guard remoteVideoView.superview != view && localVideoView.superview != view else { + return owsFailDebug("unexpectedly returned to call while we own the video views") + } + + guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else { + return owsFailDebug("failed to snapshot rootViewController") + } + + guard let pipSnapshot = pipWindow.snapshotView(afterScreenUpdates: false) else { + return owsFailDebug("failed to snapshot pip") + } + + view.insertSubview(remoteVideoView, aboveSubview: blurView) + remoteVideoView.autoPinEdgesToSuperviewEdges() + + view.insertSubview(localVideoView, aboveSubview: contactAvatarContainerView) + + updateLocalVideoLayout() + + shouldRemoteVideoControlsBeHidden = false + + animateReturnFromPip(pipSnapshot: pipSnapshot, pipFrame: pipWindow.frame, splitViewSnapshot: splitViewSnapshot) + } + + private func animateReturnFromPip(pipSnapshot: UIView, pipFrame: CGRect, splitViewSnapshot: UIView) { + guard let window = view.window else { return owsFailDebug("missing window") } + view.superview?.insertSubview(splitViewSnapshot, belowSubview: view) + splitViewSnapshot.autoPinEdgesToSuperviewEdges() + + view.frame = pipFrame + view.addSubview(pipSnapshot) + pipSnapshot.autoPinEdgesToSuperviewEdges() + + view.layoutIfNeeded() + + UIView.animate(withDuration: 0.2, animations: { + pipSnapshot.alpha = 0 + self.view.frame = window.frame + self.view.layoutIfNeeded() + }) { _ in + self.updateCallUI() + splitViewSnapshot.removeFromSuperview() + pipSnapshot.removeFromSuperview() + } + } +} diff --git a/Session/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift b/Session/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift new file mode 100644 index 000000000..a22726cdd --- /dev/null +++ b/Session/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift @@ -0,0 +1,182 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +/** + * Manage call related UI in a pre-CallKit world. + */ +class NonCallKitCallUIAdaptee: NSObject, CallUIAdaptee { + + // Starting/Stopping incoming call ringing is our apps responsibility for the non CallKit interface. + let hasManualRinger = true + + required override init() { + AssertIsOnMainThread() + + super.init() + } + + // MARK: + + func startOutgoingCall(call: SignalCall) { + AssertIsOnMainThread() + + // make sure we don't terminate audio session during call + let success = self.audioSession.startAudioActivity(call.audioActivity) + assert(success) + + self.callService.individualCallService.handleOutgoingCall(call) + } + + func reportIncomingCall(_ call: SignalCall, callerName: String, completion: @escaping (Error?) -> Void) { + AssertIsOnMainThread() + + Logger.debug("") + + self.showCall(call) + + startNotifiyingForIncomingCall(call, callerName: callerName) + + completion(nil) + } + + private var incomingCallNotificationTimer: Timer? + private func startNotifiyingForIncomingCall(_ call: SignalCall, callerName: String) { + incomingCallNotificationTimer?.invalidate() + incomingCallNotificationTimer = nil + + // present lock screen notification if we're in the background. + // we re-present the notifiation every 3 seconds to make sure + // the user sees that their phone is ringing + incomingCallNotificationTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + guard call.individualCall.state == .localRinging else { + self?.incomingCallNotificationTimer?.invalidate() + self?.incomingCallNotificationTimer = nil + return + } + if UIApplication.shared.applicationState == .active { + Logger.debug("skipping notification since app is already active.") + } else { + let notificationPresenter = AppEnvironment.shared.notificationPresenter + notificationPresenter.presentIncomingCall(call.individualCall, callerName: callerName) + } + } + } + + func answerCall(localId: UUID) { + AssertIsOnMainThread() + + guard let call = self.callService.currentCall else { + owsFailDebug("No current call.") + return + } + + guard call.individualCall.localId == localId else { + owsFailDebug("localId does not match current call") + return + } + + self.answerCall(call) + } + + func answerCall(_ call: SignalCall) { + AssertIsOnMainThread() + + guard call.individualCall.localId == self.callService.currentCall?.individualCall.localId else { + owsFailDebug("localId does not match current call") + return + } + + self.audioSession.isRTCAudioEnabled = true + self.callService.individualCallService.handleAcceptCall(call) + } + + func recipientAcceptedCall(_ call: SignalCall) { + AssertIsOnMainThread() + + self.audioSession.isRTCAudioEnabled = true + } + + func localHangupCall(localId: UUID) { + AssertIsOnMainThread() + + guard let call = self.callService.currentCall else { + owsFailDebug("No current call.") + return + } + + guard call.individualCall.localId == localId else { + owsFailDebug("localId does not match current call") + return + } + + self.localHangupCall(call) + } + + func localHangupCall(_ call: SignalCall) { + AssertIsOnMainThread() + + // If both parties hang up at the same moment, + // call might already be nil. + guard self.callService.currentCall == nil || call.individualCall.localId == self.callService.currentCall?.individualCall.localId else { + owsFailDebug("localId does not match current call") + return + } + + self.callService.individualCallService.handleLocalHangupCall(call) + } + + internal func remoteDidHangupCall(_ call: SignalCall) { + AssertIsOnMainThread() + + Logger.debug("is no-op") + } + + internal func remoteBusy(_ call: SignalCall) { + AssertIsOnMainThread() + + Logger.debug("is no-op") + } + + internal func didAnswerElsewhere(call: SignalCall) { + AssertIsOnMainThread() + + Logger.debug("is no-op") + } + + internal func didDeclineElsewhere(call: SignalCall) { + AssertIsOnMainThread() + + Logger.debug("is no-op") + } + + internal func failCall(_ call: SignalCall, error: SignalCall.CallError) { + AssertIsOnMainThread() + + Logger.debug("is no-op") + } + + func setIsMuted(call: SignalCall, isMuted: Bool) { + AssertIsOnMainThread() + + guard call.individualCall.localId == self.callService.currentCall?.individualCall.localId else { + owsFailDebug("localId does not match current call") + return + } + + self.callService.updateIsLocalAudioMuted(isLocalAudioMuted: isMuted) + } + + func setHasLocalVideo(call: SignalCall, hasLocalVideo: Bool) { + AssertIsOnMainThread() + + guard call.individualCall.localId == self.callService.currentCall?.individualCall.localId else { + owsFailDebug("localId does not match current call") + return + } + + self.callService.updateIsLocalVideoMuted(isLocalVideoMuted: !hasLocalVideo) + } +} diff --git a/Session/Calls/UserInterface/LocalVideoView.swift b/Session/Calls/UserInterface/LocalVideoView.swift new file mode 100644 index 000000000..731576252 --- /dev/null +++ b/Session/Calls/UserInterface/LocalVideoView.swift @@ -0,0 +1,117 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation + +class LocalVideoView: UIView { + private let localVideoCapturePreview = RTCCameraPreviewView() + + var captureSession: AVCaptureSession? { + set { localVideoCapturePreview.captureSession = newValue } + get { localVideoCapturePreview.captureSession } + } + + override var contentMode: UIView.ContentMode { + didSet { localVideoCapturePreview.contentMode = contentMode } + } + + override init(frame: CGRect) { + super.init(frame: .zero) + + addSubview(localVideoCapturePreview) + + if Platform.isSimulator { + backgroundColor = .green + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(updateLocalVideoOrientation), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var frame: CGRect { + didSet { updateLocalVideoOrientation() } + } + + @objc + func updateLocalVideoOrientation() { + defer { localVideoCapturePreview.frame = bounds } + + // iPad supports rotating this view controller directly, + // so we don't need to do anything here. + guard !UIDevice.current.isIPad else { return } + + // We lock this view to portrait only on phones, but the + // local video capture will rotate with the device's + // orientation (so the remote party will render your video + // in the correct orientation). As such, we need to rotate + // the local video preview layer so it *looks* like we're + // also always capturing in portrait. + + switch UIDevice.current.orientation { + case .portrait: + localVideoCapturePreview.transform = .identity + case .portraitUpsideDown: + localVideoCapturePreview.transform = .init(rotationAngle: .pi) + case .landscapeLeft: + localVideoCapturePreview.transform = .init(rotationAngle: .halfPi) + case .landscapeRight: + localVideoCapturePreview.transform = .init(rotationAngle: .pi + .halfPi) + case .faceUp, .faceDown, .unknown: + break + @unknown default: + break + } + } +} + +extension RTCCameraPreviewView { + var previewLayer: AVCaptureVideoPreviewLayer? { + return layer as? AVCaptureVideoPreviewLayer + } + + open override var contentMode: UIView.ContentMode { + set { + guard let previewLayer = previewLayer else { + return owsFailDebug("missing preview layer") + } + + switch newValue { + case .scaleAspectFill: + previewLayer.videoGravity = .resizeAspectFill + case .scaleAspectFit: + previewLayer.videoGravity = .resizeAspect + case .scaleToFill: + previewLayer.videoGravity = .resize + default: + owsFailDebug("Unexpected contentMode") + } + } + get { + guard let previewLayer = previewLayer else { + owsFailDebug("missing preview layer") + return .scaleToFill + } + + switch previewLayer.videoGravity { + case .resizeAspectFill: + return .scaleAspectFill + case .resizeAspect: + return .scaleAspectFit + case .resize: + return .scaleToFill + default: + owsFailDebug("Unexpected contentMode") + return .scaleToFill + } + } + } +} diff --git a/Session/Calls/UserInterface/RemoteVideoView.h b/Session/Calls/UserInterface/RemoteVideoView.h new file mode 100644 index 000000000..5355ea649 --- /dev/null +++ b/Session/Calls/UserInterface/RemoteVideoView.h @@ -0,0 +1,21 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Drives the full screen remote video. This is *not* a swift class + * so we can take advantage of some compile time constants from WebRTC + */ +@interface RemoteVideoView : UIView + +@property (nonatomic) BOOL isGroupCall; +@property (nonatomic) BOOL isScreenShare; +@property (nonatomic) BOOL isFullScreen; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Session/Calls/UserInterface/RemoteVideoView.m b/Session/Calls/UserInterface/RemoteVideoView.m new file mode 100644 index 000000000..25fbea98c --- /dev/null +++ b/Session/Calls/UserInterface/RemoteVideoView.m @@ -0,0 +1,289 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +#import "RemoteVideoView.h" +#import "UIFont+OWS.h" +#import "UIView+OWS.h" +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#if defined(__arm64__) +#define DEVICE_SUPPORTS_METAL 1 +#else +#define DEVICE_SUPPORTS_METAL 0 +#endif + +#pragma mark - + +@interface RemoteVideoView () + +@property (nonatomic, nullable) __kindof UIView *videoRenderer; +@property (nonatomic) BOOL applyDefaultRendererConfigurationOnNextFrame; + +@end + +#if DEVICE_SUPPORTS_METAL + +@interface RemoteVideoView (Metal) + +@property (nonatomic, readonly, nullable) RTCMTLVideoView *metalRenderer; + +- (void)setupMetalRenderer; + +@end + +@implementation RemoteVideoView (Metal) + +- (void)setupMetalRenderer +{ + RTCMTLVideoView *rtcMetalView = [[RTCMTLVideoView alloc] initWithFrame:CGRectZero]; + self.videoRenderer = rtcMetalView; + [self addSubview:rtcMetalView]; + [rtcMetalView autoPinEdgesToSuperviewEdges]; + // We want the rendered video to go edge-to-edge. + rtcMetalView.layoutMargins = UIEdgeInsetsZero; + // HACK: Although RTCMTLVideo view is positioned to the top edge of the screen + // It's inner (private) MTKView is below the status bar. + for (UIView *subview in [rtcMetalView subviews]) { + if ([subview isKindOfClass:[MTKView class]]) { + [subview autoPinEdgesToSuperviewEdges]; + } else { + OWSFailDebug(@"New subviews added to MTLVideoView. Reconsider this hack."); + } + } +} + +- (nullable RTCMTLVideoView *)metalRenderer +{ + return (RTCMTLVideoView *)self.videoRenderer; +} + +@end + +#endif + +#pragma mark - + +@implementation RemoteVideoView + +- (instancetype)init +{ + self = [super init]; + if (!self) { + return self; + } + +#if DEVICE_SUPPORTS_METAL + [self setupMetalRenderer]; +#endif + + [self applyDefaultRendererConfiguration]; + + // Metal is not supported on the simulator, so we just set a + // background color for debugging purposes. + if (Platform.isSimulator) { + // For simulators just set a solid background color. + self.backgroundColor = [UIColor.blueColor colorWithAlphaComponent:0.4]; + } else { + OWSAssertDebug(self.videoRenderer); + } + + return self; +} + +#pragma mark - RTCVideoRenderer + +/** The size of the frame. */ +- (void)setSize:(CGSize)size +{ + [self.videoRenderer setSize:size]; +} + +/** The frame to be displayed. */ +- (void)renderFrame:(nullable RTCVideoFrame *)frame +{ + [self.videoRenderer renderFrame:frame]; + +#if DEVICE_SUPPORTS_METAL + DispatchMainThreadSafe(^{ + if (self.applyDefaultRendererConfigurationOnNextFrame) { + self.applyDefaultRendererConfigurationOnNextFrame = NO; + [self applyDefaultRendererConfiguration]; + } + + if (self.isScreenShare) { + self.metalRenderer.videoContentMode = UIViewContentModeScaleAspectFit; + + // Rotate the video so it's always right side up in landscape. We only + // allow portrait orientation in the calling views on iPhone so we don't + // get this for free. iPad allows all orientations so we can skip this. + if (self.isFullScreen && !UIDevice.currentDevice.isIPad) { + switch (UIDevice.currentDevice.orientation) { + case UIDeviceOrientationPortrait: + case UIDeviceOrientationPortraitUpsideDown: + // We don't have to do anything, the renderer will automatically + // make sure it's right-side-up. + self.metalRenderer.rotationOverride = nil; + break; + case UIDeviceOrientationLandscapeLeft: + switch (frame.rotation) { + // Portrait upside-down + case RTCVideoRotation_270: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_0); + break; + // Portrait + case RTCVideoRotation_90: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_180); + break; + // Landscape right + case RTCVideoRotation_180: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_270); + break; + // Landscape left + case RTCVideoRotation_0: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_90); + break; + } + break; + case UIDeviceOrientationLandscapeRight: + switch (frame.rotation) { + // Portrait upside-down + case RTCVideoRotation_270: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_180); + break; + // Portrait + case RTCVideoRotation_90: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_0); + break; + // Landscape right + case RTCVideoRotation_180: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_90); + break; + // Landscape left + case RTCVideoRotation_0: + self.metalRenderer.rotationOverride = @(RTCVideoRotation_270); + break; + } + break; + default: + // Do nothing if we're face down, up, etc. + // Assume we're already setup for the correct orientation. + break; + } + } else { + self.metalRenderer.rotationOverride = nil; + } + + } else if (UIDevice.currentDevice.isIPad || self.isGroupCall) { + BOOL isLandscape = self.width > self.height; + BOOL remoteIsLandscape = frame.rotation == RTCVideoRotation_180 || frame.rotation == RTCVideoRotation_0; + + BOOL isSquarish = (MAX(self.width, self.height) / MIN(self.width, self.height)) <= 1.2; + + self.metalRenderer.rotationOverride = nil; + + // If we're both in the same orientation, let the video fill the screen. + // Otherwise, fit the video to the screen size respecting the aspect ratio. + if (isLandscape == remoteIsLandscape || isSquarish) { + self.metalRenderer.videoContentMode = UIViewContentModeScaleAspectFill; + } else { + self.metalRenderer.videoContentMode = UIViewContentModeScaleAspectFit; + } + } else { + // iPhones are locked to portrait mode. However, we want both + // portrait and portrait upside-down to be right side up in portrait. + // We want both landscape left and landscape right to be right side + // up in landscape. This means, sometimes we force the rotation to + // portrait, and sometimes we force the rotation to portrait upside + // down depending on the orientation of the incoming frames AND + // the device's current orientation, so that from the user's perspective + // everything always looks right-side-up. + switch (frame.rotation) { + // Portrait upside-down + case RTCVideoRotation_270: + // Portrait upside down renders in portrait + self.metalRenderer.rotationOverride = @(RTCVideoRotation_270); + break; + // Portrait + case RTCVideoRotation_90: + // Portrait renders in portrait + self.metalRenderer.rotationOverride = @(RTCVideoRotation_90); + break; + // Landscape right + case RTCVideoRotation_180: + // If the device is in landscape left, flip upside down + if (UIDevice.currentDevice.orientation == UIDeviceOrientationLandscapeLeft) { + self.metalRenderer.rotationOverride = @(RTCVideoRotation_270); + } else if (UIDevice.currentDevice.orientation == UIDeviceOrientationLandscapeRight) { + self.metalRenderer.rotationOverride = @(RTCVideoRotation_90); + } + break; + // Landscape left + case RTCVideoRotation_0: + // If the device is in landscape right, flip upside down + if (UIDevice.currentDevice.orientation == UIDeviceOrientationLandscapeRight) { + self.metalRenderer.rotationOverride = @(RTCVideoRotation_270); + } else if (UIDevice.currentDevice.orientation == UIDeviceOrientationLandscapeLeft) { + self.metalRenderer.rotationOverride = @(RTCVideoRotation_90); + } + break; + } + } + }); +#endif +} + +- (void)setIsScreenShare:(BOOL)isScreenShare +{ + if (isScreenShare != _isScreenShare) { + self.applyDefaultRendererConfigurationOnNextFrame = YES; + } + + _isScreenShare = isScreenShare; +} + +- (void)setIsGroupCall:(BOOL)isGroupCall +{ + if (isGroupCall != _isGroupCall) { + self.applyDefaultRendererConfigurationOnNextFrame = YES; + } + + _isGroupCall = isGroupCall; +} + +- (void)setIsFullScreen:(BOOL)isFullScreen +{ + if (isFullScreen != _isFullScreen) { + self.applyDefaultRendererConfigurationOnNextFrame = YES; + } + + _isFullScreen = isFullScreen; +} + +- (void)applyDefaultRendererConfiguration +{ +#if DEVICE_SUPPORTS_METAL + if (UIDevice.currentDevice.isIPad) { + self.metalRenderer.videoContentMode = UIViewContentModeScaleAspectFit; + self.metalRenderer.rotationOverride = nil; + } else { + self.metalRenderer.videoContentMode = UIViewContentModeScaleAspectFill; + self.metalRenderer.rotationOverride = nil; + } +#endif +} + +@end + +NS_ASSUME_NONNULL_END