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 91dabf96eb
commit cd411b57e0
15 changed files with 485 additions and 19 deletions
@@ -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)
}
}
}
@@ -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 }
@@ -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 { }
@@ -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
@@ -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