feat(calls): CallKit ring-only + WebView media (background audio via voip mode)

Pivot away from the native-LiveKit rewrite. Discovery: the 'voip' UIBackgroundMode
already keeps the WebView's call audio alive when backgrounded (confirmed on device),
so background audio is solved WITHOUT native media. The only issue was CallKit
reserving the mic. So use CallKit purely for the incoming RING:

- Plugin CXAnswerCallAction: fulfill, then immediately end the CallKit call
  (reportCall endedAt) to RELEASE the mic, and fire answerCall to the WebView after a
  ~1s beat so iOS tears down the CallKit audio session first. didActivate no longer
  reconfigures the session (was fighting WebKit).
- home.html: outgoing calls no longer register with CallKit (WebView-only → mic works);
  incoming still rings via CallKit → hands off to the WebView on answer.

Net: native full-screen ring + working mic + background audio + all existing call
features. Needs a Codemagic build; then flip CALLKIT_ENABLED=1 to test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:25:59 +05:30
parent e7c3231c50
commit 6096b62368
2 changed files with 18 additions and 9 deletions
@@ -171,10 +171,20 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
}
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
var data: [String: Any] = calls[action.callUUID] ?? [:]
data["callUUID"] = action.callUUID.uuidString
notifyListeners("answerCall", data: data)
let uuid = action.callUUID
var data: [String: Any] = calls[uuid] ?? [:]
data["callUUID"] = uuid.uuidString
action.fulfill()
// RING-ONLY MODEL: CallKit was used purely for the incoming ring. End the CallKit call NOW so iOS
// RELEASES the microphone an active CallKit call reserves the mic and the WebView's WebRTC would get
// a dead mic. Then hand the call to the WebView, giving iOS a beat to tear down the CallKit audio
// session before the WebView grabs the mic. Background audio is preserved by the 'voip' background mode.
provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.notifyListeners("answerCall", data: data)
}
}
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
@@ -196,11 +206,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
action.fulfill()
}
// CallKit hands us the call audio session; configure it for a voice call. The WebView's WebRTC audio
// uses this session, and because it's a CallKit call the app keeps running in the background.
// RING-ONLY MODEL: the call audio runs in the WebView, not through CallKit's session so we do NOT
// reconfigure the session here (that would fight WebKit's WebRTC). Just report the state. (The CallKit
// call is ended right after answer anyway, so this session is short-lived.)
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
try? audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
try? audioSession.setActive(true)
notifyListeners("audioActivated", data: ["ok": true])
}