diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 70665d1557..f27c0a3e7d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -343,6 +343,7 @@ 3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; }; 3CB9EC9B670C90618B839D1B /* RemotePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69A05E85E4872C3221C5C287 /* RemotePreference.swift */; }; 3CE4C5071B6D2576E2473989 /* OrderedSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62B07B296D7A9D2F09120853 /* OrderedSet.swift */; }; + 3D0DAED550E967AB49F1758C /* CXProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */; }; 3D72F5F9109AAA257542456B /* CallInviteRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 664ABD745A746C45CB842158 /* CallInviteRoomTimelineView.swift */; }; 3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DC38E64A5ED3FDB201029A /* BugReportService.swift */; }; 3DAD62988F072607441CB7A5 /* PollFormScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F8664F1FB95AF3C4202478 /* PollFormScreenCoordinator.swift */; }; @@ -469,6 +470,7 @@ 53C1E7F6A7D6409D89F36ED7 /* AggregatedReactionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */; }; 53DEF39F0C4DE02E3FC56D91 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 800631D7250B7F93195035F1 /* KeychainAccess */; }; 53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */; }; + 5470E62F65AE1803BBF3D528 /* CXProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E1BAA7232081635662A83F /* CXProviderMock.swift */; }; 54AE8860D668AFD96E7E177B /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 54FDA3625AACBD9E438D084D /* BlurEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07934EF08BB39353E4A94272 /* BlurEffectView.swift */; }; 5518DA4A6C9B4FC4B497EA9A /* LogViewerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B795AAAB7B8747FE2FF311 /* LogViewerScreenModels.swift */; }; @@ -1005,6 +1007,7 @@ B5618E3C948584E5C1F67033 /* DTHTMLElement+AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */; }; B5899F18AD6C56CE08FE532B /* RoomSummaryProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */; }; B5BCE012F9E7C45D1C76108E /* RoomMembersListScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2520C4F33AA0C061D209C28 /* RoomMembersListScreenTests.swift */; }; + B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */ = {isa = PBXBuildFile; productRef = FFA423B0A7BBD8AA9BB91AB0 /* Clocks */; }; B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; }; B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; B6064D82FCDCB829601C1F59 /* SecureBackupLogoutConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */; }; @@ -1183,6 +1186,7 @@ D63974A88CF2BC721F109C77 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = DCA3C4A997AD28E6918D4CE5 /* Compound */; }; D6DE764B17FB4A9A12C33BF4 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */; }; D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; + D820B3C223E4C2E77BB2A2BF /* ElementCallServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */; }; D8459AAD6969B1431ECBE990 /* UnsupportedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E535B3388755B65C34CD10 /* UnsupportedRoomTimelineView.swift */; }; D8517B8EED58D24396FB71E7 /* DeactivateAccountScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BAE25A0E9E9F2F1BBA8930 /* DeactivateAccountScreenViewModel.swift */; }; D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */; }; @@ -1982,6 +1986,7 @@ 5D53754227CEBD06358956D7 /* PinnedEventsTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenCoordinator.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5E33FD32BBC44D703C7AE4F9 /* TextBasedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineItem.swift; sourceTree = ""; }; + 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = ""; }; 5E6DE144D887A254F4CAF203 /* UserPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreference.swift; sourceTree = ""; }; 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = ""; }; 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; @@ -2130,6 +2135,7 @@ 7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 7DDF49CEBC0DFC59C308335F /* RoomMemberDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModelProtocol.swift; sourceTree = ""; }; 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenViewModelProtocol.swift; sourceTree = ""; }; + 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceTests.swift; sourceTree = ""; }; 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInviterLabel.swift; sourceTree = ""; }; 7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelTests.swift; sourceTree = ""; }; 7F615A00DB223FF3280204D2 /* UserDiscoveryServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceProtocol.swift; sourceTree = ""; }; @@ -2186,6 +2192,7 @@ 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModel.swift; sourceTree = ""; }; 86C8CE2630F54D5FE1591786 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; 86D7CD5CA270BFC3EBB450CA /* PinnedEventsTimelineScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenViewModel.swift; sourceTree = ""; }; + 86E1BAA7232081635662A83F /* CXProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderMock.swift; sourceTree = ""; }; 87FC42213E86E8182CFD3A49 /* preview_avatar_user.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = preview_avatar_user.jpg; sourceTree = ""; }; 88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreen.swift; sourceTree = ""; }; 8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreen.swift; sourceTree = ""; }; @@ -2835,6 +2842,7 @@ files = ( 7FF27DA70D833CFC5724EFC5 /* MatrixRustSDK in Frameworks */, BCA5E2157CE27AB6F1D043D3 /* AsyncAlgorithms in Frameworks */, + B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3486,6 +3494,7 @@ 8F7FC9580CABF797A2E6213A /* BugReportServiceMock.swift */, E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */, 4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */, + 86E1BAA7232081635662A83F /* CXProviderMock.swift */, E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */, 3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */, 867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */, @@ -4533,6 +4542,7 @@ DEBB74427E24AF30CDB131B7 /* DeferredFulfillmentTests.swift */, 6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */, 906451FB8CF27C628152BF7A /* EditRoomAddressScreenViewModelTests.swift */, + 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */, A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */, 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */, 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */, @@ -5099,6 +5109,7 @@ 92E99C57D7F92ED16F73282C /* ElementCall */ = { isa = PBXGroup; children = ( + 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */, CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */, 33AE897D86784CCA5E4E9227 /* ElementCallService.swift */, 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */, @@ -6598,6 +6609,7 @@ packageProductDependencies = ( C07EA60CAB296D7726210F5B /* MatrixRustSDK */, 5A8EF1A5F9629FCA309D4B2A /* AsyncAlgorithms */, + FFA423B0A7BBD8AA9BB91AB0 /* Clocks */, ); productName = UnitTests; productReference = AAC9344689121887B74877AF /* UnitTests.xctest */; @@ -6850,6 +6862,7 @@ E025F19D013D9BA6C58B37F4 /* XCRemoteSwiftPackageReference "swift-algorithms" */, AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */, 4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */, F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */, F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */, 4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */, @@ -7264,6 +7277,7 @@ A583B70939707197B0B21DFC /* DeferredFulfillmentTests.swift in Sources */, 864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */, EDB6915EC953BB2A44AA608E /* EditRoomAddressScreenViewModelTests.swift in Sources */, + D820B3C223E4C2E77BB2A2BF /* ElementCallServiceTests.swift in Sources */, 7AE25D29734267271106D732 /* EmojiPickerScreenViewModelTests.swift in Sources */, 25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */, 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */, @@ -7577,6 +7591,8 @@ 172E6E9A612ADCF10A62CF13 /* BugReportServiceProtocol.swift in Sources */, E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */, 6BAD956B909A6E29F6CC6E7C /* ButtonStyle.swift in Sources */, + 5470E62F65AE1803BBF3D528 /* CXProviderMock.swift in Sources */, + 3D0DAED550E967AB49F1758C /* CXProviderProtocol.swift in Sources */, 01B63F1A04A276B39AC17014 /* CallInviteRoomTimelineItem.swift in Sources */, 3D72F5F9109AAA257542456B /* CallInviteRoomTimelineView.swift in Sources */, E5AB28123E2488F97E953AC0 /* CallNotificationRoomTimelineItem.swift in Sources */, @@ -9349,6 +9365,14 @@ minimumVersion = 1.4.2; }; }; + 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-clocks"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.6; + }; + }; 91740346377FEBEAF7AD32FC /* XCRemoteSwiftPackageReference "swift-mutex" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/swhitty/swift-mutex"; @@ -9820,6 +9844,11 @@ package = AB8E808A59756170682BEC20 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; + FFA423B0A7BBD8AA9BB91AB0 /* Clocks */ = { + isa = XCSwiftPackageProductDependency; + package = 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */; + productName = Clocks; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AC22997D58D612146053154D /* Project object */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6735960663..3eb5c2b9bb 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9d89a9320c29b16ca97c3b4c1dce15f8ab13ae1a66da50c98eec015db72c30d1", + "originHash" : "d68e23488bdd8328e4d65f4aa0fb826b3aaad601da516473abe6544c1a13c3f0", "pins" : [ { "identity" : "compound-design-tokens", @@ -225,6 +225,15 @@ "version" : "1.0.4" } }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -234,6 +243,15 @@ "version" : "1.2.0" } }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/ElementX/Sources/Mocks/CXProviderMock.swift b/ElementX/Sources/Mocks/CXProviderMock.swift new file mode 100644 index 0000000000..b8f4e3903e --- /dev/null +++ b/ElementX/Sources/Mocks/CXProviderMock.swift @@ -0,0 +1,17 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +extension CXProviderMock { + struct Configuration { } + + convenience init(_ configuration: Configuration) { + self.init() + reportNewIncomingCallWithUpdateCompletionClosure = { _, _, completion in + completion(nil) + } + } +} diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 01974a033e..59cf73510b 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -9,6 +9,7 @@ import AnalyticsEvents import AVFoundation +import CallKit import Foundation import LocalAuthentication import Photos @@ -2066,6 +2067,132 @@ class BugReportServiceMock: BugReportServiceProtocol, @unchecked Sendable { } } } +class CXProviderMock: CXProviderProtocol, @unchecked Sendable { + + //MARK: - setDelegate + + var setDelegateQueueUnderlyingCallsCount = 0 + var setDelegateQueueCallsCount: Int { + get { + if Thread.isMainThread { + return setDelegateQueueUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = setDelegateQueueUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + setDelegateQueueUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + setDelegateQueueUnderlyingCallsCount = newValue + } + } + } + } + var setDelegateQueueCalled: Bool { + return setDelegateQueueCallsCount > 0 + } + var setDelegateQueueReceivedArguments: (delegate: CXProviderDelegate?, queue: DispatchQueue?)? + var setDelegateQueueReceivedInvocations: [(delegate: CXProviderDelegate?, queue: DispatchQueue?)] = [] + var setDelegateQueueClosure: ((CXProviderDelegate?, DispatchQueue?) -> Void)? + + func setDelegate(_ delegate: CXProviderDelegate?, queue: DispatchQueue?) { + setDelegateQueueCallsCount += 1 + setDelegateQueueReceivedArguments = (delegate: delegate, queue: queue) + DispatchQueue.main.async { + self.setDelegateQueueReceivedInvocations.append((delegate: delegate, queue: queue)) + } + setDelegateQueueClosure?(delegate, queue) + } + //MARK: - reportNewIncomingCall + + var reportNewIncomingCallWithUpdateCompletionUnderlyingCallsCount = 0 + var reportNewIncomingCallWithUpdateCompletionCallsCount: Int { + get { + if Thread.isMainThread { + return reportNewIncomingCallWithUpdateCompletionUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = reportNewIncomingCallWithUpdateCompletionUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + reportNewIncomingCallWithUpdateCompletionUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + reportNewIncomingCallWithUpdateCompletionUnderlyingCallsCount = newValue + } + } + } + } + var reportNewIncomingCallWithUpdateCompletionCalled: Bool { + return reportNewIncomingCallWithUpdateCompletionCallsCount > 0 + } + var reportNewIncomingCallWithUpdateCompletionReceivedArguments: (uuid: UUID, update: CXCallUpdate, completion: (Error?) -> Void)? + var reportNewIncomingCallWithUpdateCompletionReceivedInvocations: [(uuid: UUID, update: CXCallUpdate, completion: (Error?) -> Void)] = [] + var reportNewIncomingCallWithUpdateCompletionClosure: ((UUID, CXCallUpdate, @escaping (Error?) -> Void) -> Void)? + + func reportNewIncomingCall(with uuid: UUID, update: CXCallUpdate, completion: @escaping (Error?) -> Void) { + reportNewIncomingCallWithUpdateCompletionCallsCount += 1 + reportNewIncomingCallWithUpdateCompletionReceivedArguments = (uuid: uuid, update: update, completion: completion) + DispatchQueue.main.async { + self.reportNewIncomingCallWithUpdateCompletionReceivedInvocations.append((uuid: uuid, update: update, completion: completion)) + } + reportNewIncomingCallWithUpdateCompletionClosure?(uuid, update, completion) + } + //MARK: - reportCall + + var reportCallWithEndedAtReasonUnderlyingCallsCount = 0 + var reportCallWithEndedAtReasonCallsCount: Int { + get { + if Thread.isMainThread { + return reportCallWithEndedAtReasonUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = reportCallWithEndedAtReasonUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + reportCallWithEndedAtReasonUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + reportCallWithEndedAtReasonUnderlyingCallsCount = newValue + } + } + } + } + var reportCallWithEndedAtReasonCalled: Bool { + return reportCallWithEndedAtReasonCallsCount > 0 + } + var reportCallWithEndedAtReasonReceivedArguments: (uuid: UUID, endedAt: Date?, reason: CXCallEndedReason)? + var reportCallWithEndedAtReasonReceivedInvocations: [(uuid: UUID, endedAt: Date?, reason: CXCallEndedReason)] = [] + var reportCallWithEndedAtReasonClosure: ((UUID, Date?, CXCallEndedReason) -> Void)? + + func reportCall(with uuid: UUID, endedAt: Date?, reason: CXCallEndedReason) { + reportCallWithEndedAtReasonCallsCount += 1 + reportCallWithEndedAtReasonReceivedArguments = (uuid: uuid, endedAt: endedAt, reason: reason) + DispatchQueue.main.async { + self.reportCallWithEndedAtReasonReceivedInvocations.append((uuid: uuid, endedAt: endedAt, reason: reason)) + } + reportCallWithEndedAtReasonClosure?(uuid, endedAt, reason) + } +} class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { var actionsPublisher: AnyPublisher { get { return underlyingActionsPublisher } diff --git a/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift b/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift new file mode 100644 index 0000000000..ec72266c67 --- /dev/null +++ b/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift @@ -0,0 +1,17 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import CallKit + +// sourcery: AutoMockable +protocol CXProviderProtocol { + func setDelegate(_ delegate: CXProviderDelegate?, queue: DispatchQueue?) + func reportNewIncomingCall(with uuid: UUID, update: CXCallUpdate, completion: @escaping (Error?) -> Void) + func reportCall(with uuid: UUID, endedAt: Date?, reason: CXCallEndedReason) +} + +extension CXProvider: CXProviderProtocol { } diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index ef00e7c5aa..95a22395c7 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -13,6 +13,12 @@ import MatrixRustSDK import PushKit import UIKit +// Keep this class testable +struct Time { + var clock: any Clock + var nowDate: () -> Date +} + class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate { private struct CallID: Equatable { let callKitID: UUID @@ -22,20 +28,8 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private let pushRegistry: PKPushRegistry private let callController = CXCallController() - private let callProvider: CXProvider = { - let configuration = CXProviderConfiguration() - configuration.supportsVideo = true - configuration.includesCallsInRecents = true - - if let callKitIcon = UIImage(named: "images/app-logo") { - configuration.iconTemplateImageData = callKitIcon.pngData() - } - - // https://stackoverflow.com/a/46077628/730924 - configuration.supportedHandleTypes = [.generic] - - return CXProvider(configuration: configuration) - }() + private let callProvider: CXProviderProtocol + private let timeClock: Time private weak var clientProxy: ClientProxyProtocol? { didSet { @@ -71,15 +65,34 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var declineListenerHandle: TaskHandle? - override init() { + init(callProvider: CXProviderProtocol? = nil, timeClock: Time? = nil) { pushRegistry = PKPushRegistry(queue: nil) + self.timeClock = timeClock ?? Time(clock: ContinuousClock(), nowDate: Date.init) + + if let callProvider { + self.callProvider = callProvider + } else { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.includesCallsInRecents = true + + if let callKitIcon = UIImage(named: "images/app-logo") { + configuration.iconTemplateImageData = callKitIcon.pngData() + } + + // https://stackoverflow.com/a/46077628/730924 + configuration.supportedHandleTypes = [.generic] + + self.callProvider = CXProvider(configuration: configuration) + } + super.init() pushRegistry.delegate = self pushRegistry.desiredPushTypes = [.voIP] - callProvider.setDelegate(self, queue: nil) + self.callProvider.setDelegate(self, queue: nil) } func setClientProxy(_ clientProxy: any ClientProxyProtocol) { @@ -163,6 +176,19 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID) incomingCallID = callID + guard let expirationTimestamp = (payload.dictionaryPayload[ElementCallServiceNotificationKey.expirationTimestampMillis.rawValue] as? NSNumber)?.uint64Value else { + MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)") + return + } + let nowTimestampMillis = UInt64(timeClock.nowDate().timeIntervalSince1970 * 1000) + + guard nowTimestampMillis < expirationTimestamp else { + MXLog.warning("Call expired for room \(roomID), ignoring incoming push") + return + } + + let ringDurationMillis = min(expirationTimestamp - nowTimestampMillis, 90000) + let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String let update = CXCallUpdate() @@ -182,7 +208,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe } endUnansweredCallTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(90)) + try? await self?.timeClock.clock.sleep(for: .milliseconds(ringDurationMillis)) guard let self, !Task.isCancelled else { return diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceConstants.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceConstants.swift index 34e2f8b365..06747b90fd 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceConstants.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceConstants.swift @@ -13,6 +13,8 @@ enum ElementCallServiceNotificationKey: String { /// When an incoming call is set to ring, there will be a `m.rtc.notification`event (MSC4075). /// Keep the notification event id as it is needed to decline calls (MSC4310). case rtcNotifyEventID + /// The local timestamp in millis at which the incoming call should stop ringing. + case expirationTimestampMillis } let ElementCallServiceNotificationDiscardDelta = 15.0 diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index bfa8d28724..473f6a4ab9 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -124,10 +124,11 @@ class NotificationHandler { } return .processedShouldDiscard - case .rtcNotification(let notificationType, _): + case .rtcNotification(let notificationType, let expirationTimestamp): return await handleCallNotification(notificationType: notificationType, rtcNotifyEventID: event.eventId(), timestamp: event.timestamp(), + expirationTimestamp: expirationTimestamp, roomID: itemProxy.roomID, roomDisplayName: itemProxy.roomDisplayName) case .callAnswer, @@ -156,6 +157,7 @@ class NotificationHandler { private func handleCallNotification(notificationType: RtcNotificationType, rtcNotifyEventID: String, timestamp: Timestamp, + expirationTimestamp: Timestamp, roomID: String, roomDisplayName: String) async -> NotificationProcessingResult { // Handle incoming VoIP calls, show the native OS call screen @@ -209,7 +211,8 @@ class NotificationHandler { let payload = [ElementCallServiceNotificationKey.roomID.rawValue: roomID, ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName, - ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID] + ElementCallServiceNotificationKey.expirationTimestampMillis.rawValue: expirationTimestamp, + ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID] as [String: Any] do { try await CXProvider.reportNewIncomingVoIPPushPayload(payload) diff --git a/Tools/Sourcery/AutoMockable.stencil b/Tools/Sourcery/AutoMockable.stencil index e45d6a494d..d9e70b0851 100644 --- a/Tools/Sourcery/AutoMockable.stencil +++ b/Tools/Sourcery/AutoMockable.stencil @@ -6,6 +6,7 @@ import AnalyticsEvents import AVFoundation +import CallKit import Foundation import LocalAuthentication import Photos diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift new file mode 100644 index 0000000000..0ca182638d --- /dev/null +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -0,0 +1,131 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Clocks +import PushKit +import XCTest + +@testable import ElementX + +@MainActor +class ElementCallServiceTests: XCTestCase { + var callProvider: CXProviderMock! + var currentDate: Date! + var testClock: TestClock! + let pushRegistry = PKPushRegistry(queue: nil) + + var service: ElementCallService! + + func testIncomingCall() async { + setupService() + + XCTAssertFalse(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + + let expectation = XCTestExpectation(description: "Call accepted") + + let pkPushPayloadMock = PKPushPayloadMock().addSeconds(currentDate, lifetime: 30) + + service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) { + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 1) + XCTAssertTrue(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + } + + func testCallIsTimingOut() async { + setupService() + + XCTAssertFalse(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + + let expectation = XCTestExpectation(description: "Call accepted") + + let pushPayload = PKPushPayloadMock().addSeconds(currentDate, lifetime: 20) + + service.pushRegistry(pushRegistry, + didReceiveIncomingPushWith: pushPayload, + for: .voIP) { + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 1) + + // advance past the timeout + await testClock.advance(by: .seconds(30)) + + // Call should have ended with unanswered + XCTAssertTrue(callProvider.reportCallWithEndedAtReasonCalled) + XCTAssertEqual(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason, .unanswered) + } + + func testExpiredRingLifetimeIsIgnored() async { + setupService() + + XCTAssertFalse(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + + let pushPayload = PKPushPayloadMock().addSeconds(currentDate, lifetime: 20) + + currentDate = currentDate.addingTimeInterval(60) + + service.pushRegistry(pushRegistry, + didReceiveIncomingPushWith: pushPayload, + for: .voIP) { } + sleep(20) + + XCTAssertTrue(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + } + + func testLifetimeIsCapped() async { + setupService() + + XCTAssertFalse(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + + let pushPayload = PKPushPayloadMock().addSeconds(currentDate, lifetime: 300) + + service.pushRegistry(pushRegistry, + didReceiveIncomingPushWith: pushPayload, + for: .voIP) { } + + // advance pass the max timeout but below the 300 + await testClock.advance(by: .seconds(100)) + + // Call should have ended with unanswered + XCTAssertTrue(callProvider.reportCallWithEndedAtReasonCalled) + XCTAssertEqual(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason, .unanswered) + } + + // MARK: - Helpers + + private func setupService() { + callProvider = CXProviderMock(.init()) + currentDate = Date() + testClock = TestClock() + let dateProvider: () -> Date = { + self.currentDate + } + service = ElementCallService(callProvider: callProvider, timeClock: Time(clock: testClock, nowDate: dateProvider)) + } +} + +private class PKPushPayloadMock: PKPushPayload { + var dict: [AnyHashable: Any] = [:] + + override init() { + dict[ElementCallServiceNotificationKey.roomID.rawValue] = "!room:example.com" + dict[ElementCallServiceNotificationKey.roomDisplayName.rawValue] = "welcome" + dict[ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue] = "$000" + dict[ElementCallServiceNotificationKey.expirationTimestampMillis.rawValue] = 10 + } + + override var dictionaryPayload: [AnyHashable: Any] { + dict + } + + func addSeconds(_ from: Date, lifetime: Int) -> Self { + dict[ElementCallServiceNotificationKey.expirationTimestampMillis.rawValue] = UInt64(from.timeIntervalSince1970 * 1000) + UInt64(lifetime) + return self + } +} diff --git a/UnitTests/SupportingFiles/target.yml b/UnitTests/SupportingFiles/target.yml index f1c7887901..a26efd5b7e 100644 --- a/UnitTests/SupportingFiles/target.yml +++ b/UnitTests/SupportingFiles/target.yml @@ -33,6 +33,7 @@ targets: - target: ElementX - package: MatrixRustSDK - package: AsyncAlgorithms + - package: Clocks info: path: ../SupportingFiles/Info.plist diff --git a/project.yml b/project.yml index 634eeee623..0e18acb02d 100644 --- a/project.yml +++ b/project.yml @@ -101,6 +101,9 @@ packages: AsyncAlgorithms: url: https://github.com/apple/swift-async-algorithms minorVersion: 1.0.0 + Clocks: + url: https://github.com/pointfreeco/swift-clocks + from: 1.0.6 Collections: url: https://github.com/apple/swift-collections minorVersion: 1.2.0