mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
feat(healthchat): ring calls via gateway VoIP push
CallKit rejects the NSE hand-off without the filtering entitlement.
This commit is contained in:
@@ -15,6 +15,27 @@ import Foundation
|
|||||||
import Macros
|
import Macros
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Common settings between app and NSE
|
||||||
|
nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable {
|
||||||
|
var lastNotificationBootTime: TimeInterval? { get set }
|
||||||
|
var selectedNotificationTone: NotificationTone? { get set }
|
||||||
|
|
||||||
|
var logLevel: LogLevel { get }
|
||||||
|
var traceLogPacks: Set<TraceLogPack> { get }
|
||||||
|
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
|
||||||
|
var contentScannerURL: RemotePreference<URL?> { get }
|
||||||
|
var forceDisableE2EE: RemotePreference<Bool> { get }
|
||||||
|
var mapTilerConfiguration: RemotePreference<MapTilerConfiguration> { get }
|
||||||
|
|
||||||
|
var enableOnlySignedDeviceIsolationMode: Bool { get }
|
||||||
|
var threadsEnabled: Bool { get }
|
||||||
|
var hideQuietNotificationAlerts: Bool { get }
|
||||||
|
|
||||||
|
var voipPusherAppID: String { get }
|
||||||
|
var pushKitDeviceToken: String? { get }
|
||||||
|
var voipPushGatewayNotifyURL: String? { get }
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated enum AppBuildType {
|
nonisolated enum AppBuildType {
|
||||||
case debug
|
case debug
|
||||||
case nightly
|
case nightly
|
||||||
@@ -51,10 +72,15 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
store.reset()
|
store.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetSessionSpecificSettings() {
|
||||||
|
MXLog.warning("Resetting the user session specific AppSettings.")
|
||||||
|
resetHasRunIdentityConfirmationOnboarding()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Hooks
|
// MARK: - Hooks
|
||||||
|
|
||||||
// swiftlint:disable:next function_parameter_count
|
// swiftlint:disable:next function_parameter_count
|
||||||
func override(accountProviders: [AccountProvider],
|
func override(accountProviders: [String],
|
||||||
allowOtherAccountProviders: Bool,
|
allowOtherAccountProviders: Bool,
|
||||||
hideBrandChrome: Bool,
|
hideBrandChrome: Bool,
|
||||||
pushGatewayBaseURL: URL,
|
pushGatewayBaseURL: URL,
|
||||||
@@ -115,15 +141,11 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: true)
|
@UserPreference(defaultValue: true)
|
||||||
var hasSeenNewSoundBanner: Bool
|
var hasSeenNewSoundBanner: Bool
|
||||||
|
|
||||||
/// Defaults to `true` so new users never see it. Developer options arms it for now, an upgrade migration will at launch.
|
|
||||||
@UserPreference(defaultValue: true)
|
|
||||||
var hasSeenMultiAccountAnnouncement: Bool
|
|
||||||
|
|
||||||
/// The initial set of account providers shown to the user in the authentication flow.
|
/// The initial set of account providers shown to the user in the authentication flow.
|
||||||
///
|
///
|
||||||
/// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should
|
/// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should
|
||||||
/// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`.
|
/// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`.
|
||||||
private(set) var accountProviders: [AccountProvider] = [.managed(serverName: "matrix.org", baseURL: "https://matrix-client.matrix.org")]
|
private(set) var accountProviders = ["matrix.org"]
|
||||||
/// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`.
|
/// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`.
|
||||||
private(set) var allowOtherAccountProviders = true
|
private(set) var allowOtherAccountProviders = true
|
||||||
/// Whether the components surrounding the app brand/logo should be hidden or not
|
/// Whether the components surrounding the app brand/logo should be hidden or not
|
||||||
@@ -131,8 +153,6 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
|
|
||||||
/// The task identifier used for background app refresh. Also used in main target's the Info.plist
|
/// The task identifier used for background app refresh. Also used in main target's the Info.plist
|
||||||
let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh"
|
let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh"
|
||||||
/// The task identifier used for the search index backfill. Also used in main target's the Info.plist
|
|
||||||
let searchBackfillTaskIdentifier = "io.element.elementx.background.search-backfill"
|
|
||||||
|
|
||||||
/// A URL where users can go read more about the app.
|
/// A URL where users can go read more about the app.
|
||||||
private(set) var websiteURL: URL = "https://element.io"
|
private(set) var websiteURL: URL = "https://element.io"
|
||||||
@@ -170,12 +190,8 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(key: "previousServers", defaultValue: [])
|
@UserPreference(key: "previousServers", defaultValue: [])
|
||||||
var previousServers: [String]
|
var previousServers: [String]
|
||||||
|
|
||||||
var defaultAccountProvider: AccountProvider {
|
var defaultServer: String {
|
||||||
if allowOtherAccountProviders {
|
previousServers.first ?? accountProviders[0]
|
||||||
previousServers.first.map { .generic($0) } ?? accountProviders[0]
|
|
||||||
} else {
|
|
||||||
accountProviders[0]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security
|
// MARK: - Security
|
||||||
@@ -232,6 +248,34 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify")
|
pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealthChat: the NSE lacks the notification filtering entitlement, so it cannot hand ringing
|
||||||
|
// calls to CallKit directly. Instead it asks the push gateway for a PushKit (VoIP) push, the
|
||||||
|
// way the legacy element-ios app did. The app persists what the NSE needs for that request
|
||||||
|
// here, because the NSE neither receives PushKit credentials nor runs the app settings hook.
|
||||||
|
|
||||||
|
/// The push gateway app ID that delivers PushKit pushes (`push_type: voip` in Sygnal).
|
||||||
|
var voipPusherAppID: String {
|
||||||
|
#if DEBUG
|
||||||
|
InfoPlistReader.main.baseBundleIdentifier + ".ios.voip.dev"
|
||||||
|
#else
|
||||||
|
InfoPlistReader.main.baseBundleIdentifier + ".ios.voip.prod"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device's PushKit token, base64 encoded like the APNs pushkey.
|
||||||
|
@UserPreference
|
||||||
|
var pushKitDeviceToken: String?
|
||||||
|
|
||||||
|
/// `pushGatewayNotifyEndpoint` as configured in the app, for the NSE.
|
||||||
|
@UserPreference
|
||||||
|
var voipPushGatewayNotifyURL: String?
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: true)
|
||||||
|
var enableNotifications: Bool
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: true)
|
||||||
|
var enableInAppNotifications: Bool
|
||||||
|
|
||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var hideQuietNotificationAlerts: Bool
|
var hideQuietNotificationAlerts: Bool
|
||||||
|
|
||||||
@@ -243,9 +287,9 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference
|
@UserPreference
|
||||||
var lastNotificationBootTime: TimeInterval?
|
var lastNotificationBootTime: TimeInterval?
|
||||||
|
|
||||||
/// The app icon badge value the app last computed from the SDK's unread notification counts.
|
/// The sound played when delivering noisy notifications. If nil, use the ElementX default
|
||||||
@UserPreference(defaultValue: 0)
|
@UserPreference
|
||||||
var lastKnownBadgeCount: Int
|
var selectedNotificationTone: NotificationTone?
|
||||||
|
|
||||||
// MARK: - Logging
|
// MARK: - Logging
|
||||||
|
|
||||||
@@ -304,6 +348,9 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var hasRunNotificationPermissionsOnboarding: Bool
|
var hasRunNotificationPermissionsOnboarding: Bool
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: false)
|
||||||
|
var hasRunIdentityConfirmationOnboarding: Bool
|
||||||
|
|
||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var hasRequestedLocationAlwaysLocationAuthorization: Bool
|
var hasRequestedLocationAlwaysLocationAuthorization: Bool
|
||||||
|
|
||||||
@@ -321,6 +368,14 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var liveLocationDisclaimerDisplayed: Bool
|
var liveLocationDisclaimerDisplayed: Bool
|
||||||
|
|
||||||
|
// MARK: - Home Screen
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: RoomListActivityVisibility.current)
|
||||||
|
var roomListActivityVisibility: RoomListActivityVisibility
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: false)
|
||||||
|
var roomListNotificationCountEnabled: Bool
|
||||||
|
|
||||||
// MARK: - Room Screen
|
// MARK: - Room Screen
|
||||||
|
|
||||||
@UserPreference(defaultValue: AppBuildType.current == .debug)
|
@UserPreference(defaultValue: AppBuildType.current == .debug)
|
||||||
@@ -367,6 +422,11 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
/// The MapTiler configuration used to build map URLs, which defaults to the bundled one.
|
/// The MapTiler configuration used to build map URLs, which defaults to the bundled one.
|
||||||
private(set) var mapTilerConfiguration = RemotePreference(AppSettings.bundledMapTilerConfiguration)
|
private(set) var mapTilerConfiguration = RemotePreference(AppSettings.bundledMapTilerConfiguration)
|
||||||
|
|
||||||
|
// MARK: - Presence
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: true)
|
||||||
|
var sharePresence: Bool
|
||||||
|
|
||||||
// MARK: - Feature Flags
|
// MARK: - Feature Flags
|
||||||
|
|
||||||
/// Others
|
/// Others
|
||||||
@@ -392,7 +452,7 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var messageMultiSelectEnabled: Bool
|
var messageMultiSelectEnabled: Bool
|
||||||
|
|
||||||
@UserPreference(key: "globalSearchEnabledV2", defaultValue: true)
|
@UserPreference(defaultValue: ProcessInfo().isiOSAppOnMac)
|
||||||
var globalSearchEnabled: Bool
|
var globalSearchEnabled: Bool
|
||||||
|
|
||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
@@ -412,32 +472,22 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var linkNewDeviceEnabled: Bool
|
var linkNewDeviceEnabled: Bool
|
||||||
|
|
||||||
|
@UserPreference(defaultValue: false)
|
||||||
|
var automaticBackPaginationEnabled: Bool
|
||||||
|
|
||||||
@UserPreference(key: "clientPausingAndResumingEnabledV2", defaultValue: false, volatile: true)
|
@UserPreference(key: "clientPausingAndResumingEnabledV2", defaultValue: false, volatile: true)
|
||||||
var clientPausingAndResumingEnabled: Bool
|
var clientPausingAndResumingEnabled: Bool
|
||||||
|
|
||||||
@UserPreference(defaultValue: AppBuildType.current != .release)
|
@UserPreference(defaultValue: AppBuildType.current != .release)
|
||||||
var developerOptionsEnabled: Bool
|
var developerOptionsEnabled: Bool
|
||||||
|
|
||||||
/// Runs calls through the native matrix-rust-rtc stack instead of the Element Call web view.
|
|
||||||
@UserPreference(defaultValue: false)
|
|
||||||
var nativeCallEnabled: Bool
|
|
||||||
|
|
||||||
@UserPreference(defaultValue: false)
|
|
||||||
var multiAccountEnabled: Bool
|
|
||||||
|
|
||||||
/// Lets the add account flow start once `multiAccountEnabled` shows its entry points. Not a user preference, so testers
|
|
||||||
/// can't add a second account while signing out of one isn't safe yet. Only set it to `true` locally, for development.
|
|
||||||
let addAccountFlowEnabled = false
|
|
||||||
|
|
||||||
init(store: UserDefaultsProtocol) {
|
init(store: UserDefaultsProtocol) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
func userSettings(for userID: String) -> UserSettings {
|
|
||||||
UserSettings(appSettings: self, accountSettings: .init(userID: userID, store: store))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func volatile() -> AppSettings {
|
static func volatile() -> AppSettings {
|
||||||
AppSettings(store: VolatileUserDefaults())
|
AppSettings(store: VolatileUserDefaults())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated extension AppSettings: CommonSettingsProtocol { }
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CallKit
|
import CallKit
|
||||||
import Combine
|
import Combine
|
||||||
import ElementCall
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
import PushKit
|
import PushKit
|
||||||
@@ -27,14 +26,13 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
let roomID: String
|
let roomID: String
|
||||||
let rtcNotificationID: String?
|
let rtcNotificationID: String?
|
||||||
let isVoiceCall: Bool
|
let isVoiceCall: Bool
|
||||||
var isNative = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let appSettings: AppSettings
|
|
||||||
private let pushRegistry: PKPushRegistry
|
private let pushRegistry: PKPushRegistry
|
||||||
private let callController: CXCallControllerProtocol
|
private let callController = CXCallController()
|
||||||
private let callProvider: CXProviderProtocol
|
private let callProvider: CXProviderProtocol
|
||||||
private let timeProvider: TimeProvider
|
private let timeProvider: TimeProvider
|
||||||
|
private let appSettings: AppSettings
|
||||||
|
|
||||||
private weak var clientProxy: ClientProxyProtocol? {
|
private weak var clientProxy: ClientProxyProtocol? {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -54,10 +52,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
|
|
||||||
private var endUnansweredCallTask: Task<Void, Never>?
|
private var endUnansweredCallTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// A call answered for the native stack, until the stack takes it over.
|
|
||||||
private var answeredNativeCallID: CallID?
|
|
||||||
private var endPendingCallTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private var ongoingCallID: CallID? {
|
private var ongoingCallID: CallID? {
|
||||||
didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) }
|
didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) }
|
||||||
}
|
}
|
||||||
@@ -74,26 +68,11 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
|
|
||||||
private var declineListenerHandle: TaskHandle?
|
private var declineListenerHandle: TaskHandle?
|
||||||
|
|
||||||
/// The native call stack, for as long as there's a session running calls through it.
|
init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil, appSettings: AppSettings) {
|
||||||
private var nativeCallStack: NativeCallStack?
|
|
||||||
private var nativeCallCancellable: AnyCancellable?
|
|
||||||
private var mediaProvider: MediaProviderProtocol?
|
|
||||||
|
|
||||||
var nativeCallController: ElementCallController? {
|
|
||||||
nativeCallStack?.controller
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
init(appSettings: AppSettings,
|
|
||||||
callProvider: CXProviderProtocol? = nil,
|
|
||||||
callController: CXCallControllerProtocol? = nil,
|
|
||||||
timeProvider: TimeProvider? = nil) {
|
|
||||||
self.appSettings = appSettings
|
|
||||||
self.callController = callController ?? CXCallController()
|
|
||||||
pushRegistry = PKPushRegistry(queue: nil)
|
pushRegistry = PKPushRegistry(queue: nil)
|
||||||
|
|
||||||
self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init)
|
self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init)
|
||||||
|
self.appSettings = appSettings
|
||||||
|
|
||||||
if let callProvider {
|
if let callProvider {
|
||||||
self.callProvider = callProvider
|
self.callProvider = callProvider
|
||||||
@@ -118,145 +97,45 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
pushRegistry.desiredPushTypes = [.voIP]
|
pushRegistry.desiredPushTypes = [.voIP]
|
||||||
|
|
||||||
self.callProvider.setDelegate(self, queue: nil)
|
self.callProvider.setDelegate(self, queue: nil)
|
||||||
|
|
||||||
appSettings.nativeCallEnabledPublisher
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.updateNativeCallStack()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUserSession(_ userSession: UserSessionProtocol?) {
|
func setClientProxy(_ clientProxy: any ClientProxyProtocol) {
|
||||||
// A stack is built against one client proxy and can't be handed to another, which is what a
|
self.clientProxy = clientProxy
|
||||||
// soft logout or a cache clear brings: a new session without the sign out that drops this one.
|
|
||||||
if userSession?.clientProxy !== clientProxy {
|
|
||||||
stopNativeCallStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
clientProxy = userSession?.clientProxy
|
|
||||||
mediaProvider = userSession?.mediaProvider
|
|
||||||
|
|
||||||
updateNativeCallStack()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
func setupCallSession(roomID: String, roomDisplayName: String) async {
|
||||||
func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool {
|
|
||||||
guard let nativeCallStack else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
nativeCallStack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: isVoiceCall)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func minimizeNativeCall() {
|
|
||||||
nativeCallStack?.minimize()
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreNativeCall() {
|
|
||||||
nativeCallStack?.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async {
|
|
||||||
// Unlike the push handling below, this always runs with a session, so the stack itself is
|
|
||||||
// the answer rather than the setting that would have built one.
|
|
||||||
let isNative = nativeCallStack != nil
|
|
||||||
MXLog.info("Setting up a call session, native: \(isNative), video: \(isVideo), replacing an ongoing call: \(ongoingCallID != nil)")
|
|
||||||
|
|
||||||
// Drop any ongoing calls when starting a new one
|
// Drop any ongoing calls when starting a new one
|
||||||
if ongoingCallID != nil {
|
if ongoingCallID != nil {
|
||||||
tearDownCallSession(sendEndCallAction: true)
|
tearDownCallSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this starting from a ring reuse those identifiers
|
// If this starting from a ring reuse those identifiers
|
||||||
// Make sure the roomID matches
|
// Make sure the roomID matches
|
||||||
var callID = if let incomingCallID, incomingCallID.roomID == roomID {
|
let callID = if let incomingCallID, incomingCallID.roomID == roomID {
|
||||||
incomingCallID
|
incomingCallID
|
||||||
} else {
|
} else {
|
||||||
CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: !isVideo)
|
CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: false)
|
||||||
}
|
}
|
||||||
callID.isNative = isNative
|
|
||||||
let isAnsweringIncomingCall = callID.rtcNotificationID != nil
|
|
||||||
|
|
||||||
clearIncomingCallState()
|
clearIncomingCallState()
|
||||||
ongoingCallID = callID
|
ongoingCallID = callID
|
||||||
|
|
||||||
// A web view call must not be tracked by CallKit, as that gives this process exclusive
|
// Don't bother starting another CallKit session as it won't work properly
|
||||||
// access to media and the web view runs in another (see `CXAnswerCallAction`).
|
|
||||||
// https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022
|
// https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022
|
||||||
guard isNative else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Answering the ringing call: it is already reported, keep it.
|
// let handle = CXHandle(type: .generic, value: roomDisplayName)
|
||||||
guard !isAnsweringIncomingCall else {
|
// let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle)
|
||||||
return
|
// startCallAction.isVideo = true
|
||||||
}
|
|
||||||
|
|
||||||
let handle = CXHandle(type: .generic, value: roomID)
|
// do {
|
||||||
let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle)
|
// try await callController.request(CXTransaction(action: startCallAction))
|
||||||
startCallAction.contactIdentifier = roomDisplayName
|
// } catch {
|
||||||
startCallAction.isVideo = isVideo
|
// MXLog.error("Failed requesting start call action with error: \(error)")
|
||||||
|
// }
|
||||||
// Awaited because our membership goes out straight after: a peer that sees it before the
|
|
||||||
// system knows about the call reads the call as answered elsewhere.
|
|
||||||
await requestTransaction(CXTransaction(action: startCallAction), describedAs: "start call")
|
|
||||||
|
|
||||||
// Without this the system call UI shows the handle, i.e. the room ID.
|
|
||||||
let update = CXCallUpdate()
|
|
||||||
update.localizedCallerName = roomDisplayName
|
|
||||||
update.remoteHandle = handle
|
|
||||||
update.hasVideo = isVideo
|
|
||||||
callProvider.reportCall(with: callID.callKitID, updated: update)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportCallSessionConnected(roomID: String) {
|
func tearDownCallSession() {
|
||||||
guard let ongoingCallID, ongoingCallID.roomID == roomID else {
|
tearDownCallSession(sendEndCallAction: true)
|
||||||
// The system ends an outgoing call that never reports connecting, so a silent guard
|
|
||||||
// here shows up much later as a call that hangs itself up.
|
|
||||||
MXLog.warning("Not reporting the call as connected, no matching ongoing call")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard ongoingCallID.isNative else {
|
|
||||||
MXLog.info("Not reporting a web view call as connected, CallKit doesn't track it")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
MXLog.info("Reporting the call as connected")
|
|
||||||
callProvider.reportOutgoingCall(with: ongoingCallID.callKitID, connectedAt: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tearDownCallSession(roomID: String) {
|
|
||||||
if ongoingCallID?.roomID == roomID {
|
|
||||||
tearDownCallSession(sendEndCallAction: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Answered and handed over, but the stack couldn't take it. Ending it here rather than
|
|
||||||
// waiting for the watchdog takes the system call UI away straight away.
|
|
||||||
if answeredNativeCallID?.roomID == roomID {
|
|
||||||
MXLog.warning("Ending an answered call the native call stack didn't take over")
|
|
||||||
endPendingCall()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
MXLog.info("Not tearing down the call session, no call for room \(roomID)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportCallSessionEnded(roomID: String, reason: CXCallEndedReason) {
|
|
||||||
guard let ongoingCallID, ongoingCallID.roomID == roomID else {
|
|
||||||
MXLog.info("Not reporting the end of a call for \(roomID), it isn't the ongoing call")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// A web view call is not tracked by CallKit, as in `reportCallSessionConnected`.
|
|
||||||
if ongoingCallID.isNative {
|
|
||||||
MXLog.info("Reporting the call as ended without us: \(reason.rawValue)")
|
|
||||||
callProvider.reportCall(with: ongoingCallID.callKitID, endedAt: nil, reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ongoingCallID = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAudioEnabled(_ enabled: Bool, roomID: String) {
|
func setAudioEnabled(_ enabled: Bool, roomID: String) {
|
||||||
@@ -278,60 +157,18 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Native calls
|
|
||||||
|
|
||||||
/// Builds or releases the call stack to match the session and the setting.
|
|
||||||
private func updateNativeCallStack() {
|
|
||||||
guard appSettings.nativeCallEnabled, let clientProxy else {
|
|
||||||
stopNativeCallStack()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard nativeCallStack == nil else { return }
|
|
||||||
|
|
||||||
guard let transport = clientProxy.makeNativeCallTransport() else {
|
|
||||||
MXLog.error("Cannot start the native call stack without a transport")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let style = ElementCallStyle(theme: NativeCallTheme(),
|
|
||||||
icons: NativeCallIcons(),
|
|
||||||
avatars: NativeCallAvatars(mediaProvider: mediaProvider),
|
|
||||||
strings: .init(you: L10n.commonYou,
|
|
||||||
error: L10n.commonError,
|
|
||||||
stop: L10n.actionStop,
|
|
||||||
back: L10n.actionBack))
|
|
||||||
|
|
||||||
// The stats overlay is raw RTP counters, so it follows whatever already reveals developer
|
|
||||||
// surface. Screen sharing stays off until the broadcast extension that makes it work lands.
|
|
||||||
let options = ElementCallOptions(isDeveloperModeEnabled: appSettings.developerOptionsEnabled)
|
|
||||||
|
|
||||||
let stack = NativeCallStack(transport: transport,
|
|
||||||
system: NativeCallSystem(service: self),
|
|
||||||
options: options,
|
|
||||||
style: style)
|
|
||||||
nativeCallStack = stack
|
|
||||||
|
|
||||||
nativeCallCancellable = stack.actions
|
|
||||||
.sink { [weak self] presentation in
|
|
||||||
self?.actionsSubject.send(.nativeCall(presentation))
|
|
||||||
}
|
|
||||||
|
|
||||||
Task { await stack.start() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopNativeCallStack() {
|
|
||||||
guard let nativeCallStack else { return }
|
|
||||||
self.nativeCallStack = nil
|
|
||||||
nativeCallCancellable = nil
|
|
||||||
|
|
||||||
nativeCallStack.stop()
|
|
||||||
actionsSubject.send(.nativeCall(.dismiss))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PKPushRegistryDelegate
|
// MARK: - PKPushRegistryDelegate
|
||||||
|
|
||||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { }
|
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||||
|
// HealthChat: stored for the NSE, which requests a VoIP push through the push gateway
|
||||||
|
// for ringing calls as long as the notification filtering entitlement is missing.
|
||||||
|
appSettings.pushKitDeviceToken = pushCredentials.token.base64EncodedString()
|
||||||
|
appSettings.voipPushGatewayNotifyURL = appSettings.pushGatewayNotifyEndpoint.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||||
|
appSettings.pushKitDeviceToken = nil
|
||||||
|
}
|
||||||
|
|
||||||
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||||
guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else {
|
guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else {
|
||||||
@@ -363,7 +200,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID, isVoiceCall: isVoiceCall)
|
let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID, isVoiceCall: isVoiceCall)
|
||||||
incomingCallID = callID
|
incomingCallID = callID
|
||||||
|
|
||||||
guard let expirationDate = (payload.dictionaryPayload[ElementCallServiceNotificationKey.expirationDate.rawValue] as? Date) else {
|
guard let expirationDate = Self.expirationDate(from: payload.dictionaryPayload) else {
|
||||||
MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)")
|
MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)")
|
||||||
assertionFailure("Incoming voip push is missing the expiration timestamp")
|
assertionFailure("Incoming voip push is missing the expiration timestamp")
|
||||||
clearIncomingCallState()
|
clearIncomingCallState()
|
||||||
@@ -389,7 +226,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
// If not for audio call the app will not be put to foreground and the webview won't be able to handle the call...
|
// If not for audio call the app will not be put to foreground and the webview won't be able to handle the call...
|
||||||
// Consequence: The call will be presented to the user as a video call in CallKit UI,
|
// Consequence: The call will be presented to the user as a video call in CallKit UI,
|
||||||
// but once Element Call is launched it will correctly route to a voice-only call.
|
// but once Element Call is launched it will correctly route to a voice-only call.
|
||||||
update.hasVideo = appSettings.nativeCallEnabled ? !isVoiceCall : true
|
update.hasVideo = true
|
||||||
update.localizedCallerName = roomDisplayName
|
update.localizedCallerName = roomDisplayName
|
||||||
// https://stackoverflow.com/a/41230020/730924
|
// https://stackoverflow.com/a/41230020/730924
|
||||||
update.remoteHandle = .init(type: .generic, value: roomID)
|
update.remoteHandle = .init(type: .generic, value: roomID)
|
||||||
@@ -427,19 +264,10 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
|
|
||||||
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||||
MXLog.info("Call provider did activate audio session")
|
MXLog.info("Call provider did activate audio session")
|
||||||
actionsSubject.send(.audioSessionActivated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||||
MXLog.info("Call provider did deactivate audio session")
|
MXLog.info("Call provider did deactivate audio session")
|
||||||
actionsSubject.send(.audioSessionDeactivated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
|
||||||
// The native controller configured the audio session already; CallKit activates it and
|
|
||||||
// reports back through `didActivate`.
|
|
||||||
action.fulfill()
|
|
||||||
callProvider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func providerDidReset(_ provider: CXProvider) {
|
func providerDidReset(_ provider: CXProvider) {
|
||||||
@@ -470,15 +298,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
// First fullfill the action
|
// First fullfill the action
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
|
|
||||||
if appSettings.nativeCallEnabled {
|
|
||||||
// The native stack owns media in this process, so the CallKit call stays up: the
|
|
||||||
// controller attaches to it in `setupCallSession`.
|
|
||||||
endUnansweredCallTask?.cancel()
|
|
||||||
endCallIfLeftPending(incomingCallID)
|
|
||||||
actionsSubject.send(.startCall(roomID: incomingCallID.roomID, isVoiceCall: incomingCallID.isVoiceCall))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// And delay ending the call so that the app has enough time
|
// And delay ending the call so that the app has enough time
|
||||||
// to get deeplinked into
|
// to get deeplinked into
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
@@ -494,7 +313,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
if let ongoingCallID {
|
if let ongoingCallID {
|
||||||
actionsSubject.send(.setAudioEnabled(!action.isMuted, roomID: ongoingCallID.roomID))
|
actionsSubject.send(.setAudioEnabled(!action.isMuted, roomID: ongoingCallID.roomID))
|
||||||
} else {
|
} else {
|
||||||
MXLog.info("Ignoring a mute action without an ongoing call")
|
MXLog.error("Failed muting/unmuting call, missing ongoingCallID")
|
||||||
}
|
}
|
||||||
|
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
@@ -505,56 +324,30 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
// This gets called for no reason on simulators, where CallKit
|
// This gets called for no reason on simulators, where CallKit
|
||||||
// isn't even supported, ignore it.
|
// isn't even supported, ignore it.
|
||||||
#else
|
#else
|
||||||
endCall(withCallKitID: action.callUUID)
|
if let ongoingCallID {
|
||||||
|
actionsSubject.send(.endCall(roomID: ongoingCallID.roomID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let incomingCallID {
|
||||||
|
Task {
|
||||||
|
await sendDeclineCallEvent(incomingCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if incomingCallID?.callKitID == action.callUUID {
|
||||||
|
clearIncomingCallState()
|
||||||
|
}
|
||||||
|
|
||||||
|
tearDownCallSession(sendEndCallAction: false)
|
||||||
|
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the call the system identified, whether that's the ongoing one, the ringing one, or
|
|
||||||
/// neither because it has already been replaced.
|
|
||||||
///
|
|
||||||
/// Kept out of the delegate method so that it's reachable on the simulator, where the provider
|
|
||||||
/// performs end call actions of its own and the delegate has to ignore them.
|
|
||||||
func endCall(withCallKitID callKitID: UUID) {
|
|
||||||
MXLog.info("Ending the call for \(callKitID), ongoing: \(ongoingCallID != nil), incoming: \(incomingCallID != nil)")
|
|
||||||
|
|
||||||
// Both branches are gated on the identifier. Leaving a call to start one in another room
|
|
||||||
// requests an end call action for the old call and replaces `ongoingCallID` before the
|
|
||||||
// system gets round to performing it, so an ungated handler reads it as the new call ending
|
|
||||||
// and hangs up the call that was just started.
|
|
||||||
if let ongoingCallID, ongoingCallID.callKitID == callKitID {
|
|
||||||
actionsSubject.send(.endCall(roomID: ongoingCallID.roomID))
|
|
||||||
tearDownCallSession(sendEndCallAction: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let incomingCallID, incomingCallID.callKitID == callKitID {
|
|
||||||
Task {
|
|
||||||
await sendDeclineCallEvent(incomingCallID)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearIncomingCallState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
/// Bridges the controller's completion handler, which is all ``CXCallControllerProtocol`` can
|
|
||||||
/// carry, back to an awaitable call.
|
|
||||||
private func requestTransaction(_ transaction: CXTransaction, describedAs description: String) async {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
callController.request(transaction) { error in
|
|
||||||
if let error {
|
|
||||||
MXLog.error("Failed requesting \(description) action with error: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tearDownCallSession(sendEndCallAction: Bool = true) {
|
private func tearDownCallSession(sendEndCallAction: Bool = true) {
|
||||||
if sendEndCallAction, let ongoingCallID {
|
if sendEndCallAction, let ongoingCallID {
|
||||||
MXLog.info("Requesting an end call action for the ongoing call")
|
|
||||||
let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID))
|
let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID))
|
||||||
callController.request(transaction) { error in
|
callController.request(transaction) { error in
|
||||||
if let error {
|
if let error {
|
||||||
@@ -571,6 +364,19 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
/// a call and immediately end it. `callerInfo` names the call after the room so the brief
|
/// a call and immediately end it. `callerInfo` names the call after the room so the brief
|
||||||
/// system UI and the Recents entry show the room rather than "Unknown", and the entry can be
|
/// system UI and the Recents entry show the room rather than "Unknown", and the entry can be
|
||||||
/// tapped to call back.
|
/// tapped to call back.
|
||||||
|
/// CallKit hands the NSE's payload over as-is, so the expiration arrives as a `Date`. A VoIP push
|
||||||
|
/// requested from the push gateway travels as JSON, where the same key is seconds since 1970.
|
||||||
|
private static func expirationDate(from payload: [AnyHashable: Any]) -> Date? {
|
||||||
|
switch payload[ElementCallServiceNotificationKey.expirationDate.rawValue] {
|
||||||
|
case let date as Date:
|
||||||
|
date
|
||||||
|
case let secondsSince1970 as TimeInterval:
|
||||||
|
Date(timeIntervalSince1970: secondsSince1970)
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func reportImmediatelyEndedCall(reason: CXCallEndedReason,
|
private func reportImmediatelyEndedCall(reason: CXCallEndedReason,
|
||||||
callerInfo: (roomID: String, roomDisplayName: String?)? = nil,
|
callerInfo: (roomID: String, roomDisplayName: String?)? = nil,
|
||||||
completion: @escaping () -> Void) {
|
completion: @escaping () -> Void) {
|
||||||
@@ -599,32 +405,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends an answered call that the native stack never took over, which nothing else would: the
|
|
||||||
/// call is deliberately left up for the controller to attach to in `setupCallSession`.
|
|
||||||
private func endCallIfLeftPending(_ callID: CallID) {
|
|
||||||
answeredNativeCallID = callID
|
|
||||||
|
|
||||||
endPendingCallTask = Task { [weak self] in
|
|
||||||
try? await self?.timeProvider.clock.sleep(for: .seconds(30))
|
|
||||||
|
|
||||||
guard let self, !Task.isCancelled, answeredNativeCallID == callID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
MXLog.error("The native call stack never took over the answered call, ending it")
|
|
||||||
endPendingCall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func endPendingCall() {
|
|
||||||
guard let answeredNativeCallID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callProvider.reportCall(with: answeredNativeCallID.callKitID, endedAt: nil, reason: .failed)
|
|
||||||
clearIncomingCallState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendDeclineCallEvent(_ incomingCallID: CallID) async {
|
private func sendDeclineCallEvent(_ incomingCallID: CallID) async {
|
||||||
guard let rtcNotificationID = incomingCallID.rtcNotificationID else {
|
guard let rtcNotificationID = incomingCallID.rtcNotificationID else {
|
||||||
MXLog.info("No rtc notification event to decline.")
|
MXLog.info("No rtc notification event to decline.")
|
||||||
@@ -726,9 +506,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
private func clearIncomingCallState() {
|
private func clearIncomingCallState() {
|
||||||
endUnansweredCallTask?.cancel()
|
endUnansweredCallTask?.cancel()
|
||||||
endUnansweredCallTask = nil
|
endUnansweredCallTask = nil
|
||||||
endPendingCallTask?.cancel()
|
|
||||||
endPendingCallTask = nil
|
|
||||||
answeredNativeCallID = nil
|
|
||||||
declineListenerHandle?.cancel()
|
declineListenerHandle?.cancel()
|
||||||
declineListenerHandle = nil
|
declineListenerHandle = nil
|
||||||
incomingCallRoomInfoCancellable = nil
|
incomingCallRoomInfoCancellable = nil
|
||||||
|
|||||||
@@ -256,13 +256,62 @@ nonisolated class NotificationHandler {
|
|||||||
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
|
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
|
||||||
MXLog.info("Call notification delegated to CallKit")
|
MXLog.info("Call notification delegated to CallKit")
|
||||||
} catch {
|
} catch {
|
||||||
MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification")
|
// HealthChat: without the notification filtering entitlement CallKit rejects the hand-off,
|
||||||
|
// so ask the push gateway for a PushKit push instead and keep the alert visible.
|
||||||
|
MXLog.error("Failed reporting voip call with error: \(error). Requesting a VoIP push instead")
|
||||||
|
await requestVoIPPush(roomID: roomID,
|
||||||
|
roomDisplayName: roomDisplayName,
|
||||||
|
rtcNotifyEventID: rtcNotifyEventID,
|
||||||
|
expirationDate: expirationDate,
|
||||||
|
isVoiceCall: callIntent == RtcCallIntent.audio)
|
||||||
return .shouldDisplay
|
return .shouldDisplay
|
||||||
}
|
}
|
||||||
|
|
||||||
return .processedShouldDiscard
|
return .processedShouldDiscard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks the push gateway to deliver a PushKit push with the call details, the way the legacy
|
||||||
|
/// element-ios app did. The app's `PKPushRegistryDelegate` then reports the call to CallKit.
|
||||||
|
private func requestVoIPPush(roomID: String, roomDisplayName: String, rtcNotifyEventID: String, expirationDate: Date, isVoiceCall: Bool) async {
|
||||||
|
guard let token = settings.pushKitDeviceToken,
|
||||||
|
let notifyURL = settings.voipPushGatewayNotifyURL.flatMap(URL.init(string:)) else {
|
||||||
|
MXLog.warning("No PushKit token or push gateway stored by the app, cannot request a VoIP push")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gateway merges `default_payload` into the push verbatim, so the keys match what the app expects.
|
||||||
|
let defaultPayload: [String: Any] = [ElementCallServiceNotificationKey.roomID.rawValue: roomID,
|
||||||
|
ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName,
|
||||||
|
ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID,
|
||||||
|
ElementCallServiceNotificationKey.expirationDate.rawValue: expirationDate.timeIntervalSince1970,
|
||||||
|
ElementCallServiceNotificationKey.isVoiceCall.rawValue: isVoiceCall]
|
||||||
|
let body: [String: Any] = ["notification": ["event_id": rtcNotifyEventID,
|
||||||
|
"room_id": roomID,
|
||||||
|
"prio": "high",
|
||||||
|
"devices": [["app_id": settings.voipPusherAppID,
|
||||||
|
"pushkey": token,
|
||||||
|
"data": ["format": "event_id_only",
|
||||||
|
"default_payload": defaultPayload]]]]]
|
||||||
|
|
||||||
|
var request = URLRequest(url: notifyURL, timeoutInterval: 5)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
do {
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let rejected = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["rejected"] as? [String] ?? []
|
||||||
|
guard statusCode == 200, rejected.isEmpty else {
|
||||||
|
MXLog.error("Push gateway refused the VoIP push: status \(statusCode), rejected \(rejected.count) pushkey(s)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
MXLog.info("Requested a VoIP push for the call in \(roomID)")
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Failed requesting a VoIP push: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum NotificationProcessingResult {
|
private enum NotificationProcessingResult {
|
||||||
case shouldDisplay
|
case shouldDisplay
|
||||||
case processedShouldDiscard
|
case processedShouldDiscard
|
||||||
|
|||||||
@@ -6,46 +6,36 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import CallKit
|
import CallKit
|
||||||
import ElementCall
|
|
||||||
@testable import ElementX
|
@testable import ElementX
|
||||||
import PushKit
|
import PushKit
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ElementCallServiceTests {
|
final class ElementCallServiceTests {
|
||||||
private var appSettings: AppSettings!
|
|
||||||
private var callProvider: CXProviderMock!
|
private var callProvider: CXProviderMock!
|
||||||
private var callController: CXCallControllerMock!
|
|
||||||
private var currentDate: Date!
|
private var currentDate: Date!
|
||||||
private var clock: ManualClock!
|
private var clock: ManualClock!
|
||||||
private var pushRegistry: PKPushRegistry!
|
private var pushRegistry: PKPushRegistry!
|
||||||
private var service: ElementCallService!
|
private var service: ElementCallService!
|
||||||
private var userSession: UserSessionMock!
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
appSettings = AppSettings.volatile()
|
|
||||||
pushRegistry = PKPushRegistry(queue: nil)
|
pushRegistry = PKPushRegistry(queue: nil)
|
||||||
callProvider = CXProviderMock(.init())
|
callProvider = CXProviderMock(.init())
|
||||||
callController = CXCallControllerMock(.init())
|
|
||||||
currentDate = Date()
|
currentDate = Date()
|
||||||
clock = ManualClock()
|
clock = ManualClock()
|
||||||
let dateProvider: () -> Date = {
|
let dateProvider: () -> Date = {
|
||||||
self.currentDate
|
self.currentDate
|
||||||
}
|
}
|
||||||
service = ElementCallService(appSettings: appSettings,
|
service = ElementCallService(callProvider: callProvider,
|
||||||
callProvider: callProvider,
|
timeProvider: TimeProvider(clock: clock, now: dateProvider),
|
||||||
callController: callController,
|
appSettings: AppSettings.volatile())
|
||||||
timeProvider: TimeProvider(clock: clock, now: dateProvider))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isolated deinit {
|
isolated deinit {
|
||||||
appSettings = nil
|
|
||||||
callProvider = nil
|
callProvider = nil
|
||||||
callController = nil
|
|
||||||
currentDate = nil
|
currentDate = nil
|
||||||
clock = nil
|
clock = nil
|
||||||
pushRegistry = nil
|
pushRegistry = nil
|
||||||
userSession = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -69,6 +59,21 @@ final class ElementCallServiceTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HealthChat: a VoIP push requested from the push gateway carries the expiration as JSON seconds.
|
||||||
|
@Test
|
||||||
|
func incomingCallFromPushGatewayVoIPPush() async {
|
||||||
|
await waitForConfirmation { confirmation in
|
||||||
|
let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(secondsSince1970: currentDate.timeIntervalSince1970 + 30)
|
||||||
|
|
||||||
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) {
|
||||||
|
confirmation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
||||||
|
#expect(!callProvider.reportCallWithEndedAtReasonCalled)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func incomingVoiceCall() async {
|
func incomingVoiceCall() async {
|
||||||
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
||||||
@@ -194,7 +199,7 @@ final class ElementCallServiceTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func setupCallSessionCancelsPendingUnansweredTimeout() async throws {
|
func setupCallSessionCancelsPendingUnansweredTimeout() async {
|
||||||
// Schedule the 60s unanswered timer via an incoming push
|
// Schedule the 60s unanswered timer via an incoming push
|
||||||
await waitForConfirmation { confirmation in
|
await waitForConfirmation { confirmation in
|
||||||
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
||||||
@@ -208,20 +213,22 @@ final class ElementCallServiceTests {
|
|||||||
|
|
||||||
// Simulate the answer flow handing off to setupCallSession, which must cancel
|
// Simulate the answer flow handing off to setupCallSession, which must cancel
|
||||||
// the pending endUnansweredCallTask as part of clearing the incoming state.
|
// the pending endUnansweredCallTask as part of clearing the incoming state.
|
||||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome")
|
||||||
|
|
||||||
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
var unansweredFired = false
|
||||||
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
||||||
endedCallsContinuation.yield(reason)
|
if reason == .unanswered {
|
||||||
|
unansweredFired = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let deferredUnansweredCall = deferFailure(endedCalls,
|
|
||||||
timeout: .milliseconds(100),
|
|
||||||
message: "endUnansweredCallTask should have been cancelled by setupCallSession") { $0 == .unanswered }
|
|
||||||
|
|
||||||
// Advance past what would have been the 60s unanswered timeout
|
// Advance past what would have been the 60s unanswered timeout
|
||||||
clock.advance(by: .seconds(120))
|
clock.advance(by: .seconds(120))
|
||||||
|
for _ in 0..<3 {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
|
||||||
try await deferredUnansweredCall.fulfill()
|
#expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -239,7 +246,7 @@ final class ElementCallServiceTests {
|
|||||||
@Test
|
@Test
|
||||||
func duplicateRoomPushReportsCallAsHandled() async {
|
func duplicateRoomPushReportsCallAsHandled() async {
|
||||||
// A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone.
|
// A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone.
|
||||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome")
|
||||||
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30)
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30)
|
||||||
await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere)
|
await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere)
|
||||||
|
|
||||||
@@ -250,271 +257,6 @@ final class ElementCallServiceTests {
|
|||||||
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
func webViewCallSessionIsNotTrackedByCallKit() async {
|
|
||||||
// CallKit tracking a call gives this process exclusive media access, which starves the web
|
|
||||||
// view. So a web view call is only ever tracked on the app side.
|
|
||||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
||||||
service.reportCallSessionConnected(roomID: "!room:example.com")
|
|
||||||
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
||||||
#expect(!callProvider.reportCallWithUpdatedCalled)
|
|
||||||
#expect(!callProvider.reportOutgoingCallWithConnectedAtCalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func endingAReplacedCallLeavesTheCallThatReplacedItUp() async throws {
|
|
||||||
enableNativeCalls()
|
|
||||||
|
|
||||||
// Leaving a call to start one in another room requests an end call action for the first,
|
|
||||||
// then starts the second before the system performs it.
|
|
||||||
await service.setupCallSession(roomID: "!first:example.com", roomDisplayName: "first", isVideo: true)
|
|
||||||
let firstCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid)
|
|
||||||
|
|
||||||
service.tearDownCallSession(roomID: "!first:example.com")
|
|
||||||
await service.setupCallSession(roomID: "!second:example.com", roomDisplayName: "second", isVideo: true)
|
|
||||||
|
|
||||||
var endedRooms: [String] = []
|
|
||||||
let cancellable = service.actions.sink { action in
|
|
||||||
if case .endCall(let roomID) = action {
|
|
||||||
endedRooms.append(roomID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer { cancellable.cancel() }
|
|
||||||
|
|
||||||
service.endCall(withCallKitID: firstCallID)
|
|
||||||
|
|
||||||
#expect(endedRooms.isEmpty, "The first call's end action must not hang up the second call")
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == "!second:example.com")
|
|
||||||
|
|
||||||
// And the second call still ends when it's the one the system means.
|
|
||||||
let secondCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid)
|
|
||||||
service.endCall(withCallKitID: secondCallID)
|
|
||||||
|
|
||||||
#expect(endedRooms == ["!second:example.com"])
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A declined or unanswered call is the system's to log as such: reported with its reason, and
|
|
||||||
/// never as an end call action, which would log it as one we hung up.
|
|
||||||
@Test
|
|
||||||
func aCallThatEndedWithoutUsIsReportedRatherThanEnded() async throws {
|
|
||||||
enableNativeCalls()
|
|
||||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
||||||
let callID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid)
|
|
||||||
let requestsBeforeTheEnd = callController.requestCompletionCallsCount
|
|
||||||
|
|
||||||
service.reportCallSessionEnded(roomID: "!other:example.com", reason: .unanswered)
|
|
||||||
#expect(!callProvider.reportCallWithEndedAtReasonCalled, "Another room's end is not this call's")
|
|
||||||
|
|
||||||
service.reportCallSessionEnded(roomID: "!room:example.com", reason: .unanswered)
|
|
||||||
|
|
||||||
#expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.uuid == callID)
|
|
||||||
#expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason == .unanswered)
|
|
||||||
#expect(callController.requestCompletionCallsCount == requestsBeforeTheEnd)
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func tearingDownAnotherRoomLeavesTheOngoingCallUp() async {
|
|
||||||
// A call screen that is dismissed after another room's call replaced it must not take the
|
|
||||||
// new call down with it.
|
|
||||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
||||||
|
|
||||||
service.tearDownCallSession(roomID: "!other:example.com")
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
||||||
|
|
||||||
service.tearDownCallSession(roomID: "!room:example.com")
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Native calls
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func nativeModeReportsVoiceCallsWithoutVideo() async {
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
|
|
||||||
await reportIncomingPush(isVoiceCall: true)
|
|
||||||
|
|
||||||
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func nativeModeStillReportsVideoCallsWithVideo() async {
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
|
|
||||||
await reportIncomingPush(isVoiceCall: false)
|
|
||||||
|
|
||||||
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func answeringInNativeModeKeepsTheCallUp() async throws {
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
await reportIncomingPush(isVoiceCall: false)
|
|
||||||
await clock.waitForScheduledSleep()
|
|
||||||
|
|
||||||
try await answerReportedCall()
|
|
||||||
|
|
||||||
// The webview path ends the call a second after answering so that `setupCallSession` can
|
|
||||||
// start a new one. The native stack takes this one over instead, so it has to survive.
|
|
||||||
#expect(!callProvider.reportCallWithEndedAtReasonCalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func answeredNativeCallEndsWhenNothingTakesItOver() async throws {
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
await reportIncomingPush(isVoiceCall: false)
|
|
||||||
await clock.waitForScheduledSleep()
|
|
||||||
|
|
||||||
let answeredUUID = try await answerReportedCall()
|
|
||||||
|
|
||||||
let (endedCalls, endedCallsContinuation) = AsyncStream<(UUID, CXCallEndedReason)>.makeStream()
|
|
||||||
callProvider.reportCallWithEndedAtReasonClosure = { uuid, _, reason in
|
|
||||||
endedCallsContinuation.yield((uuid, reason))
|
|
||||||
}
|
|
||||||
let deferredEndedCall = deferFulfillment(endedCalls) { _ in true }
|
|
||||||
|
|
||||||
// Answering swaps the unanswered timer for the watchdog that catches a hand-off that never
|
|
||||||
// happens — the room isn't joined, there's no transport, a call is already running.
|
|
||||||
#expect(await clock.waitForScheduledSleep() == .seconds(30))
|
|
||||||
clock.advance(by: .seconds(30))
|
|
||||||
|
|
||||||
let (uuid, reason) = try await deferredEndedCall.fulfill()
|
|
||||||
#expect(uuid == answeredUUID)
|
|
||||||
#expect(reason == .failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func nativeCallStackTakesOverTheAnsweredCall() async throws {
|
|
||||||
enableNativeCalls()
|
|
||||||
await reportIncomingPush(isVoiceCall: false)
|
|
||||||
await clock.waitForScheduledSleep()
|
|
||||||
|
|
||||||
try await answerReportedCall()
|
|
||||||
|
|
||||||
// Answering starts the watchdog, so wait for it before asserting that the hand-over stops it.
|
|
||||||
await clock.waitForScheduledSleep()
|
|
||||||
|
|
||||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
||||||
|
|
||||||
// Taking the answered call over means keeping its identifier rather than reporting a new
|
|
||||||
// outgoing call, which is what the update below would be part of.
|
|
||||||
#expect(!callProvider.reportCallWithUpdatedCalled)
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
||||||
|
|
||||||
// And the watchdog must be gone, otherwise it ends the call that was just taken over.
|
|
||||||
#expect(!clock.hasPendingSleep, "The watchdog must not outlive the hand-over")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func nativeCallStackForAnotherRoomReportsANewCall() async throws {
|
|
||||||
enableNativeCalls()
|
|
||||||
await reportIncomingPush(isVoiceCall: false)
|
|
||||||
await clock.waitForScheduledSleep()
|
|
||||||
|
|
||||||
let answeredUUID = try await answerReportedCall()
|
|
||||||
|
|
||||||
await service.setupCallSession(roomID: "!other:example.com", roomDisplayName: "other", isVideo: true)
|
|
||||||
|
|
||||||
let update = try #require(callProvider.reportCallWithUpdatedReceivedArguments)
|
|
||||||
#expect(update.uuid != answeredUUID)
|
|
||||||
#expect(update.update.localizedCallerName == "other")
|
|
||||||
#expect(service.ongoingCallRoomIDPublisher.value == "!other:example.com")
|
|
||||||
|
|
||||||
// Unlike a web view call, a native one is tracked by CallKit, so it gets the connected report too.
|
|
||||||
service.reportCallSessionConnected(roomID: "!other:example.com")
|
|
||||||
#expect(callProvider.reportOutgoingCallWithConnectedAtReceivedArguments?.uuid == update.uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func nativeCallStackNeedsBothTheSettingAndASession() {
|
|
||||||
#expect(service.nativeCallController == nil)
|
|
||||||
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
#expect(service.nativeCallController == nil, "The setting alone has no transport to run a call over")
|
|
||||||
|
|
||||||
service.setUserSession(nativeUserSession())
|
|
||||||
#expect(service.nativeCallController != nil)
|
|
||||||
|
|
||||||
service.setUserSession(nil)
|
|
||||||
#expect(service.nativeCallController == nil, "Signing out has to release the stack's to-device subscription")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func turningTheSettingOnMidSessionBuildsTheStack() {
|
|
||||||
service.setUserSession(nativeUserSession())
|
|
||||||
#expect(service.nativeCallController == nil)
|
|
||||||
|
|
||||||
// The transport is built on demand, so the setting takes effect without an app restart.
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
#expect(service.nativeCallController != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func aNewSessionGetsANewStack() {
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
service.setUserSession(nativeUserSession())
|
|
||||||
let firstController = service.nativeCallController
|
|
||||||
|
|
||||||
// A soft logout or a cache clear brings a new session without the sign out that drops this
|
|
||||||
// one, and the stack is built against the client proxy it was given.
|
|
||||||
service.setUserSession(nativeUserSession())
|
|
||||||
|
|
||||||
#expect(service.nativeCallController !== firstController)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func callsFallBackToTheWebViewWithoutAStack() {
|
|
||||||
let roomProxy = JoinedRoomProxyMock(.init(id: "!room:example.com"))
|
|
||||||
|
|
||||||
#expect(!service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false))
|
|
||||||
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
service.setUserSession(nativeUserSession())
|
|
||||||
|
|
||||||
#expect(service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a session whose client proxy can serve the native call stack, and holds on to it: the
|
|
||||||
/// service only keeps a weak reference, as the app owns the session.
|
|
||||||
private func nativeUserSession() -> UserSessionMock {
|
|
||||||
let clientProxy = ClientProxyMock(.init())
|
|
||||||
clientProxy.makeNativeCallTransportReturnValue = ElementCallFakeTransport()
|
|
||||||
let session = UserSessionMock(.init(clientProxy: clientProxy))
|
|
||||||
userSession = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Puts the service in native mode, with the stack the CallKit paths check for.
|
|
||||||
private func enableNativeCalls() {
|
|
||||||
appSettings.nativeCallEnabled = true
|
|
||||||
service.setUserSession(nativeUserSession())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delivers a VoIP push and waits for it to be reported, returning once the call is ringing.
|
|
||||||
private func reportIncomingPush(isVoiceCall: Bool) async {
|
|
||||||
await waitForConfirmation { confirmation in
|
|
||||||
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
|
||||||
.updateIsVoice(isVoiceCall)
|
|
||||||
|
|
||||||
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) {
|
|
||||||
confirmation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Answers the call the provider last reported, returning its identifier.
|
|
||||||
@discardableResult
|
|
||||||
private func answerReportedCall() async throws -> UUID {
|
|
||||||
let uuid = try #require(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid)
|
|
||||||
let provider = CXProvider(configuration: CXProviderConfiguration())
|
|
||||||
|
|
||||||
service.provider(provider, perform: CXAnswerCallAction(call: uuid))
|
|
||||||
|
|
||||||
return uuid
|
|
||||||
}
|
|
||||||
|
|
||||||
private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock,
|
private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock,
|
||||||
expectedReason: CXCallEndedReason) async {
|
expectedReason: CXCallEndedReason) async {
|
||||||
let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount
|
let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount
|
||||||
@@ -555,6 +297,11 @@ private nonisolated class PKPushPayloadMock: PKPushPayload {
|
|||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatingExpiration(secondsSince1970: TimeInterval) -> Self {
|
||||||
|
dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = secondsSince1970
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
func updateIsVoice(_ isVoice: Bool) -> Self {
|
func updateIsVoice(_ isVoice: Bool) -> Self {
|
||||||
dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice
|
dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice
|
||||||
return self
|
return self
|
||||||
|
|||||||
Reference in New Issue
Block a user