Skip to content

feat: NSE helper - WPB-22200#4389

Open
KaterinaWire wants to merge 33 commits into
developfrom
feat/avs_for_nse
Open

feat: NSE helper - WPB-22200#4389
KaterinaWire wants to merge 33 commits into
developfrom
feat/avs_for_nse

Conversation

@KaterinaWire

@KaterinaWire KaterinaWire commented Mar 4, 2026

Copy link
Copy Markdown
Contributor
TaskWPB-22200 [iOS] Research into background calls issues

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 to wcall_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:

  • add tests
  • add logs

Testing

All the calling notifications.

Checklist

  • Title contains a reference JIRA issue number like [WPB-XXX].
  • Description is filled and free of optional paragraphs.
  • Adds/updates automated tests.

UI accessibility checklist

If your PR includes UI changes, please utilize this checklist:

  • Make sure you use the API for UI elements that support large fonts.
  • All colors are taken from WireDesign.ColorTheme or constructed using WireDesign.BaseColorPalette.
  • New UI elements have Accessibility strings for VoiceOver.

@sonarqubecloud

Copy link
Copy Markdown

@KaterinaWire KaterinaWire changed the title feat: NSE helper feat: NSE helper - DRAFT (THIS MEANTS THAT SOME INFO AND LOGS WILL BE REMOVED) May 29, 2026
@KaterinaWire KaterinaWire changed the title feat: NSE helper - DRAFT (THIS MEANTS THAT SOME INFO AND LOGS WILL BE REMOVED) feat: NSE helper Jun 15, 2026
@KaterinaWire KaterinaWire marked this pull request as ready for review June 15, 2026 07:33
@KaterinaWire KaterinaWire changed the title feat: NSE helper feat: NSE helper - WPB-22200 Jun 15, 2026
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Test Results

  2 files  188 suites   22s ⏱️
846 tests 846 ✅ 0 💤 0 ❌
847 runs  847 ✅ 0 💤 0 ❌

Results for commit 59a99e2.

♻️ This comment has been updated with latest results.

Summary: workflow run #28586749119
Allure report (download zip): html-report-31456-feat_avs_for_nse


private var callingService: any AVSCallingEventServiceProtocol {
shared {
AVSCallingEventService(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is it OK to have multiple instances of AVSCallingEventService alive simultaneously? Asking this as an NSE can have multiple instances of a NSEClientScope.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 netbe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left some comments before approving

}
}

private struct AVSIdentifier {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: don't we have this logic already somewherw?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: is it possible to make this an actor instead of relying on locks

Comment on lines +167 to +172
notificationEventStream = AsyncStream<[UpdateEvent]> { continuation in
for batch in eventBatches {
continuation.yield(batch)
}
continuation.finish()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is there a need for this? can't you just pass eventStream

timestamp: Date,
isMLS: Bool
) async -> AVSCallParams? {
let callingConvID = callingProto.qualifiedConversationID

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit-pic:

Suggested change
let callingConvID = callingProto.qualifiedConversationID
let callingConversationID = callingProto.qualifiedConversationID

@@ -0,0 +1,49 @@
# NSE Calling Events

## Overview

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: documentation added

eventBatches: [[UpdateEvent]],
callKitReportingCoordinator: CallKitReportingCoordinator
) async {
callingService.start()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: add cancellation checks in case the NSE cancels the outer task

func waitForCompletion() async {

while true {
let snapshot: [Task<Void, Never>] = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: add cancellation checks

let task = Task {
do {
try await CXProvider.reportNewIncomingVoIPPushPayload(callKitContent)
} catch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: could you explain why we have this registration mechanism of tasks?
Should we handle cancellation here ?

@johnxnguyen johnxnguyen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just a few questions

Comment on lines +69 to +72
callKitReportingCoordinator.setCallerName(
params.callerName,
for: params.conversationId
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what is the purpose of this?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: why don't we handle the missed call event?

callerNamesByConversationID[conversationID.storageKey] = callerName
}

/// Wait for all callback-started CallKit tasks to finish.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: how many callbacks do we realistically expect to occur? Just one at a time? Or it's valid that there may be multiple?

Comment on lines +156 to +172
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()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copilot AI review requested due to automatic review settings June 30, 2026 07:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +117 to +123
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)
Comment on lines +198 to +200
let service = Unmanaged<AVSCallingEventService>.fromOpaque(contextRef).takeUnretainedValue()
WireLogger.mls.info("12345 \(service.onIncomingCall == nil), conversationId = \(conversationId)")
service.onIncomingCall?(
Comment on lines +77 to +81
conversationId: params.conversationId,
userId: params.userId,
clientId: clientID,
conversationType: params.conversationType
)
Comment on lines +95 to +99
case let .conversation(.proteusMessageAdd(e)):
await avsParametersForProteus(e).first
case let .conversation(.mlsMessageAdd(e)):
await avsParametersForMLS(e).first
default:
Comment on lines +161 to +165
_ = callKitReportingCoordinator
await processCallingEventsUseCase.invoke(
eventBatches: eventBatches,
callKitReportingCoordinator: callKitReportingCoordinator
)
Comment on lines +156 to +159
var eventBatches: [[UpdateEvent]] = []
for await batch in eventStream {
eventBatches.append(batch)
}
Comment on lines +37 to +41
final class ProcessCallingEventsUseCase: ProcessCallingEventsUseCaseProtocol {

private let callingService: any AVSCallingEventServiceProtocol
private let clientID: String
private let conversationLocalStore: any ConversationLocalStoreProtocol
@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants