feat: NSE helper - WPB-22200#4389
Conversation
|
Test Results 2 files 188 suites 22s ⏱️ Results for commit 59a99e2. ♻️ This comment has been updated with latest results. Summary: workflow run #28586749119 |
|
|
||
| private var callingService: any AVSCallingEventServiceProtocol { | ||
| shared { | ||
| AVSCallingEventService( |
There was a problem hiding this comment.
question: Is it OK to have multiple instances of AVSCallingEventService alive simultaneously? Asking this as an NSE can have multiple instances of a NSEClientScope.
There was a problem hiding this comment.
It's a good question. From Dusan's words, wcall_event_create should be called only once when the NSE starts. And every time we receive a push notification, we create a new instance of NSEClientScope, which will contain only one instance of AVSCallingEventService.
netbe
left a comment
There was a problem hiding this comment.
left some comments before approving
| } | ||
| } | ||
|
|
||
| private struct AVSIdentifier { |
There was a problem hiding this comment.
question: don't we have this logic already somewherw?
There was a problem hiding this comment.
Yes, this is in the WireRequestStrategy, because all the calling logic is currently in the RS and SE, I didn't want to significantly change and change the existing legacy architecture to save time and efforts (implementation + testing, especially since we do call testing manually). We plan to migrate this logic to Kalium soon.
| /// | ||
| /// Callback-triggered CallKit work is tracked as pending tasks so the NSE can wait | ||
| /// for reporting to finish before continuing with regular notification generation. | ||
| final class CallKitReportingCoordinator { |
There was a problem hiding this comment.
suggestion: is it possible to make this an actor instead of relying on locks
| notificationEventStream = AsyncStream<[UpdateEvent]> { continuation in | ||
| for batch in eventBatches { | ||
| continuation.yield(batch) | ||
| } | ||
| continuation.finish() | ||
| } |
There was a problem hiding this comment.
question: is there a need for this? can't you just pass eventStream
| timestamp: Date, | ||
| isMLS: Bool | ||
| ) async -> AVSCallParams? { | ||
| let callingConvID = callingProto.qualifiedConversationID |
There was a problem hiding this comment.
nit-pic:
| let callingConvID = callingProto.qualifiedConversationID | |
| let callingConversationID = callingProto.qualifiedConversationID |
| @@ -0,0 +1,49 @@ | |||
| # NSE Calling Events | |||
|
|
|||
| ## Overview | |||
There was a problem hiding this comment.
praise: documentation added
| eventBatches: [[UpdateEvent]], | ||
| callKitReportingCoordinator: CallKitReportingCoordinator | ||
| ) async { | ||
| callingService.start() |
There was a problem hiding this comment.
todo: add cancellation checks in case the NSE cancels the outer task
| func waitForCompletion() async { | ||
|
|
||
| while true { | ||
| let snapshot: [Task<Void, Never>] = { |
There was a problem hiding this comment.
todo: add cancellation checks
| let task = Task { | ||
| do { | ||
| try await CXProvider.reportNewIncomingVoIPPushPayload(callKitContent) | ||
| } catch { |
There was a problem hiding this comment.
question: could you explain why we have this registration mechanism of tasks?
Should we handle cancellation here ?
johnxnguyen
left a comment
There was a problem hiding this comment.
Looks good, just a few questions
| callKitReportingCoordinator.setCallerName( | ||
| params.callerName, | ||
| for: params.conversationId | ||
| ) |
There was a problem hiding this comment.
question: what is the purpose of this?
There was a problem hiding this comment.
don't worry, I see now it's because it'll be used later if we report the call.
|
|
||
| private func avsParametersForProteus( | ||
| _ event: ConversationProteusMessageAddEvent | ||
| ) async -> [AVSCallParams] { |
There was a problem hiding this comment.
question: why does this return an array when the buildParams function it uses returns AVSCallParams?, which is then wrapped in an array and returned, only for the calling function avsParameters(from event) just takes the first value of this array?
| } | ||
|
|
||
| avsService.onMissedCall = { [self] _, _, _ in | ||
| markCallbackReceived() |
There was a problem hiding this comment.
question: why don't we handle the missed call event?
| callerNamesByConversationID[conversationID.storageKey] = callerName | ||
| } | ||
|
|
||
| /// Wait for all callback-started CallKit tasks to finish. |
There was a problem hiding this comment.
question: how many callbacks do we realistically expect to occur? Just one at a time? Or it's valid that there may be multiple?
| var eventBatches: [[UpdateEvent]] = [] | ||
| for await batch in eventStream { | ||
| eventBatches.append(batch) | ||
| } | ||
|
|
||
| _ = callKitReportingCoordinator | ||
| await processCallingEventsUseCase.invoke( | ||
| eventBatches: eventBatches, | ||
| callKitReportingCoordinator: callKitReportingCoordinator | ||
| ) | ||
|
|
||
| notificationEventStream = AsyncStream<[UpdateEvent]> { continuation in | ||
| for batch in eventBatches { | ||
| continuation.yield(batch) | ||
| } | ||
| continuation.finish() | ||
| } |
There was a problem hiding this comment.
question: do I understand this correctly, we will collect all the events, then pass them all to the AVS helper (which may wake the main app to report the call), and only then continue with normal execution to display any other notifications?
There was a problem hiding this comment.
Pull request overview
Introduces an “NSE Helper” path in WireDomain to batch-process call-related notification sync events through AVS (wcall_event_*) inside the Notification Service Extension, and to translate AVS callback results into CallKit reporting actions.
Changes:
- Adds a new calling-event processing pipeline:
ProcessCallingEventsUseCase+AVSCallingEventService+CallKitReportingCoordinator. - Gates legacy CallKit notification behavior behind
DeveloperFlag.enableNSEHelper. - Updates WireDomain docs and package dependencies to support the new NSE calling-event flow.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| WireDomain/Sources/WireDomain/WireDomain.docc/WireDomain.md | Adds navigation entry for NSE calling-event documentation. |
| WireDomain/Sources/WireDomain/WireDomain.docc/nse-calling-events.md | Documents the intended NSE helper event-batching flow and AVS callbacks. |
| WireDomain/Sources/WireDomain/Notifications/ShowNotificationUseCase.swift | Skips waking the main app via CXProvider.reportNewIncomingVoIPPushPayload when the helper flag is enabled. |
| WireDomain/Sources/WireDomain/Notifications/Protocols/ProcessCallingEventsUseCaseProtocol.swift | Adds protocol for the new calling-event batching use case. |
| WireDomain/Sources/WireDomain/Notifications/ProcessCallingEventsUseCase.swift | Implements extraction + forwarding of calling payloads from synced events into AVS. |
| WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift | Integrates calling-event batching into NSE sync flow behind a developer flag. |
| WireDomain/Sources/WireDomain/Notifications/Components/CallKitReportingCoordinator.swift | Adds CallKit reporting orchestration for AVS callback results and completion waiting. |
| WireDomain/Sources/WireDomain/Notifications/Components/AVSCallingEventService.swift | Adds the AVS wcall_event_* bridge and callback plumbing for the NSE helper. |
| WireDomain/Sources/WireDomain/Notifications/Builders/Conversation/ConversationCallingEventNotificationBuilder.swift | Disables building CallKit call notifications when helper flag is enabled. |
| WireDomain/Package.swift | Adds WireAVS dependency to WireDomain. |
| wire-ios-utilities/Source/DeveloperFlag.swift | Introduces enableNSEHelper developer flag. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public init(userID: String, clientID: String) { | ||
| self.handle = 0 | ||
| wcall_set_log_handler({ _, msgPtr, _ in | ||
| guard let msg = msgPtr.flatMap({ String(cString: $0) }) else { return } | ||
| WireLogger.calling.debug(msg, attributes: .newNSE, .safePublic) | ||
| }, nil) | ||
| let retained = Unmanaged.passRetained(self) |
| let service = Unmanaged<AVSCallingEventService>.fromOpaque(contextRef).takeUnretainedValue() | ||
| WireLogger.mls.info("12345 \(service.onIncomingCall == nil), conversationId = \(conversationId)") | ||
| service.onIncomingCall?( |
| conversationId: params.conversationId, | ||
| userId: params.userId, | ||
| clientId: clientID, | ||
| conversationType: params.conversationType | ||
| ) |
| case let .conversation(.proteusMessageAdd(e)): | ||
| await avsParametersForProteus(e).first | ||
| case let .conversation(.mlsMessageAdd(e)): | ||
| await avsParametersForMLS(e).first | ||
| default: |
| _ = callKitReportingCoordinator | ||
| await processCallingEventsUseCase.invoke( | ||
| eventBatches: eventBatches, | ||
| callKitReportingCoordinator: callKitReportingCoordinator | ||
| ) |
| var eventBatches: [[UpdateEvent]] = [] | ||
| for await batch in eventStream { | ||
| eventBatches.append(batch) | ||
| } |
| final class ProcessCallingEventsUseCase: ProcessCallingEventsUseCaseProtocol { | ||
|
|
||
| private let callingService: any AVSCallingEventServiceProtocol | ||
| private let clientID: String | ||
| private let conversationLocalStore: any ConversationLocalStoreProtocol |
|



Issue
NSE Helper is a lightweight AVS instance that runs inside the Notification Service Extension (NSE). Its purpose is to process call-related events while the main app is not running and determine whether a call should be started, updated, or ended.
Unlike the current implementation, where the NSE attempts to infer call state itself, the “NSE Helper” uses the same call-state logic as AVS and acts as the source of truth for call decisions within the NSE process. It processes call events received during notification sync and produces high-level actions such as "incoming call", "missed call", or "close call".
The NSE Helper and the main app each run their own AVS instance and do not share runtime state. Instead, they independently derive call state from the same stream of call events, ensuring that call decisions are made by AVS logic rather than by custom state-tracking code in the NSE.
The NSE helper should call
wcall_event_create()during initialization and retain the returned handle for all subsequent API calls.When a notification is received, the helper should call
wcall_event_start(). During the notification sync process, every call-related event encountered should be passed towcall_event_process().Once syncing is complete, the helper should call
wcall_event_end(). At that point, the library will evaluate all processed events and invoke the callbacks that were registered with wcall_event_create(). Depending on the final state of each call, one of the following callbacks will be triggered:incomingh for a new incoming call
missedh for a missed call
closeh for a call that should be closed
This allows the NSE to process all call events as a batch and receive the resulting call actions only after synchronization has finished.
https://wearezeta.atlassian.net/wiki/spaces/CS1/pages/2395111536/iOS+AVS+background+calls#iOS-workaround
TODO:
Testing
All the calling notifications.
Checklist
[WPB-XXX].UI accessibility checklist
If your PR includes UI changes, please utilize this checklist: