MSC4075 Use expirationTS to define the call ringing window (#4652)

* Listen to call decline to stop ringing when declined from other device

* MSC4075 Use expirationTS to define the call ringing window

* Implement ElementCallService tests.

* Update acknowledgements.
This commit is contained in:
Valere Fedronic
2025-11-12 13:59:09 +01:00
committed by GitHub
parent d04a074556
commit c75353a903
15 changed files with 485 additions and 19 deletions

View File

@@ -344,6 +344,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 */; };
@@ -1014,6 +1016,7 @@
B5479997ECC516C121E6625E /* LocationMarkerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFECCE59967018204876D0A5 /* LocationMarkerView.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 */; };
@@ -1195,6 +1198,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 */; };
@@ -2003,6 +2007,7 @@
5E33FD32BBC44D703C7AE4F9 /* TextBasedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineItem.swift; sourceTree = "<group>"; };
5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = "<group>"; };
5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceViewModel.swift; sourceTree = "<group>"; };
5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = "<group>"; };
5E6DE144D887A254F4CAF203 /* UserPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreference.swift; sourceTree = "<group>"; };
5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = "<group>"; };
5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = "<group>"; };
@@ -2155,6 +2160,7 @@
7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
7DDF49CEBC0DFC59C308335F /* RoomMemberDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenViewModelProtocol.swift; sourceTree = "<group>"; };
7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceTests.swift; sourceTree = "<group>"; };
7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInviterLabel.swift; sourceTree = "<group>"; };
7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelTests.swift; sourceTree = "<group>"; };
7F615A00DB223FF3280204D2 /* UserDiscoveryServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceProtocol.swift; sourceTree = "<group>"; };
@@ -2212,6 +2218,7 @@
86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModel.swift; sourceTree = "<group>"; };
86C8CE2630F54D5FE1591786 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = "<group>"; };
86D7CD5CA270BFC3EBB450CA /* PinnedEventsTimelineScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenViewModel.swift; sourceTree = "<group>"; };
86E1BAA7232081635662A83F /* CXProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderMock.swift; sourceTree = "<group>"; };
87FC42213E86E8182CFD3A49 /* preview_avatar_user.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = preview_avatar_user.jpg; sourceTree = "<group>"; };
88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreen.swift; sourceTree = "<group>"; };
8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreen.swift; sourceTree = "<group>"; };
@@ -2872,6 +2879,7 @@
files = (
7FF27DA70D833CFC5724EFC5 /* MatrixRustSDK in Frameworks */,
BCA5E2157CE27AB6F1D043D3 /* AsyncAlgorithms in Frameworks */,
B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3530,6 +3538,7 @@
8F7FC9580CABF797A2E6213A /* BugReportServiceMock.swift */,
E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */,
4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */,
86E1BAA7232081635662A83F /* CXProviderMock.swift */,
E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */,
3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */,
867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */,
@@ -4606,6 +4615,7 @@
DEBB74427E24AF30CDB131B7 /* DeferredFulfillmentTests.swift */,
6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */,
906451FB8CF27C628152BF7A /* EditRoomAddressScreenViewModelTests.swift */,
7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */,
A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */,
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */,
84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */,
@@ -5183,6 +5193,7 @@
92E99C57D7F92ED16F73282C /* ElementCall */ = {
isa = PBXGroup;
children = (
5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */,
CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */,
33AE897D86784CCA5E4E9227 /* ElementCallService.swift */,
406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */,
@@ -6709,6 +6720,7 @@
packageProductDependencies = (
C07EA60CAB296D7726210F5B /* MatrixRustSDK */,
5A8EF1A5F9629FCA309D4B2A /* AsyncAlgorithms */,
FFA423B0A7BBD8AA9BB91AB0 /* Clocks */,
);
productName = UnitTests;
productReference = AAC9344689121887B74877AF /* UnitTests.xctest */;
@@ -6959,6 +6971,7 @@
E025F19D013D9BA6C58B37F4 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */,
4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */,
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */,
4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */,
D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */,
@@ -7371,6 +7384,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 */,
@@ -7684,6 +7698,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 */,
@@ -9484,6 +9500,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";
@@ -9916,6 +9940,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 */;

View File

@@ -1,4 +1,5 @@
{
"originHash" : "d68e23488bdd8328e4d65f4aa0fb826b3aaad601da516473abe6544c1a13c3f0",
"pins" : [
{
"identity" : "compound-design-tokens",
@@ -216,6 +217,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",
@@ -225,6 +235,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",

View File

@@ -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)
}
}
}

View File

@@ -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<ClientProxyAction, Never> {
get { return underlyingActionsPublisher }

View File

@@ -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 { }

View File

@@ -14,6 +14,12 @@ import MatrixRustSDK
import PushKit
import UIKit
// Keep this class testable
struct TimeProvider {
var clock: any Clock<Duration>
var now: () -> Date
}
class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate {
private struct CallID: Equatable {
let callKitID: UUID
@@ -23,20 +29,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 timeProvider: TimeProvider
private weak var clientProxy: ClientProxyProtocol? {
didSet {
@@ -72,15 +66,34 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
private var declineListenerHandle: TaskHandle?
override init() {
init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil) {
pushRegistry = PKPushRegistry(queue: nil)
self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: 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) {
@@ -164,6 +177,20 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID)
incomingCallID = callID
guard let expirationDate = (payload.dictionaryPayload[ElementCallServiceNotificationKey.expirationDate.rawValue] as? Date) else {
MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)")
return
}
let nowDate = timeProvider.now()
guard nowDate < expirationDate else {
MXLog.warning("Call expired for room \(roomID), ignoring incoming push")
return
}
let ringDuration: Duration = .seconds(min(expirationDate.timeIntervalSince1970 - nowDate.timeIntervalSince1970, 90))
let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String
let update = CXCallUpdate()
@@ -183,7 +210,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
}
endUnansweredCallTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(90))
try? await self?.timeProvider.clock.sleep(for: ringDuration)
guard let self, !Task.isCancelled else {
return

View File

@@ -14,6 +14,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 Date at which the incoming call should stop ringing.
case expirationDate
}
let ElementCallServiceNotificationDiscardDelta = 15.0

View File

@@ -178,6 +178,14 @@
<key>Type</key>
<string>PSChildPaneSpecifier</string>
</dict>
<dict>
<key>File</key>
<string>Packages/swift-clocks</string>
<key>Title</key>
<string>swift-clocks</string>
<key>Type</key>
<string>PSChildPaneSpecifier</string>
</dict>
<dict>
<key>File</key>
<string>Packages/swift-collections</string>
@@ -186,6 +194,14 @@
<key>Type</key>
<string>PSChildPaneSpecifier</string>
</dict>
<dict>
<key>File</key>
<string>Packages/swift-concurrency-extras</string>
<key>Title</key>
<string>swift-concurrency-extras</string>
<key>Type</key>
<string>PSChildPaneSpecifier</string>
</dict>
<dict>
<key>File</key>
<string>Packages/swift-custom-dump</string>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreferenceSpecifiers</key>
<array>
<dict>
<key>FooterText</key>
<string>MIT License
Copyright (c) 2022 Point-Free
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreferenceSpecifiers</key>
<array>
<dict>
<key>FooterText</key>
<string>MIT License
Copyright (c) 2023 Point-Free
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
</array>
</dict>
</plist>

View File

@@ -125,10 +125,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,
@@ -157,6 +158,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
@@ -208,9 +210,11 @@ class NotificationHandler {
}
}
let expirationDate = Date(timeIntervalSince1970: TimeInterval(expirationTimestamp / 1000))
let payload = [ElementCallServiceNotificationKey.roomID.rawValue: roomID,
ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName,
ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID]
ElementCallServiceNotificationKey.expirationDate.rawValue: expirationDate,
ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID] as [String: Any]
do {
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)

View File

@@ -6,6 +6,7 @@
import AnalyticsEvents
import AVFoundation
import CallKit
import Foundation
import LocalAuthentication
import Photos

View File

@@ -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<Duration>!
let pushRegistry = PKPushRegistry(queue: nil)
var service: ElementCallService!
func testIncomingCall() async {
setupService()
XCTAssertFalse(callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
let expectation = XCTestExpectation(description: "Call accepted")
let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(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().updatingExpiration(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().updatingExpiration(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().updatingExpiration(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, timeProvider: TimeProvider(clock: testClock, now: 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.expirationDate.rawValue] = Date(timeIntervalSince1970: 10)
}
override var dictionaryPayload: [AnyHashable: Any] {
dict
}
func updatingExpiration(_ from: Date, lifetime: TimeInterval) -> Self {
dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = from.addingTimeInterval(lifetime)
return self
}
}

View File

@@ -33,6 +33,7 @@ targets:
- target: ElementX
- package: MatrixRustSDK
- package: AsyncAlgorithms
- package: Clocks
info:
path: ../SupportingFiles/Info.plist

View File

@@ -102,6 +102,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