Repository: wireapp/wire-ios
Pull request #2882: refactor: process calling - WPB-11664
Author: login: jullianm
Created: 2025-04-11T10:59:43Z | Merged: 2025-04-15T09:39:01Z

<!--do not remove this marker, its needed to replace info when ticket title is updated -->
<!--jira-description-action-hidden-marker-start-->

<table>
<td>
  <a href="https://wearezeta.atlassian.net/browse/WPB-11664" title="WPB-11664" target="_blank"><img alt="Task" src="https://wearezeta.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10818?size=medium" />WPB-11664</a>  [iOS] Generate UNNotificationContent for incoming call
  </td></table>
  <br />
 

<!--jira-description-action-hidden-marker-end-->
<!--do not remove the jira markers to link tickets automatically -->


### Issue

Tapping on incoming call notification does not show calling screen when back to the main app.
Reason is that MLS and Proteus add message processors didn't handle incoming call.
This PR fixes it.

### Testing

_Describe how to test._

_Optional: attachments like images, videos, etc._

---

### Checklist

- [ ] Title contains a reference JIRA issue number like `[WPB-XXX]`.
- [ ] Description is filled and free of optional paragraphs.
- [ ] Adds/updates automated tests.



Diff:
diff --git a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift
index e00dd56cd0c..a7899793a9f 100644
--- a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift
+++ b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift
@@ -42,6 +42,7 @@ public final class ClientSessionComponent {
     private let mlsDecryptionService: any MLSDecryptionServiceInterface
     private let proteusService: any ProteusServiceInterface
 
+    private let onProcessedCallEvent: (CallEventInfo) -> Void
     private let onSelfClientInvalidated: () async -> Void
 
     public init(
@@ -60,7 +61,8 @@ public final class ClientSessionComponent {
         mlsService: any MLSServiceInterface,
         mlsDecryptionService: any MLSDecryptionServiceInterface,
         proteusService: any ProteusServiceInterface,
-        onSelfClientInvalidated: @escaping () async -> Void
+        onSelfClientInvalidated: @escaping () async -> Void,
+        onProcessedCallEvent: @escaping (CallEventInfo) -> Void
     ) {
         self.selfUserID = selfUserID
         self.selfClientID = selfClientID
@@ -77,6 +79,7 @@ public final class ClientSessionComponent {
         self.localDomain = localDomain
         self.isFederationEnabled = isFederationEnabled
         self.isMLSEnabled = isMLSEnabled
+        self.onProcessedCallEvent = onProcessedCallEvent
         self.onSelfClientInvalidated = onSelfClientInvalidated
     }
 
@@ -430,7 +433,8 @@ public final class ClientSessionComponent {
         conversationLocalStore: conversationLocalStore,
         messageLocalStore: messageLocalStore,
         userLocalStore: userLocalStore,
-        protobufMessageProcessor: conversationProtobufMessageProcessor
+        protobufMessageProcessor: conversationProtobufMessageProcessor,
+        onProcessedCallEvent: onProcessedCallEvent
     )
 
     private lazy var conversationMLSWelcomeEventProcessor = ConversationMLSWelcomeEventProcessor(
@@ -445,7 +449,8 @@ public final class ClientSessionComponent {
         conversationLocalStore: conversationLocalStore,
         messageLocalStore: messageLocalStore,
         userLocalStore: userLocalStore,
-        protobufMessageProcessor: conversationProtobufMessageProcessor
+        protobufMessageProcessor: conversationProtobufMessageProcessor,
+        onProcessedCallEvent: onProcessedCallEvent
     )
 
     private lazy var conversationProtocolUpdateEventProcessor = ConversationProtocolUpdateEventProcessor(
diff --git a/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift b/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift
index 0a865b299a0..31fdf9f7349 100644
--- a/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift
+++ b/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift
@@ -123,7 +123,8 @@ public final class UserSessionComponent {
 
     public func clientSessionComponent(
         clientID: String,
-        onSelfClientInvalidated: @escaping () async -> Void
+        onSelfClientInvalidated: @escaping () async -> Void,
+        onProcessedCallEvent: @escaping (CallEventInfo) -> Void
     ) -> ClientSessionComponent {
         ClientSessionComponent(
             selfUserID: selfUserID,
@@ -141,7 +142,8 @@ public final class UserSessionComponent {
             mlsService: mlsService,
             mlsDecryptionService: mlsDecryptionService,
             proteusService: proteusService,
-            onSelfClientInvalidated: onSelfClientInvalidated
+            onSelfClientInvalidated: onSelfClientInvalidated,
+            onProcessedCallEvent: onProcessedCallEvent
         )
     }
 
diff --git a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift
index 22ba94326c6..ef176d947d4 100644
--- a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift	
+++ b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift	
@@ -31,6 +31,7 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
     let messageLocalStore: any MessageLocalStoreProtocol
     let userLocalStore: any UserLocalStoreProtocol
     let protobufMessageProcessor: any ConversationProtobufMessageProcessorProtocol
+    let onProcessedCallEvent: (CallEventInfo) -> Void
 
     func processEvent(_ event: ConversationMLSMessageAddEvent) async throws {
         let conversationID = event.conversationID
@@ -49,6 +50,7 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
                 decryptedMessage,
                 conversationID: conversationID,
                 senderID: senderID,
+                event: event,
                 date: date
             )
         }
@@ -58,6 +60,7 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
         _ decryptedMessage: ConversationMLSMessageAddEvent.DecryptedMessage,
         conversationID: ConversationID,
         senderID: UserID,
+        event: ConversationMLSMessageAddEvent,
         date: Date?
     ) async throws {
         guard let conversation = await conversationLocalStore.fetchConversation(
@@ -101,6 +104,16 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
             )
         }
 
+        // Handle calling if there's one.
+
+        if let callEventInfo = getCallEventInfo(
+            event: event,
+            decryptedMessage: decryptedMessage,
+            genericMessage: genericMessage
+        ) {
+            return onProcessedCallEvent(callEventInfo)
+        }
+
         await conversationLocalStore.updateSecurityLevelAfterReceivingMessage(
             conversation: conversation,
             genericMessage: genericMessage,
@@ -158,4 +171,53 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
+        event: ConversationMLSMessageAddEvent,
+        decryptedMessage: ConversationMLSMessageAddEvent.DecryptedMessage,
+        genericMessage: GenericMessage
+    ) -> CallEventInfo? {
+        guard genericMessage.hasCalling else {
+            return nil
+        }
+
+        guard let callContent: CallContent = .decode(from: genericMessage.calling) else {
+            return nil
+        }
+
+        guard let payload = genericMessage.calling.content.data(
+            using: .utf8, allowLossyConversion: false
+        ) else {
+            return nil
+        }
+
+        let isRemoteMute = callContent.type == "REMOTEMUTE"
+        let callingConversationID = genericMessage.calling.qualifiedConversationID
+        let senderID = event.senderID
+        let eventTimestamp = event.timestamp
+        let clientID = decryptedMessage.senderClientID
+
+        let conversationID = !callingConversationID.id
+            .isEmpty ? UUID(uuidString: callingConversationID.id)! : event.conversationID.uuid
+
+        let conversationDomain = !callingConversationID.domain.isEmpty ? callingConversationID.domain : event
+            .conversationID.domain
+
+        guard let clientID, let eventTimestamp else {
+            return nil
+        }
+
+        return CallEventInfo(
+            data: payload,
+            conversationID: conversationID,
+            conversationDomain: conversationDomain,
+            userID: senderID.uuid,
+            userDomain: senderID.domain,
+            eventTimestamp: eventTimestamp,
+            clientID: clientID,
+            isMuted: isRemoteMute
+        )
+    }
+
 }
diff --git a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift
index ea8c4b25791..9466797df7f 100644
--- a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift	
+++ b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift	
@@ -26,6 +26,7 @@ struct ConversationProteusMessageAddEventProcessor: ConversationProteusMessageAd
     let messageLocalStore: any MessageLocalStoreProtocol
     let userLocalStore: any UserLocalStoreProtocol
     let protobufMessageProcessor: any ConversationProtobufMessageProcessorProtocol
+    let onProcessedCallEvent: (CallEventInfo) -> Void
 
     func processEvent(_ event: ConversationProteusMessageAddEvent) async throws {
         let senderID = event.senderID
@@ -86,6 +87,15 @@ struct ConversationProteusMessageAddEventProcessor: ConversationProteusMessageAd
             )
         }
 
+        // Handle calling if there's one.
+
+        if let callEventInfo = getCallEventInfo(
+            event: event,
+            genericMessage: genericMessage
+        ) {
+            return onProcessedCallEvent(callEventInfo)
+        }
+
         await conversationLocalStore.updateSecurityLevelAfterReceivingMessage(
             conversation: conversation,
             genericMessage: genericMessage,
@@ -191,4 +201,48 @@ struct ConversationProteusMessageAddEventProcessor: ConversationProteusMessageAd
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
+        event: ConversationProteusMessageAddEvent,
+        genericMessage: GenericMessage
+    ) -> CallEventInfo? {
+        guard genericMessage.hasCalling else {
+            return nil
+        }
+
+        guard let callContent: CallContent = .decode(from: genericMessage.calling) else {
+            return nil
+        }
+
+        guard let payload = genericMessage.calling.content.data(
+            using: .utf8, allowLossyConversion: false
+        ) else {
+            return nil
+        }
+
+        let isRemoteMute = callContent.type == "REMOTEMUTE"
+        let callingConversationID = genericMessage.calling.qualifiedConversationID
+        let senderID = event.senderID
+        let eventTimestamp = event.timestamp
+        let clientID = event.messageSenderClientID
+
+        let conversationID = !callingConversationID.id
+            .isEmpty ? UUID(uuidString: callingConversationID.id)! : event.conversationID.uuid
+
+        let conversationDomain = !callingConversationID.domain.isEmpty ? callingConversationID.domain : event
+            .conversationID.domain
+
+        return CallEventInfo(
+            data: payload,
+            conversationID: conversationID,
+            conversationDomain: conversationDomain,
+            userID: senderID.uuid,
+            userDomain: senderID.domain,
+            eventTimestamp: eventTimestamp,
+            clientID: clientID,
+            isMuted: isRemoteMute
+        )
+    }
+
 }
diff --git a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/Models/CallEventInfo.swift b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/Models/CallEventInfo.swift
new file mode 100644
index 00000000000..6f2f84c0c30
--- /dev/null
+++ b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/Models/CallEventInfo.swift	
@@ -0,0 +1,30 @@
+//
+// Wire
+// Copyright (C) 2025 Wire Swiss GmbH
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see http://www.gnu.org/licenses/.
+//
+
+import Foundation
+
+public struct CallEventInfo {
+    public let data: Data
+    public let conversationID: UUID
+    public let conversationDomain: String?
+    public let userID: UUID
+    public let userDomain: String?
+    public let eventTimestamp: Date
+    public let clientID: String
+    public let isMuted: Bool
+}
diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Conversation/ConversationCallingEventNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Conversation/ConversationCallingEventNotificationBuilder.swift
index 9b15bbf10fd..327f79ce649 100644
--- a/WireDomain/Sources/WireDomain/Notifications/Builders/Conversation/ConversationCallingEventNotificationBuilder.swift
+++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Conversation/ConversationCallingEventNotificationBuilder.swift
@@ -414,12 +414,6 @@ extension ConversationCallingEventNotificationBuilder {
                 domain: conversationID.domain
             )
 
-            let selfUser = await userLocalStore.fetchSelfUser()
-            let caller = await userLocalStore.fetchOrCreateUser(
-                id: senderID.uuid,
-                domain: senderID.domain
-            )
-
             let needsToBeUpdatedFromBackend = await conversationLocalStore.conversationNeedsBackendUpdate(conversation)
             let mutedMessagesTypes = await conversationLocalStore
                 .conversationMutedMessageTypesIncludingAvailability(conversation)
diff --git a/WireDomain/Sources/WireDomain/Notifications/Models/NotificationCategory.swift b/WireDomain/Sources/WireDomain/Notifications/Models/NotificationCategory.swift
index d95fe667b63..2510731fe6f 100644
--- a/WireDomain/Sources/WireDomain/Notifications/Models/NotificationCategory.swift
+++ b/WireDomain/Sources/WireDomain/Notifications/Models/NotificationCategory.swift
@@ -37,7 +37,8 @@ public enum NotificationCategory: String, CaseIterable {
         case .incomingCall:
             [.ignoreCall]
         case .missedCall:
-            [.callback]
+            []
+        // [.callback] TODO: [WPB-17220] Callback is currently broken - disabling this action for now
         case .incomingConnectionRequest:
             [.acceptConnectionRequest]
         }
diff --git a/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessorTests.swift b/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessorTests.swift
index 7c40ab6e308..26087ab1ba5 100644
--- a/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessorTests.swift	
+++ b/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessorTests.swift	
@@ -35,6 +35,8 @@ final class ConversationMLSMessageAddEventProcessorTests: XCTestCase {
     private var coreDataStackHelper: CoreDataStackHelper!
     private var modelHelper: ModelHelper!
 
+    private var callEventInfo: CallEventInfo?
+
     private var context: NSManagedObjectContext {
         coreDataStack.syncContext
     }
@@ -53,7 +55,8 @@ final class ConversationMLSMessageAddEventProcessorTests: XCTestCase {
             conversationLocalStore: conversationLocalStore,
             messageLocalStore: messageLocalStore,
             userLocalStore: userLocalStore,
-            protobufMessageProcessor: protobufMessageProcessor
+            protobufMessageProcessor: protobufMessageProcessor,
+            onProcessedCallEvent: { self.callEventInfo = $0 }
         )
     }
 
@@ -109,6 +112,26 @@ final class ConversationMLSMessageAddEventProcessorTests: XCTestCase {
             conversationLocalStore.addParticipantIfNeededParticipantIDParticipantDomainInDate_Invocations.count,
             1
         )
+
+        XCTAssertNil(callEventInfo)
+    }
+
+    func testProcessEvent_Message_Has_Calling_It_Invokes_Handler_With_Call_Event_Info() async throws {
+
+        let conversation = await context.perform { [self] in
+            modelHelper.createGroupConversation(in: context)
+        }
+
+        conversationLocalStore.fetchConversationIdDomain_MockValue = conversation
+        messageLocalStore.canAddMessageConversationSenderID_MockValue = true
+
+        // When
+
+        try await sut.processEvent(Scaffolding.callingMessageEvent)
+
+        // Then
+
+        XCTAssertNotNil(callEventInfo) // There's a call, we got the call info.
     }
 
     private enum Scaffolding {
@@ -124,6 +147,36 @@ final class ConversationMLSMessageAddEventProcessorTests: XCTestCase {
             )]
         )
 
+        static let callingMessageEvent = ConversationMLSMessageAddEvent(
+            conversationID: ConversationID(uuid: UUID(), domain: "domain.com"),
+            senderID: UserID(uuid: UUID(), domain: "domain.com"),
+            subconversation: "",
+            message: "",
+            timestamp: .now,
+            decryptedMessages: [.init(
+                message: text!,
+                senderClientID: UUID.mockID1.uuidString
+            )]
+        )
+
+        private static let message = GenericMessage(
+            content: Calling(content: content, conversationId: .random())
+        )
+
+        private static let text = try? message.serializedData().base64String()
+
+        private static let content: String = {
+            let json = [
+                "src_userid": UUID.mockID1.uuidString,
+                "src_clientid": "clientID",
+                "resp": false,
+                "type": ""
+            ] as [String: Any]
+
+            let data = try! JSONSerialization.data(withJSONObject: json, options: [])
+            return String(decoding: data, as: UTF8.self)
+        }()
+
         static let base64EncodedString = "CiQ5ZTU2NTQwOS0xODZiLTRlN2YtYTE4NC05NzE4MGE0MDAwMDQSDAoKRXZlcnl0aGluZw=="
     }
 }
diff --git a/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessorTests.swift b/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessorTests.swift
index 6d108b21082..68c4ceeee0e 100644
--- a/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessorTests.swift	
+++ b/WireDomain/Tests/WireDomainTests/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessorTests.swift	
@@ -35,6 +35,8 @@ final class ConversationProteusMessageAddEventProcessorTests: XCTestCase {
     private var coreDataStackHelper: CoreDataStackHelper!
     private var modelHelper: ModelHelper!
 
+    private var callEventInfo: CallEventInfo?
+
     private var context: NSManagedObjectContext {
         coreDataStack.syncContext
     }
@@ -53,7 +55,8 @@ final class ConversationProteusMessageAddEventProcessorTests: XCTestCase {
             conversationLocalStore: conversationLocalStore,
             messageLocalStore: messageLocalStore,
             userLocalStore: userLocalStore,
-            protobufMessageProcessor: protobufMessageProcessor
+            protobufMessageProcessor: protobufMessageProcessor,
+            onProcessedCallEvent: { self.callEventInfo = $0 }
         )
     }
 
@@ -109,6 +112,7 @@ final class ConversationProteusMessageAddEventProcessorTests: XCTestCase {
                 .count,
             1
         )
+        XCTAssertNil(callEventInfo)
 
         let processProtobufMessageInvocation = try XCTUnwrap(
             protobufMessageProcessor
@@ -165,6 +169,8 @@ final class ConversationProteusMessageAddEventProcessorTests: XCTestCase {
             1
         )
 
+        XCTAssertNil(callEventInfo)
+
         let processProtobufMessageInvocation = try XCTUnwrap(
             protobufMessageProcessor
                 .processProtobufMessageContentConversationConversationIDSenderIDSenderClientIDDateEventMessage_Invocations
@@ -182,6 +188,24 @@ final class ConversationProteusMessageAddEventProcessorTests: XCTestCase {
         }
     }
 
+    func testProcessEvent_Message_Has_Calling_It_Invokes_Handler_With_Call_Event_Info() async throws {
+
+        let conversation = await context.perform { [self] in
+            modelHelper.createGroupConversation(in: context)
+        }
+
+        conversationLocalStore.fetchConversationIdDomain_MockValue = conversation
+        messageLocalStore.canAddMessageConversationSenderID_MockValue = true
+
+        // When
+
+        try await sut.processEvent(Scaffolding.callingMessageEvent)
+
+        // Then
+
+        XCTAssertNotNil(callEventInfo) // There's a call, we got the call info.
+    }
+
     enum Scaffolding {
         static let domain = "domain.com"
 
@@ -210,5 +234,33 @@ final class ConversationProteusMessageAddEventProcessorTests: XCTestCase {
             messageRecipientClientID: UUID.mockID2.uuidString
         )
 
+        static let callingMessageEvent = ConversationProteusMessageAddEvent(
+            conversationID: ConversationID(uuid: .mockID1, domain: domain),
+            senderID: UserID(uuid: .mockID1, domain: domain),
+            timestamp: .now,
+            message: .init(encryptedMessage: "", decryptedMessage: text!),
+            // Message has calling
+            messageSenderClientID: UUID.mockID1.uuidString,
+            messageRecipientClientID: UUID.mockID2.uuidString
+        )
+
+        private static let message = GenericMessage(
+            content: Calling(content: content, conversationId: .random())
+        )
+
+        private static let text = try? message.serializedData().base64String()
+
+        private static let content: String = {
+            let json = [
+                "src_userid": UUID.mockID1.uuidString,
+                "src_clientid": "clientID",
+                "resp": false,
+                "type": ""
+            ] as [String: Any]
+
+            let data = try! JSONSerialization.data(withJSONObject: json, options: [])
+            return String(decoding: data, as: UTF8.self)
+        }()
+
     }
 }
diff --git a/wire-ios-sync-engine/Source/Calling/WireCallCenterV3.swift b/wire-ios-sync-engine/Source/Calling/WireCallCenterV3.swift
index daaddd5f251..e94f7c22f5f 100644
--- a/wire-ios-sync-engine/Source/Calling/WireCallCenterV3.swift
+++ b/wire-ios-sync-engine/Source/Calling/WireCallCenterV3.swift
@@ -1030,7 +1030,7 @@ extension WireCallCenterV3 {
         updateMLSConferenceIfNeededForMissedCall(conversationID: conversationId)
     }
 
-    /// Handles incoming OTR calling messages, and transmist them to AVS when it is ready to process events, or adds it
+    /// Handles incoming calling messages, and transmist them to AVS when it is ready to process events, or adds it
     /// to the `bufferedEvents`.
     /// - parameter callEvent: calling event to process.
     func processCallEvent(_ callEvent: CallEvent) {
diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+Push.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+Push.swift
index 9111a2dcb73..d12b2819646 100644
--- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+Push.swift
+++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+Push.swift
@@ -201,8 +201,9 @@ extension ZMUserSession: UNUserNotificationCenterDelegate {
         case ConversationNotificationAction.connect.rawValue,
              NotificationActionIdentifier.acceptConnectionRequestIdentifier:
             acceptConnectionRequest(with: userInfo, completionHandler: completionHandler)
-        case NotificationActionIdentifier.callbackIdentifier:
-            callback(with: userInfo, completionHandler: completionHandler)
+        // TODO: [WPB-17220] new NSE - callback action is currently broken - disabling this action for now
+//        case NotificationActionIdentifier.callbackIdentifier:
+//            callback(with: userInfo, completionHandler: completionHandler)
         default:
             showContent(for: userInfo)
             completionHandler()
diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
index bb787817eef..86c3bdf4361 100644
--- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
+++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
@@ -582,7 +582,8 @@ public final class ZMUserSession: NSObject {
 
         let clientSessionComponent = userSessionComponent.clientSessionComponent(
             clientID: clientID,
-            onSelfClientInvalidated: onSelfClientInvalidated
+            onSelfClientInvalidated: onSelfClientInvalidated,
+            onProcessedCallEvent: onProcessedCallEvent(callEventInfo:)
         )
 
         let syncAgent = SyncAgent(
@@ -597,6 +598,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onProcessedCallEvent(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta // serverTimeDelta can only be accessed on the sync context
+        }
+
+        viewContext.perform { [weak self] in // callCenter can only be accessed on the ui context
+            guard let self, let callCenter else { return }
+            guard !callEventInfo.isMuted else {
+                callCenter.isMuted = true
+                return
+            }
+
+            let conversationId = AVSIdentifier(
+                identifier: callEventInfo.conversationID,
+                domain: callEventInfo.conversationDomain
+            )
+
+            let userId = AVSIdentifier(
+                identifier: callEventInfo.userID,
+                domain: callEventInfo.userDomain
+            )
+
+            let callEvent = CallEvent(
+                data: callEventInfo.data,
+                currentTimestamp: Date.now.addingTimeInterval(serverTimeDelta),
+                serverTimestamp: callEventInfo.eventTimestamp,
+                conversationId: conversationId,
+                userId: userId,
+                clientId: callEventInfo.clientID
+            )
+
+            callCenter.processCallEvent(callEvent)
+        }
+
+    }
+
     // MARK: - Deinitalize
 
     deinit {


Reviews:
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T11:02:24Z
user: login: netbe
body: left a couple of comments. Note: the diff looks wrong, maybe targeted PR needs to update
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:49:27Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:19:19Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:52:47Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:53:08Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:53:16Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:53:20Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:53:47Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:37:43Z
user: login: dmitrysimkin
body: 👍 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:38:24Z
user: login: jullianm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:41:20Z
user: login: dmitrysimkin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:47:03Z
user: login: netbe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T08:04:21Z
user: login: netbe
body: Looks good
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-15T08:23:49Z

Review comments:
diff_hunk: @@ -414,12 +414,6 @@ extension ConversationCallingEventNotificationBuilder {
                 domain: conversationID.domain
             )
 
-            let selfUser = await userLocalStore.fetchSelfUser()
-            let caller = await userLocalStore.fetchOrCreateUser(
-                id: senderID.uuid,
-                domain: senderID.domain
-            )
path: WireDomain/Sources/WireDomain/Notifications/Builders/Conversation/ConversationCallingEventNotificationBuilder.swift
user: login: jullianm
body: dead code
created_at: 2025-04-11T11:02:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,12 +20,24 @@ import WireAPI
 import WireDataModel
 import WireLogging
 
+public struct CallEventInfo {
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift
user: login: netbe
body: suggestion: move to own file
created_at: 2025-04-11T13:35:56Z
author_association: COLLABORATOR
diff_hunk: @@ -191,4 +212,48 @@ struct ConversationProteusMessageAddEventProcessor: ConversationProteusMessageAd
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift
user: login: netbe
body: is it the same logic in MLS side too? 
suggestion move common code to common object
created_at: 2025-04-11T13:38:08Z
author_association: COLLABORATOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: netbe
body: suggestion: add a comment to state on which thread is this called
created_at: 2025-04-11T13:43:27Z
author_association: COLLABORATOR
diff_hunk: @@ -558,7 +558,11 @@ public final class ZMUserSession: NSObject {
     }
 
     private func setUpSyncAgent(clientID: String) {
-        let clientSessionComponent = userSessionComponent.clientSessionComponent(clientID: clientID)
+        let clientSessionComponent = userSessionComponent.clientSessionComponent(
+            clientID: clientID,
+            onCalling: onCalling(callEventInfo:)
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: netbe
body: suggestion: `onProcessedCallEvent`
created_at: 2025-04-11T13:44:10Z
author_association: COLLABORATOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta
+        }
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: netbe
body: question: is serverTimeDelta saved only on syncContext?
created_at: 2025-04-11T13:45:18Z
author_association: COLLABORATOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta
+        }
+
+        viewContext.perform { [weak self] in
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: netbe
body: question: why is it executed on viewContext?
created_at: 2025-04-11T13:46:33Z
author_association: COLLABORATOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta
+        }
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: jullianm
body: yes, I'll add a comment
created_at: 2025-04-14T15:19:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,12 +20,24 @@ import WireAPI
 import WireDataModel
 import WireLogging
 
+public struct CallEventInfo {
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift
user: login: jullianm
body: done in https://github.com/wireapp/wire-ios/pull/2882/commits/f9a0c77c1ba8060993345b92cfd6dbc6f7d87865 thx
created_at: 2025-04-14T15:52:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: jullianm
body: https://github.com/wireapp/wire-ios/pull/2882/commits/f9a0c77c1ba8060993345b92cfd6dbc6f7d87865
created_at: 2025-04-14T15:53:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -558,7 +558,11 @@ public final class ZMUserSession: NSObject {
     }
 
     private func setUpSyncAgent(clientID: String) {
-        let clientSessionComponent = userSessionComponent.clientSessionComponent(clientID: clientID)
+        let clientSessionComponent = userSessionComponent.clientSessionComponent(
+            clientID: clientID,
+            onCalling: onCalling(callEventInfo:)
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: jullianm
body: done in https://github.com/wireapp/wire-ios/pull/2882/commits/f9a0c77c1ba8060993345b92cfd6dbc6f7d87865
created_at: 2025-04-14T15:53:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta
+        }
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: jullianm
body: https://github.com/wireapp/wire-ios/pull/2882/commits/f9a0c77c1ba8060993345b92cfd6dbc6f7d87865
created_at: 2025-04-14T15:53:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -571,6 +575,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onCalling(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta
+        }
+
+        viewContext.perform { [weak self] in
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: jullianm
body: callCenter needs to be accessed from the ui context, added comment in https://github.com/wireapp/wire-ios/pull/2882/commits/f9a0c77c1ba8060993345b92cfd6dbc6f7d87865
created_at: 2025-04-14T15:53:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -191,4 +212,48 @@ struct ConversationProteusMessageAddEventProcessor: ConversationProteusMessageAd
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProteusMessageAddEventProcessor.swift
user: login: jullianm
body: @netbe While I agree the code looks pretty similar and logic is overall the same, both methods have different parameters. We could streamline the method and factor it out in a separated object however  I'm not sure introducing an extra type for a few lines of code has a lot of benefits here.
created_at: 2025-04-15T07:37:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,4 +171,53 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift
user: login: dmitrysimkin
body: question: Logic here and in `ConversationProteusMessageAddEventProcessor. getCallEventInfo` are identical? can be reused somehow?
created_at: 2025-04-15T07:38:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,4 +171,53 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift
user: login: jullianm
body: please refer to my response to François above
created_at: 2025-04-15T07:41:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,4 +171,53 @@ struct ConversationMLSMessageAddEventProcessor: ConversationMLSMessageAddEventPr
         )
     }
 
+    // MARK: - Calling
+
+    func getCallEventInfo(
path: WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationMLSMessageAddEventProcessor.swift
user: login: dmitrysimkin
body: Sorry,  missed that, thanks
created_at: 2025-04-15T07:47:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -597,6 +598,42 @@ public final class ZMUserSession: NSObject {
         syncAgent.resume()
     }
 
+    func onProcessedCallEvent(callEventInfo: CallEventInfo) {
+        let serverTimeDelta = syncContext.performAndWait {
+            syncContext.serverTimeDelta // serverTimeDelta can only be accessed on the sync context
path: wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift
user: login: netbe
body: praise: comment is useful
created_at: 2025-04-15T08:04:21Z
author_association: COLLABORATOR

PR comments:
user: login: jullianm
created_at: 2025-04-11T13:13:18Z
body: this targets the NSE branch
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-11T13:50:01Z
body: ## Test Results
0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit a6e904c7.

[test-results]:data:application/gzip;base64,H4sIAOgT/mcC/1WMQQrDIBBFrxJm3YWVGrGXKTpxYGgSi6Or0rs3pKHR3X/vw3sD8RwF7oO6DCCVyx+mmn3htB64HUXa/ZCK2IsnvzpBnudOxJxTPkyuqzSzzf34rO3cxHZuW5iWhcsG4Mfo1A2t047C1UYVImmDE2pvQlDGkzWkkeDzBZRPqyf7AAAA

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR