Add support for showing the native OS incoming call screen when starting new Element Calls

This commit is contained in:
Stefan Ceriu 2024-05-17 15:13:57 +03:00 committed by Stefan Ceriu
parent 5860317fea
commit 8bfd802793
19 changed files with 460 additions and 66 deletions

View File

@ -28,6 +28,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private let appMediator: AppMediator
private let appSettings: AppSettings
private let appDelegate: AppDelegate
private let elementCallService: ElementCallServiceProtocol
/// Common background task to continue long-running tasks in the background.
private var backgroundTask: UIBackgroundTaskIdentifier?
@ -85,6 +86,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
self.appSettings = appSettings
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
elementCallService = ElementCallService()
navigationRootCoordinator = NavigationRootCoordinator()
Self.setupServiceLocator(appSettings: appSettings)
@ -131,6 +134,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
observeAppLockChanges()
registerBackgroundAppRefresh()
elementCallService.actions.sink { [weak self] action in
switch action {
case .answerCall(let roomID):
self?.handleAppRoute(.call(roomID: roomID))
case .declineCall:
break
}
}
.store(in: &cancellables)
}
func start() {
@ -482,6 +495,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
navigationRootCoordinator: navigationRootCoordinator,
appLockService: appLockFlowCoordinator.appLockService,
bugReportService: ServiceLocator.shared.bugReportService,
elementCallService: elementCallService,
roomTimelineControllerFactory: RoomTimelineControllerFactory(),
appMediator: appMediator,
appSettings: appSettings,

View File

@ -44,6 +44,8 @@ enum AppRoute: Equatable {
case childEventOnRoomAlias(eventID: String, alias: String)
/// The profile of a matrix user (outside of a room).
case userProfile(userID: String)
/// An Element Call running in a particular room
case call(roomID: String)
/// An Element Call link generated outside of a chat room.
case genericCallLink(url: URL)
/// The settings screen.

View File

@ -168,7 +168,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above.
case .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
case .roomList, .userProfile, .call, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
break // These routes can't be handled.
}
}

View File

@ -29,6 +29,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let navigationRootCoordinator: NavigationRootCoordinator
private let navigationSplitCoordinator: NavigationSplitCoordinator
private let bugReportService: BugReportServiceProtocol
private let elementCallService: ElementCallServiceProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analytics: AnalyticsService
@ -69,6 +70,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
navigationRootCoordinator: NavigationRootCoordinator,
appLockService: AppLockServiceProtocol,
bugReportService: BugReportServiceProtocol,
elementCallService: ElementCallServiceProtocol,
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
@ -79,6 +81,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
self.userSession = userSession
self.navigationRootCoordinator = navigationRootCoordinator
self.bugReportService = bugReportService
self.elementCallService = elementCallService
self.roomTimelineControllerFactory = roomTimelineControllerFactory
self.appMediator = appMediator
self.appSettings = appSettings
@ -247,6 +250,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .userProfile(let userID):
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
case .call(let roomID):
Task {
await presentCallScreen(roomID: roomID)
}
case .genericCallLink(let url):
navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
case .settings, .chatBackupSettings:
@ -537,7 +544,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: Calls
private func presentCallScreen(roomProxy: RoomProxyProtocol) {
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(roomProxy: roomProxy,
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
roomProxy: roomProxy,
callBaseURL: appSettings.elementCallBaseURL,
clientID: InfoPlistReader.main.bundleIdentifier))

View File

@ -4230,6 +4230,88 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
setSuggestionTriggerClosure?(suggestionTrigger)
}
}
class ElementCallServiceMock: ElementCallServiceProtocol {
var actions: AnyPublisher<ElementCallServiceAction, Never> {
get { return underlyingActions }
set(value) { underlyingActions = value }
}
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
//MARK: - setupCallSession
var setupCallSessionTitleUnderlyingCallsCount = 0
var setupCallSessionTitleCallsCount: Int {
get {
if Thread.isMainThread {
return setupCallSessionTitleUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = setupCallSessionTitleUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
setupCallSessionTitleUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
setupCallSessionTitleUnderlyingCallsCount = newValue
}
}
}
}
var setupCallSessionTitleCalled: Bool {
return setupCallSessionTitleCallsCount > 0
}
var setupCallSessionTitleReceivedTitle: String?
var setupCallSessionTitleReceivedInvocations: [String] = []
var setupCallSessionTitleClosure: ((String) async -> Void)?
func setupCallSession(title: String) async {
setupCallSessionTitleCallsCount += 1
setupCallSessionTitleReceivedTitle = title
setupCallSessionTitleReceivedInvocations.append(title)
await setupCallSessionTitleClosure?(title)
}
//MARK: - tearDownCallSession
var tearDownCallSessionUnderlyingCallsCount = 0
var tearDownCallSessionCallsCount: Int {
get {
if Thread.isMainThread {
return tearDownCallSessionUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = tearDownCallSessionUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
tearDownCallSessionUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
tearDownCallSessionUnderlyingCallsCount = newValue
}
}
}
}
var tearDownCallSessionCalled: Bool {
return tearDownCallSessionCallsCount > 0
}
var tearDownCallSessionClosure: (() -> Void)?
func tearDownCallSession() {
tearDownCallSessionCallsCount += 1
tearDownCallSessionClosure?()
}
}
class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol {
var messagePublisher: PassthroughSubject<String, Never> {
get { return underlyingMessagePublisher }
@ -9780,6 +9862,70 @@ class RoomProxyMock: RoomProxyProtocol {
return elementCallWidgetDriverReturnValue
}
}
//MARK: - sendCallNotificationIfNeeeded
var sendCallNotificationIfNeeededUnderlyingCallsCount = 0
var sendCallNotificationIfNeeededCallsCount: Int {
get {
if Thread.isMainThread {
return sendCallNotificationIfNeeededUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = sendCallNotificationIfNeeededUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
sendCallNotificationIfNeeededUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
sendCallNotificationIfNeeededUnderlyingCallsCount = newValue
}
}
}
}
var sendCallNotificationIfNeeededCalled: Bool {
return sendCallNotificationIfNeeededCallsCount > 0
}
var sendCallNotificationIfNeeededUnderlyingReturnValue: Result<Void, RoomProxyError>!
var sendCallNotificationIfNeeededReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return sendCallNotificationIfNeeededUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = sendCallNotificationIfNeeededUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
sendCallNotificationIfNeeededUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
sendCallNotificationIfNeeededUnderlyingReturnValue = newValue
}
}
}
}
var sendCallNotificationIfNeeededClosure: (() async -> Result<Void, RoomProxyError>)?
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> {
sendCallNotificationIfNeeededCallsCount += 1
if let sendCallNotificationIfNeeededClosure = sendCallNotificationIfNeeededClosure {
return await sendCallNotificationIfNeeededClosure()
} else {
return sendCallNotificationIfNeeededReturnValue
}
}
//MARK: - matrixToPermalink
var matrixToPermalinkUnderlyingCallsCount = 0

View File

@ -143,6 +143,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length))
}
// swiftlint:disable:next cyclomatic_complexity
private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) {
let string = attributedString.string

View File

@ -18,10 +18,9 @@ import Combine
import SwiftUI
struct CallScreenCoordinatorParameters {
let elementCallService: ElementCallServiceProtocol
let roomProxy: RoomProxyProtocol
/// Which Element Call instance should be used
let callBaseURL: URL
/// A way to identify the current client against Element Call
let clientID: String
}
@ -39,7 +38,8 @@ final class CallScreenCoordinator: CoordinatorProtocol {
}
init(parameters: CallScreenCoordinatorParameters) {
viewModel = CallScreenViewModel(roomProxy: parameters.roomProxy,
viewModel = CallScreenViewModel(elementCallService: parameters.elementCallService,
roomProxy: parameters.roomProxy,
callBaseURL: parameters.callBaseURL,
clientID: parameters.clientID)
}

View File

@ -14,7 +14,6 @@
// limitations under the License.
//
import AVFoundation
import CallKit
import Combine
import SwiftUI
@ -22,33 +21,31 @@ import SwiftUI
typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, CallScreenViewAction>
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
private let elementCallService: ElementCallServiceProtocol
private let roomProxy: RoomProxyProtocol
private let widgetDriver: ElementCallWidgetDriverProtocol
private let callController = CXCallController()
// periphery: ignore - call kit magic do not remove
private let callProvider = CXProvider(configuration: .init())
private let callID = UUID()
private let actionsSubject: PassthroughSubject<CallScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<CallScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
deinit {
tearDownVoIPSession(callID: callID)
elementCallService.tearDownCallSession()
}
/// Designated initialiser
/// - Parameters:
/// - elementCallService: service responsible for setting up CallKit
/// - roomProxy: The room in which the call should be created
/// - callBaseURL: Which Element Call instance should be used
/// - clientID: Something to identify the current client on the Element Call side
init(roomProxy: RoomProxyProtocol,
init(elementCallService: ElementCallServiceProtocol,
roomProxy: RoomProxyProtocol,
callBaseURL: URL,
clientID: String) {
self.elementCallService = elementCallService
self.roomProxy = roomProxy
widgetDriver = roomProxy.elementCallWidgetDriver()
@ -109,11 +106,9 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
return
}
do {
try await setupVoIPSession(callID: callID)
} catch {
MXLog.error("Failed setting up VoIP session with error: \(error)")
}
await elementCallService.setupCallSession(title: roomProxy.roomTitle)
let _ = await roomProxy.sendCallNotificationIfNeeeded()
}
}
@ -125,20 +120,6 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
}
}
// MARK: - CXCallObserverDelegate
// periphery: ignore - call kit magic do not remove
func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
MXLog.info("Call changed: \(call)")
}
// MARK: - CXProviderDelegate
// periphery: ignore - call kit magic do not remove
func providerDidReset(_ provider: CXProvider) {
MXLog.info("Call provider did reset: \(provider)")
}
// MARK: - Private
private static let eventHandlerName = "elementx"
@ -160,34 +141,4 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
);
"""
}
private func setupVoIPSession(callID: UUID) async throws {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: [])
try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
let handle = CXHandle(type: .generic, value: roomProxy.roomTitle)
let startCallAction = CXStartCallAction(call: callID, handle: handle)
startCallAction.isVideo = true
let transaction = CXTransaction(action: startCallAction)
try await callController.request(transaction)
}
private nonisolated func tearDownVoIPSession(callID: UUID?) {
guard let callID else {
return
}
try? AVAudioSession.sharedInstance().setActive(false)
let endCallAction = CXEndCallAction(call: callID)
let transaction = CXTransaction(action: endCallAction)
callController.request(transaction) { error in
if let error {
MXLog.error("Failed transaction with error: \(error)")
}
}
}
}

View File

@ -167,7 +167,8 @@ struct CallScreen_Previews: PreviewProvider {
roomProxy.elementCallWidgetDriverReturnValue = widgetDriver
return CallScreenViewModel(roomProxy: roomProxy,
return CallScreenViewModel(elementCallService: ElementCallServiceMock(),
roomProxy: roomProxy,
callBaseURL: "https://call.element.io",
clientID: "io.element.elementx")
}()

View File

@ -0,0 +1,158 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import AVFoundation
import CallKit
import Combine
import Foundation
import PushKit
class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate {
private let pushRegistry: PKPushRegistry
private let callController = CXCallController()
private var callProvider: CXProvider?
private var ongoingCallID: UUID?
private var incomingCallRoomID: String?
private let actionsSubject: PassthroughSubject<ElementCallServiceAction, Never> = .init()
var actions: AnyPublisher<ElementCallServiceAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
override init() {
pushRegistry = PKPushRegistry(queue: nil)
super.init()
pushRegistry.delegate = self
pushRegistry.desiredPushTypes = [.voIP]
}
func setupCallSession(title: String) async {
guard ongoingCallID == nil else {
return
}
let callID = UUID()
ongoingCallID = callID
let handle = CXHandle(type: .generic, value: title)
let startCallAction = CXStartCallAction(call: callID, handle: handle)
startCallAction.isVideo = true
let transaction = CXTransaction(action: startCallAction)
do {
try await callController.request(transaction)
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: [])
try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
} catch {
MXLog.error("Failed setting up VoIP session with error: \(error)")
tearDownCallSession()
}
}
func tearDownCallSession() {
guard let ongoingCallID else {
return
}
try? AVAudioSession.sharedInstance().setActive(false)
let endCallAction = CXEndCallAction(call: ongoingCallID)
let transaction = CXTransaction(action: endCallAction)
callController.request(transaction) { error in
if let error {
MXLog.error("Failed transaction with error: \(error)")
}
}
}
// MARK: - PKPushRegistryDelegate
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { }
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else {
MXLog.error("Somethnig went wrong, missing room identifier for incoming voip call: \(payload)")
return
}
let callID = UUID()
ongoingCallID = callID
incomingCallRoomID = roomID
let configuration = CXProviderConfiguration()
configuration.supportsVideo = true
configuration.includesCallsInRecents = false
let update = CXCallUpdate()
update.hasVideo = true
update.localizedCallerName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String
if let senderDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.senderDisplayName.rawValue] as? String {
update.remoteHandle = .init(type: .generic, value: senderDisplayName)
} else if let senderID = payload.dictionaryPayload[ElementCallServiceNotificationKey.senderID.rawValue] as? String {
update.remoteHandle = .init(type: .generic, value: senderID)
} else {
MXLog.error("Something went wrong, both the user display name and ID are nil")
}
let callProvider = CXProvider(configuration: configuration)
callProvider.setDelegate(self, queue: nil)
callProvider.reportNewIncomingCall(with: callID, update: update) { error in
if let error {
MXLog.error("Failed reporting new incoming call with error: \(error)")
}
completion()
}
}
// MARK: - CXProviderDelegate
func providerDidReset(_ provider: CXProvider) {
MXLog.info("Call provider did reset: \(provider)")
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
if let incomingCallRoomID {
actionsSubject.send(.answerCall(roomID: incomingCallRoomID))
self.incomingCallRoomID = nil
} else {
MXLog.error("Failed answering incoming call, missing room ID")
}
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
if let incomingCallRoomID {
actionsSubject.send(.declineCall(roomID: incomingCallRoomID))
self.incomingCallRoomID = nil
} else {
MXLog.error("Failed declining incoming call, missing room ID")
}
action.fulfill()
}
}

View File

@ -0,0 +1,40 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
enum ElementCallServiceAction {
case answerCall(roomID: String)
case declineCall(roomID: String)
}
enum ElementCallServiceNotificationKey: String {
case roomID
case roomDisplayName
case senderID
case senderDisplayName
}
let ElementCallServiceNotificationDiscardDelta = 10.0
// sourcery: AutoMockable
protocol ElementCallServiceProtocol {
var actions: AnyPublisher<ElementCallServiceAction, Never> { get }
func setupCallSession(title: String) async
func tearDownCallSession()
}

View File

@ -521,6 +521,16 @@ class RoomProxy: RoomProxyProtocol {
ElementCallWidgetDriver(room: room)
}
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> {
do {
try await room.sendCallNotificationIfNeeded()
return .success(())
} catch {
MXLog.error("Failed room call notification with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Permalinks
func matrixToPermalink() async -> Result<URL, RoomProxyError> {

View File

@ -129,6 +129,8 @@ protocol RoomProxyProtocol {
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError>
// MARK: - Permalinks
func matrixToPermalink() async -> Result<URL, RoomProxyError>

View File

@ -505,6 +505,7 @@ class MockScreen: Identifiable {
appLockService: AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings),
bugReportService: BugReportServiceMock(),
elementCallService: ElementCallServiceMock(),
roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init()),
appMediator: AppMediatorMock.default,
appSettings: appSettings,

View File

@ -80,8 +80,9 @@
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
<string>voip</string>
</array>
<key>UILaunchScreen</key>

View File

@ -90,8 +90,9 @@ targets:
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
NSFaceIDUsageDescription: Face ID is used to access your app.
UIBackgroundModes: [
fetch,
audio,
fetch,
processing,
voip
]
BGTaskSchedulerPermittedIdentifiers: [

View File

@ -46,6 +46,8 @@ struct NotificationContentBuilder {
return try await processPollStartEvent(notificationItem: notificationItem, pollQuestion: question, mediaProvider: mediaProvider)
case .callInvite:
return try await processCallInviteEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
case .callNotify:
return try await processCallNotifyEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
default:
return processEmpty(notificationItem: notificationItem)
}
@ -136,6 +138,12 @@ struct NotificationContentBuilder {
notification.body = L10n.commonCallInvite
return notification
}
private func processCallNotifyEvent(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider)
notification.body = L10n.commonCallStarted
return notification
}
private func processCommonRoomMessage(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = baseMutableContent(for: notificationItem)

View File

@ -14,6 +14,7 @@
// limitations under the License.
//
import CallKit
import Intents
import MatrixRustSDK
import UserNotifications
@ -104,6 +105,10 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
return discard(unreadCount: unreadCount)
}
guard await shouldHandleCallNotification(itemProxy) else {
return discard(unreadCount: unreadCount)
}
// After the first processing, update the modified content
modifiedContent = try await notificationContentBuilder.content(for: itemProxy, mediaProvider: nil)
@ -173,4 +178,48 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
NSELogger.logMemory(with: tag)
MXLog.info("\(tag) deinit")
}
private func shouldHandleCallNotification(_ itemProxy: NotificationItemProxyProtocol) async -> Bool {
// Handle incoming VoIP calls, show the native OS call screen
// https://developer.apple.com/documentation/callkit/sending-end-to-end-encrypted-voip-calls
//
// The way this works is the following:
// - the NSE receives the notification and decrypts it
// - checks if it's still time relevant (max 10 seconds old) and whether it should ring
// - otherwise it goes on to show it as a normal notification
// - if it should ring then it discards the notification but invokes `reportNewIncomingVoIPPushPayload`
// so that the main app can handle it
// - the main app picks this up in `PKPushRegistry.didReceiveIncomingPushWith` and
// `CXProvider.reportNewIncomingCall` to show the system UI and handle actions on it.
// N.B. this flow works properly only when background processing capabilities are enabled
guard case let .timeline(event) = itemProxy.event,
case let .messageLike(content) = try? event.eventType(),
case let .callNotify(notificationType) = content,
notificationType == .ring else {
return true
}
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.timestamp() / 1000))
guard abs(timestamp.timeIntervalSinceNow) < ElementCallServiceNotificationDiscardDelta else {
MXLog.info("Call notification is too old, handling as push notification")
return true
}
var payload = [ElementCallServiceNotificationKey.roomID.rawValue: itemProxy.roomID,
ElementCallServiceNotificationKey.roomDisplayName.rawValue: itemProxy.roomDisplayName,
ElementCallServiceNotificationKey.senderID.rawValue: itemProxy.senderID]
if let senderDisplayName = itemProxy.senderDisplayName {
payload[ElementCallServiceNotificationKey.senderDisplayName.rawValue] = senderDisplayName
}
do {
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
} catch {
MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification")
return true
}
return false
}
}

View File

@ -104,4 +104,5 @@ targets:
- path: ../../ElementX/Sources/Services/Notification/Proxy
- path: ../../ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift
- path: ../../ElementX/Sources/Services/UserSession/RestorationToken.swift
- path: ../../ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift
- path: ../../ElementX/Sources/Application/AppSettings.swift