fix(ios): stop VoIP-push crash; hand mic to WebView after answer; missed-call banner

CRASH: iOS terminates an app that receives a VoIP push without calling
reportNewIncomingCall. My re-ring 'fix' made the cancel path call completion() without
reporting -> crash. Always report then immediately end on cancel (a tiny ring blip is
unavoidable; a crash is worse).

MIC + earpiece: an ACTIVE CallKit call reserves the mic (WebView WebRTC gets a dead mic
+ earpiece routing). So on answer keep CallKit active only long enough to foreground the
app, then end it and fire 'callHandoff'; the WebView forces the loudspeaker and
re-acquires the mic (sfuSetMic off/on, retried while it finishes joining).

MISSED CALL: endDmCallByRoom now sends a plain missed-call banner to the callee when the
call ends unanswered (timeout / caller hung up before pickup); skipped on decline.

Server (missed banner) deploys now; plugin + web handoff need a Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 22:48:24 +05:30
parent 9dc253143e
commit e09d17f1a7
3 changed files with 40 additions and 21 deletions
@@ -125,23 +125,17 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// CANCEL: the caller hung up / declined / it timed out before we answered stop ringing. iOS still
// requires a reported call for every VoIP push, so if we never saw the invite, report then end it.
if (dict["type"] as? String) == "cancel" {
if calls[uuid] != nil {
provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
// CRITICAL: iOS TERMINATES the app if a VoIP push does NOT call reportNewIncomingCall (this was the
// crash). So ALWAYS report, then immediately end. If the invite already reported this uuid, the
// report errors (duplicate) and does NOT re-ring; if the app was relaunched by this cancel it
// blips once then ends. The blip is unavoidable not reporting crashes the app.
let u = CXCallUpdate()
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
completion()
} else if endedCalls.contains(uuid) {
// Already ended locally (we declined/hung up) do NOT re-report; that caused the ~1s re-ring.
completion()
} else {
// Never saw the invite iOS still requires a reported call for this push, so report then end.
let u = CXCallUpdate()
u.remoteHandle = CXHandle(type: .generic, value: "Call")
endedCalls.insert(uuid)
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
completion()
}
}
return
}
@@ -175,12 +169,20 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
var data: [String: Any] = calls[uuid] ?? [:]
data["callUUID"] = uuid.uuidString
action.fulfill()
// Keep the CallKit call ACTIVE fulfilling the answer for an ACTIVE call is what foregrounds/unlocks
// the app and gives it in-call context (ending it here made iOS cancel the app launch the app never
// opened). The WebView carries the media; we deliberately do NOT reconfigure the audio session
// (didActivate is a no-op) so we don't fight WebKit's WebRTC mic. The CallKit call is ended later when
// the WebView call ends (the dm-call/group-call inactive event JS calls endCall()).
// Keep the CallKit call ACTIVE just long enough fulfilling the answer for an ACTIVE call is what
// foregrounds/unlocks the app (ending it immediately made iOS cancel the launch the app never
// opened). Then hand off: fire answerCall so the WebView joins.
notifyListeners("answerCall", data: data)
// ...and a beat later, once the app is foregrounded, END the CallKit call to RELEASE the mic + audio
// session back to the WebView (an active CallKit call reserves the mic dead mic + earpiece). Ending
// AFTER foreground does NOT cancel the launch. Then tell JS to re-acquire the mic + loudspeaker.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
guard let self = self else { return }
self.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
self.calls.removeValue(forKey: uuid)
self.endedCalls.insert(uuid)
self.notifyListeners("callHandoff", data: ["callUUID": uuid.uuidString])
}
}
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {