mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user