Add back UI code
This commit is contained in:
parent
c77255a57f
commit
05a16da189
|
@ -177,6 +177,26 @@
|
||||||
B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; };
|
B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; };
|
||||||
B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3C25C7B34D00488AB4 /* InputTextView.swift */; };
|
B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3C25C7B34D00488AB4 /* InputTextView.swift */; };
|
||||||
B82A0C0026B79EB700C1BCE3 /* GroupCallUpdateMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0BFF26B79EB700C1BCE3 /* GroupCallUpdateMessage.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 */; };
|
B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40872399EB0E00A248E7 /* LandingVC.swift */; };
|
||||||
B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40892399EC0600A248E7 /* FakeChatView.swift */; };
|
B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40892399EC0600A248E7 /* FakeChatView.swift */; };
|
||||||
B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B408B239A068800A248E7 /* RegisterVC.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 = "<group>"; };
|
B8269D3225C7A8C600488AB4 /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = "<group>"; };
|
||||||
B8269D3C25C7B34D00488AB4 /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = "<group>"; };
|
B8269D3C25C7B34D00488AB4 /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = "<group>"; };
|
||||||
B82A0BFF26B79EB700C1BCE3 /* GroupCallUpdateMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallUpdateMessage.swift; sourceTree = "<group>"; };
|
B82A0BFF26B79EB700C1BCE3 /* GroupCallUpdateMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallUpdateMessage.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0526B7B45200C1BCE3 /* GroupCallNotificationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallNotificationView.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0626B7B45200C1BCE3 /* GroupCallVideoOverflow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallVideoOverflow.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0726B7B45200C1BCE3 /* GroupCallMemberSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallMemberSheet.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0826B7B45200C1BCE3 /* GroupCallViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallViewController.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0926B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallVideoGridLayout.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0A26B7B45200C1BCE3 /* GroupCallVideoGrid.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallVideoGrid.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0B26B7B45200C1BCE3 /* GroupCallSwipeToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallSwipeToastView.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0C26B7B45200C1BCE3 /* GroupCallErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallErrorView.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0D26B7B45200C1BCE3 /* GroupCallTooltip.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallTooltip.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0E26B7B45200C1BCE3 /* GroupCallMemberView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallMemberView.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C0F26B7B45200C1BCE3 /* RemoteVideoView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RemoteVideoView.m; sourceTree = "<group>"; };
|
||||||
|
B82A0C1026B7B45200C1BCE3 /* LocalVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalVideoView.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1126B7B45200C1BCE3 /* CallHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallHeader.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1226B7B45200C1BCE3 /* CallButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallButton.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1326B7B45200C1BCE3 /* CallControls.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallControls.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1426B7B45200C1BCE3 /* RemoteVideoView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RemoteVideoView.h; sourceTree = "<group>"; };
|
||||||
|
B82A0C1626B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NonCallKitCallUIAdaptee.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1726B7B45200C1BCE3 /* IndividualCallViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndividualCallViewController.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1926B7B45200C1BCE3 /* CallKitCallManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallKitCallManager.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1A26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallKitCallUIAdaptee.swift; sourceTree = "<group>"; };
|
||||||
|
B82A0C1B26B7B45200C1BCE3 /* CallUIAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallUIAdapter.swift; sourceTree = "<group>"; };
|
||||||
B82B40872399EB0E00A248E7 /* LandingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingVC.swift; sourceTree = "<group>"; };
|
B82B40872399EB0E00A248E7 /* LandingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingVC.swift; sourceTree = "<group>"; };
|
||||||
B82B40892399EC0600A248E7 /* FakeChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeChatView.swift; sourceTree = "<group>"; };
|
B82B40892399EC0600A248E7 /* FakeChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeChatView.swift; sourceTree = "<group>"; };
|
||||||
B82B408B239A068800A248E7 /* RegisterVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterVC.swift; sourceTree = "<group>"; };
|
B82B408B239A068800A248E7 /* RegisterVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterVC.swift; sourceTree = "<group>"; };
|
||||||
|
@ -2176,6 +2217,58 @@
|
||||||
path = "Views & Modals";
|
path = "Views & Modals";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
B82A0C1526B7B45200C1BCE3 /* Individual */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B82A0C1626B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift */,
|
||||||
|
B82A0C1726B7B45200C1BCE3 /* IndividualCallViewController.swift */,
|
||||||
|
B82A0C1826B7B45200C1BCE3 /* CallKit */,
|
||||||
|
B82A0C1B26B7B45200C1BCE3 /* CallUIAdapter.swift */,
|
||||||
|
);
|
||||||
|
path = Individual;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B82A0C1826B7B45200C1BCE3 /* CallKit */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B82A0C1926B7B45200C1BCE3 /* CallKitCallManager.swift */,
|
||||||
|
B82A0C1A26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift */,
|
||||||
|
);
|
||||||
|
path = CallKit;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B835246C25C38AA20089A44F /* Conversations */ = {
|
B835246C25C38AA20089A44F /* Conversations */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -2215,13 +2308,14 @@
|
||||||
children = (
|
children = (
|
||||||
B822F9C326B275FA003B8CB8 /* INSTRUCTIONS.md */,
|
B822F9C326B275FA003B8CB8 /* INSTRUCTIONS.md */,
|
||||||
B882A75026AE878300B5AB69 /* CallService.swift */,
|
B882A75026AE878300B5AB69 /* CallService.swift */,
|
||||||
B882A75126AE878300B5AB69 /* Group */,
|
|
||||||
B882A75426AE878300B5AB69 /* CallAudioService.swift */,
|
B882A75426AE878300B5AB69 /* CallAudioService.swift */,
|
||||||
B882A75526AE878300B5AB69 /* OWSAudioSession+WebRTC.swift */,
|
B882A75526AE878300B5AB69 /* OWSAudioSession+WebRTC.swift */,
|
||||||
B882A75626AE878300B5AB69 /* SignalCall.swift */,
|
B882A75626AE878300B5AB69 /* SignalCall.swift */,
|
||||||
B882A77026AE878300B5AB69 /* Signaling */,
|
|
||||||
B882A77326AE878300B5AB69 /* AudioSource.swift */,
|
B882A77326AE878300B5AB69 /* AudioSource.swift */,
|
||||||
|
B882A77026AE878300B5AB69 /* Signaling */,
|
||||||
B882A77426AE878300B5AB69 /* Individual */,
|
B882A77426AE878300B5AB69 /* Individual */,
|
||||||
|
B882A75126AE878300B5AB69 /* Group */,
|
||||||
|
B82A0C0326B7B45200C1BCE3 /* UserInterface */,
|
||||||
);
|
);
|
||||||
path = Calls;
|
path = Calls;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -4881,6 +4975,7 @@
|
||||||
452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */,
|
452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */,
|
||||||
3496956E21A301A100DCFE74 /* OWSBackupExportJob.m in Sources */,
|
3496956E21A301A100DCFE74 /* OWSBackupExportJob.m in Sources */,
|
||||||
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
||||||
|
B82A0C2C26B7B45200C1BCE3 /* IndividualCallViewController.swift in Sources */,
|
||||||
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
||||||
B882A79226AE878300B5AB69 /* WebRTCCallMessageHandler.swift in Sources */,
|
B882A79226AE878300B5AB69 /* WebRTCCallMessageHandler.swift in Sources */,
|
||||||
B882A79326AE878300B5AB69 /* TurnServerInfo.swift in Sources */,
|
B882A79326AE878300B5AB69 /* TurnServerInfo.swift in Sources */,
|
||||||
|
@ -4918,6 +5013,7 @@
|
||||||
34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */,
|
34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */,
|
||||||
34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */,
|
34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */,
|
||||||
4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */,
|
4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */,
|
||||||
|
B82A0C2626B7B45200C1BCE3 /* RemoteVideoView.m in Sources */,
|
||||||
3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */,
|
3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */,
|
||||||
C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */,
|
C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */,
|
||||||
340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */,
|
340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */,
|
||||||
|
@ -4925,6 +5021,7 @@
|
||||||
B882A79726AE878300B5AB69 /* IndividualCall.swift in Sources */,
|
B882A79726AE878300B5AB69 /* IndividualCall.swift in Sources */,
|
||||||
C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */,
|
C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */,
|
||||||
C3548F0624456447009433A8 /* PNModeVC.swift in Sources */,
|
C3548F0624456447009433A8 /* PNModeVC.swift in Sources */,
|
||||||
|
B82A0C1D26B7B45200C1BCE3 /* GroupCallVideoOverflow.swift in Sources */,
|
||||||
B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */,
|
B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */,
|
||||||
D221A09A169C9E5E00537ABF /* main.m in Sources */,
|
D221A09A169C9E5E00537ABF /* main.m in Sources */,
|
||||||
3496957221A301A100DCFE74 /* OWSBackup.m in Sources */,
|
3496957221A301A100DCFE74 /* OWSBackup.m in Sources */,
|
||||||
|
@ -4934,7 +5031,9 @@
|
||||||
34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */,
|
34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */,
|
||||||
B8D0A26925E4A2C200C1835E /* Onboarding.swift in Sources */,
|
B8D0A26925E4A2C200C1835E /* Onboarding.swift in Sources */,
|
||||||
B882A79526AE878300B5AB69 /* IndividualCallService.swift in Sources */,
|
B882A79526AE878300B5AB69 /* IndividualCallService.swift in Sources */,
|
||||||
|
B82A0C2026B7B45200C1BCE3 /* GroupCallVideoGridLayout.swift in Sources */,
|
||||||
34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */,
|
34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */,
|
||||||
|
B82A0C1C26B7B45200C1BCE3 /* GroupCallNotificationView.swift in Sources */,
|
||||||
450DF2051E0D74AC003D14BE /* Platform.swift in Sources */,
|
450DF2051E0D74AC003D14BE /* Platform.swift in Sources */,
|
||||||
4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */,
|
4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */,
|
||||||
B882A77B26AE878300B5AB69 /* CallAudioService.swift in Sources */,
|
B882A77B26AE878300B5AB69 /* CallAudioService.swift in Sources */,
|
||||||
|
@ -4942,12 +5041,14 @@
|
||||||
B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */,
|
B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */,
|
||||||
346129991FD1E4DA00532771 /* SignalApp.m in Sources */,
|
346129991FD1E4DA00532771 /* SignalApp.m in Sources */,
|
||||||
3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */,
|
3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */,
|
||||||
|
B82A0C2B26B7B45200C1BCE3 /* NonCallKitCallUIAdaptee.swift in Sources */,
|
||||||
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
|
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
|
||||||
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
|
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
|
||||||
B882A77D26AE878300B5AB69 /* SignalCall.swift in Sources */,
|
B882A77D26AE878300B5AB69 /* SignalCall.swift in Sources */,
|
||||||
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
|
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
|
||||||
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
|
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
|
||||||
C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */,
|
C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */,
|
||||||
|
B82A0C2126B7B45200C1BCE3 /* GroupCallVideoGrid.swift in Sources */,
|
||||||
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
|
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
|
||||||
45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */,
|
45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */,
|
||||||
45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */,
|
45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */,
|
||||||
|
@ -4960,6 +5061,7 @@
|
||||||
C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */,
|
C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */,
|
||||||
B882A77A26AE878300B5AB69 /* GroupCallRemoteVideoManager.swift in Sources */,
|
B882A77A26AE878300B5AB69 /* GroupCallRemoteVideoManager.swift in Sources */,
|
||||||
B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */,
|
B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */,
|
||||||
|
B82A0C1F26B7B45200C1BCE3 /* GroupCallViewController.swift in Sources */,
|
||||||
4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */,
|
4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */,
|
||||||
34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */,
|
34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */,
|
||||||
344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */,
|
344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */,
|
||||||
|
@ -4974,10 +5076,12 @@
|
||||||
4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */,
|
4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */,
|
||||||
B848A4C5269EAAA200617031 /* UserDetailsSheet.swift in Sources */,
|
B848A4C5269EAAA200617031 /* UserDetailsSheet.swift in Sources */,
|
||||||
34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */,
|
34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */,
|
||||||
|
B82A0C2226B7B45200C1BCE3 /* GroupCallSwipeToastView.swift in Sources */,
|
||||||
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */,
|
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */,
|
||||||
B8544E3323D50E4900299F14 /* SNAppearance.swift in Sources */,
|
B8544E3323D50E4900299F14 /* SNAppearance.swift in Sources */,
|
||||||
4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */,
|
4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */,
|
||||||
C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */,
|
C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */,
|
||||||
|
B82A0C2F26B7B45200C1BCE3 /* CallUIAdapter.swift in Sources */,
|
||||||
B882A79626AE878300B5AB69 /* OutboundIndividualCallInitiator.swift in Sources */,
|
B882A79626AE878300B5AB69 /* OutboundIndividualCallInitiator.swift in Sources */,
|
||||||
4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */,
|
4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */,
|
||||||
45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */,
|
45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */,
|
||||||
|
@ -4988,11 +5092,13 @@
|
||||||
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */,
|
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */,
|
||||||
B882A77926AE878300B5AB69 /* GroupCallUpdateMessageHandler.swift in Sources */,
|
B882A77926AE878300B5AB69 /* GroupCallUpdateMessageHandler.swift in Sources */,
|
||||||
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
|
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
|
||||||
|
B82A0C2926B7B45200C1BCE3 /* CallButton.swift in Sources */,
|
||||||
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
|
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
|
||||||
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
|
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
|
||||||
B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */,
|
B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */,
|
||||||
B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */,
|
B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */,
|
||||||
C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */,
|
C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */,
|
||||||
|
B82A0C2826B7B45200C1BCE3 /* CallHeader.swift in Sources */,
|
||||||
3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */,
|
3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */,
|
||||||
45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */,
|
45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */,
|
||||||
4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */,
|
4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */,
|
||||||
|
@ -5000,7 +5106,10 @@
|
||||||
45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */,
|
45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */,
|
||||||
34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */,
|
34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */,
|
||||||
4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */,
|
4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */,
|
||||||
|
B82A0C2D26B7B45200C1BCE3 /* CallKitCallManager.swift in Sources */,
|
||||||
C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */,
|
C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */,
|
||||||
|
B82A0C2426B7B45200C1BCE3 /* GroupCallTooltip.swift in Sources */,
|
||||||
|
B82A0C1E26B7B45200C1BCE3 /* GroupCallMemberSheet.swift in Sources */,
|
||||||
340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */,
|
340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */,
|
||||||
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */,
|
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */,
|
||||||
B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */,
|
B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */,
|
||||||
|
@ -5008,11 +5117,13 @@
|
||||||
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */,
|
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */,
|
||||||
B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */,
|
B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */,
|
||||||
3488F9362191CC4000E524CC /* MediaView.swift in Sources */,
|
3488F9362191CC4000E524CC /* MediaView.swift in Sources */,
|
||||||
|
B82A0C2526B7B45200C1BCE3 /* GroupCallMemberView.swift in Sources */,
|
||||||
B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */,
|
B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */,
|
||||||
3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */,
|
3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */,
|
||||||
C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */,
|
C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */,
|
||||||
34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */,
|
34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */,
|
||||||
C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */,
|
C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */,
|
||||||
|
B82A0C2326B7B45200C1BCE3 /* GroupCallErrorView.swift in Sources */,
|
||||||
4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */,
|
4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */,
|
||||||
3496957021A301A100DCFE74 /* OWSBackupIO.m in Sources */,
|
3496957021A301A100DCFE74 /* OWSBackupIO.m in Sources */,
|
||||||
B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */,
|
B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */,
|
||||||
|
@ -5020,12 +5131,14 @@
|
||||||
76EB054018170B33006006FC /* AppDelegate.m in Sources */,
|
76EB054018170B33006006FC /* AppDelegate.m in Sources */,
|
||||||
340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */,
|
340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */,
|
||||||
C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */,
|
C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */,
|
||||||
|
B82A0C2A26B7B45200C1BCE3 /* CallControls.swift in Sources */,
|
||||||
C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */,
|
C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */,
|
||||||
B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */,
|
B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */,
|
||||||
C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */,
|
C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */,
|
||||||
C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */,
|
C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */,
|
||||||
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
|
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
|
||||||
340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */,
|
340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */,
|
||||||
|
B82A0C2E26B7B45200C1BCE3 /* CallKitCallUIAdaptee.swift in Sources */,
|
||||||
B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */,
|
B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */,
|
||||||
B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */,
|
B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */,
|
||||||
B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */,
|
B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */,
|
||||||
|
@ -5037,6 +5150,7 @@
|
||||||
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */,
|
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */,
|
||||||
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */,
|
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */,
|
||||||
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
|
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
|
||||||
|
B82A0C2726B7B45200C1BCE3 /* LocalVideoView.swift in Sources */,
|
||||||
45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */,
|
45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */,
|
||||||
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
|
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
|
||||||
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
|
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
|
||||||
|
|
|
@ -32,10 +32,10 @@ class GroupCallUpdateMessageHandler: CallServiceObserver, CallObserver, Dependen
|
||||||
func sendUpdateMessageForThread(_ thread: TSGroupThread, eraId: String?) {
|
func sendUpdateMessageForThread(_ thread: TSGroupThread, eraId: String?) {
|
||||||
Logger.info("Sending call update message for thread \(thread.uniqueId)")
|
Logger.info("Sending call update message for thread \(thread.uniqueId)")
|
||||||
|
|
||||||
let updateMessage = OWSOutgoingGroupCallMessage(thread: thread, eraId: eraId)
|
let message = GroupCallUpdateMessage()
|
||||||
let messagePreparer = updateMessage.asPreparer
|
message.eraID = eraId
|
||||||
SDSDatabaseStorage.shared.asyncWrite { writeTx in
|
Storage.write { transaction in
|
||||||
Self.messageSenderJobQueue.add(message: messagePreparer, transaction: writeTx)
|
MessageSender.send(message, in: thread, using: transaction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<ActiveMember>()
|
||||||
|
private var membersPendingJoinNotification = Set<ActiveMember>()
|
||||||
|
private var membersPendingLeaveNotification = Set<ActiveMember>()
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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..<min(maxItems, call.groupCall.remoteDeviceStates.count)]).sortedByAddedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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..<numberOfItems {
|
||||||
|
let indexPath = NSIndexPath(item: index, section: 0)
|
||||||
|
let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath as IndexPath)
|
||||||
|
|
||||||
|
let row = ceil(CGFloat(index + 1) / CGFloat(numberOfColumns)) - 1
|
||||||
|
let yPosition = (row * rowHeight) + vInset + (CGFloat(row) * vSpacing)
|
||||||
|
|
||||||
|
let columnWidth = columnWidthPerRow[Int(row)]
|
||||||
|
|
||||||
|
let column = CGFloat(index % numberOfColumns)
|
||||||
|
let xPosition = (column * columnWidth) + vInset + (CGFloat(column) * vSpacing)
|
||||||
|
|
||||||
|
itemAttributes.frame = CGRect(x: xPosition, y: yPosition, width: columnWidth, height: rowHeight)
|
||||||
|
itemAttributesMap[index] = itemAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
contentSize = collectionView.frame.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutAttributesForElements(in rect: CGRect) -> [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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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..<joinedRemoteDeviceStates.count]).sortedByAddedTime.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <WebRTC/RTCVideoRenderer.h>
|
||||||
|
|
||||||
|
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 <RTCVideoRenderer>
|
||||||
|
|
||||||
|
@property (nonatomic) BOOL isGroupCall;
|
||||||
|
@property (nonatomic) BOOL isScreenShare;
|
||||||
|
@property (nonatomic) BOOL isFullScreen;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
|
@ -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 <MetalKit/MetalKit.h>
|
||||||
|
#import <PureLayout/PureLayout.h>
|
||||||
|
#import <SignalCoreKit/Threading.h>
|
||||||
|
#import <SignalMessaging/SignalMessaging-Swift.h>
|
||||||
|
#import <SignalServiceKit/SignalServiceKit-Swift.h>
|
||||||
|
#import <WebRTC/RTCEAGLVideoView.h>
|
||||||
|
#import <WebRTC/RTCMTLVideoView.h>
|
||||||
|
#import <WebRTC/RTCVideoFrame.h>
|
||||||
|
#import <WebRTC/RTCVideoRenderer.h>
|
||||||
|
#import <objc/runtime.h>
|
||||||
|
|
||||||
|
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<RTCVideoRenderer> *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
|
Loading…
Reference in New Issue