Native calls: CallKit audio-session coordination, no re-ring blip, mute sync, WS ring path
Plugin (rides next build): - Audio: disable LiveKit auto audio-session config + keep the engine OFF, then configure the session and start the engine ONLY in CXProvider didActivate (stop in didDeactivate). Fixes intermittent dead mic / no audio and the "speaker turns on late" routing. Request mic permission on connect so enabling the engine in didActivate can't block on undetermined permission (SDK #815). - Re-ring blip: a cancel push for a call we already ended/known no longer reports a NEW incoming call (that was the phantom "rings back for a second"); it ends the known call cleanly, and only reports+ends for a truly unknown (cold) call. - Mute display: answer/outgoing reflect muted-by-default on the CallKit screen; setMuted now drives mute THROUGH CallKit so the system screen and the in-app meeting UI stay in sync. - reportIncomingCall: new method to ring CallKit from a WebSocket call event — a 2nd path alongside the VoIP push for when the app is open (push can be delayed); deduped by UUID. Web (deploys now; the WS ring path activates once the build has the new method): - onDmCall/onGroupCall call nativeReportIncoming for native incoming calls. - audioActivated telemetry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
public let pluginMethods: [CAPPluginMethod] = [
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
@@ -53,12 +54,22 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
registry.delegate = self
|
registry.delegate = self
|
||||||
registry.desiredPushTypes = [.voIP]
|
registry.desiredPushTypes = [.voIP]
|
||||||
pushRegistry = registry
|
pushRegistry = registry
|
||||||
|
|
||||||
|
// CallKit audio coordination. LiveKit auto-configures & activates the AVAudioSession by default, which
|
||||||
|
// RACES CallKit's own activation → intermittent dead mic / no audio, and the output route only settling
|
||||||
|
// once audio starts flowing (the "speaker turns on late" symptom). Fix (per LiveKit's CallKit guide):
|
||||||
|
// disable auto-config, keep the audio engine OFF, and start it ONLY inside CXProvider didActivate.
|
||||||
|
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
|
||||||
|
try? AudioManager.shared.setEngineAvailability(.none)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - LiveKit media
|
// MARK: - LiveKit media
|
||||||
|
|
||||||
private func connectRoom(url: String, token: String) {
|
private func connectRoom(url: String, token: String) {
|
||||||
guard !url.isEmpty, !token.isEmpty else { return }
|
guard !url.isEmpty, !token.isEmpty else { return }
|
||||||
|
// Ask for mic permission now (we still connect MUTED). If it stays "undetermined", enabling the audio
|
||||||
|
// engine in didActivate can BLOCK (SDK issue #815) → no audio. Determining it up front avoids that.
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
|
||||||
let old = room
|
let old = room
|
||||||
let r = Room()
|
let r = Room()
|
||||||
room = r
|
room = r
|
||||||
@@ -104,15 +115,50 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
callController.request(CXTransaction(action: start)) { [weak self] error in
|
callController.request(CXTransaction(action: start)) { [weak self] error in
|
||||||
if let error = error { call.reject(error.localizedDescription); return }
|
if let error = error { call.reject(error.localizedDescription); return }
|
||||||
self?.connectRoom(url: url, token: token)
|
self?.connectRoom(url: url, token: token)
|
||||||
|
// Start muted (house rule) — also reflect it on the CallKit system call screen.
|
||||||
|
self?.callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
|
||||||
self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date())
|
self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date())
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ring CallKit from a WEBSOCKET call event (a 2nd path alongside the VoIP push). When the app is open the
|
||||||
|
// WS is connected and reliable, but the VoIP push can be delayed/dropped → "sometimes it doesn't ring".
|
||||||
|
// Deduped by UUID: if the VoIP push already reported this call, this is a no-op (and vice-versa).
|
||||||
|
@objc func reportIncomingCall(_ call: CAPPluginCall) {
|
||||||
|
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
|
||||||
|
call.reject("callUUID required"); return
|
||||||
|
}
|
||||||
|
// Already handled (ended, ringing, or active) → don't re-report (CallKit would reject a dup anyway).
|
||||||
|
if endedCalls.contains(uuid) || calls[uuid] != nil || activeUUID == uuid { call.resolve(); return }
|
||||||
|
let callerName = call.getString("callerName") ?? call.getString("groupName") ?? "Incoming call"
|
||||||
|
let hasVideo = call.getBool("hasVideo") ?? false
|
||||||
|
calls[uuid] = [
|
||||||
|
"callUUID": uuidStr, "room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
|
||||||
|
"callerId": call.getString("callerId") ?? "", "callerName": callerName,
|
||||||
|
"groupId": call.getString("groupId") ?? "", "groupName": call.getString("groupName") ?? "",
|
||||||
|
"hasVideo": hasVideo, "livekitUrl": call.getString("url") ?? "", "livekitToken": call.getString("token") ?? "",
|
||||||
|
]
|
||||||
|
let update = CXCallUpdate()
|
||||||
|
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||||
|
update.localizedCallerName = callerName
|
||||||
|
update.hasVideo = hasVideo
|
||||||
|
update.supportsHolding = false; update.supportsGrouping = false; update.supportsUngrouping = false
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in }
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
@objc func setMuted(_ call: CAPPluginCall) {
|
@objc func setMuted(_ call: CAPPluginCall) {
|
||||||
let muted = call.getBool("muted") ?? false
|
let muted = call.getBool("muted") ?? false
|
||||||
let r = room
|
// Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the
|
||||||
Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) }
|
// CXSetMutedCallAction handler does the actual setMicrophone). Fall back to a direct call if somehow
|
||||||
|
// there's no active CallKit call.
|
||||||
|
if let uuid = activeUUID {
|
||||||
|
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: muted))) { _ in }
|
||||||
|
} else {
|
||||||
|
let r = room
|
||||||
|
Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) }
|
||||||
|
}
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,13 +198,28 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
|
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
|
||||||
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
||||||
|
|
||||||
// CANCEL: caller hung up / declined / timed out before answer. Still must report (or crash), so
|
// CANCEL: caller hung up / declined / timed out. iOS requires a reported call per push, but blindly
|
||||||
// report then immediately end. If already reported the report errors (no re-ring); if relaunched it
|
// reporting a NEW incoming call is what caused the "rings back for a second" blip on a call we've
|
||||||
// blips once then ends.
|
// already handled. So:
|
||||||
if (dict["type"] as? String) == "cancel" {
|
if (dict["type"] as? String) == "cancel" {
|
||||||
|
// (a) already ended here → nothing to do (re-reporting would blip).
|
||||||
|
if endedCalls.contains(uuid) { completion(); return }
|
||||||
|
// (b) a call we already know (still ringing OR active) → end it WITHOUT reporting a new incoming,
|
||||||
|
// so there's no blip. reportCall(endedAt:) cleanly dismisses the CallKit UI.
|
||||||
|
if calls[uuid] != nil || activeUUID == uuid {
|
||||||
|
var ev = calls[uuid] ?? [:]; ev["callUUID"] = uuid.uuidString
|
||||||
|
calls.removeValue(forKey: uuid)
|
||||||
|
endedCalls.insert(uuid)
|
||||||
|
if activeUUID == uuid { disconnectRoom(); activeUUID = nil }
|
||||||
|
provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||||
|
notifyListeners("endCall", data: ev) // if it was active, leave the meeting window too
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// (c) unknown call (this very push relaunched the app) → we MUST report, then immediately end.
|
||||||
|
// A tiny unavoidable blip, but only in this rare cold case.
|
||||||
let u = CXCallUpdate()
|
let u = CXCallUpdate()
|
||||||
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
|
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
|
||||||
calls.removeValue(forKey: uuid)
|
|
||||||
endedCalls.insert(uuid)
|
endedCalls.insert(uuid)
|
||||||
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
|
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
|
||||||
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||||
@@ -197,6 +258,9 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
// Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room
|
// Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room
|
||||||
// NATIVELY so the mic works with the active CallKit call and audio survives backgrounding.
|
// NATIVELY so the mic works with the active CallKit call and audio survives backgrounding.
|
||||||
connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "")
|
connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "")
|
||||||
|
// We answer muted (house rule) — reflect that on the CallKit system call screen (the mic button there
|
||||||
|
// showed unmuted before, even though we were functionally muted).
|
||||||
|
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
|
||||||
var ev = data; ev["callUUID"] = uuid.uuidString
|
var ev = data; ev["callUUID"] = uuid.uuidString
|
||||||
// Answering from a KILLED/locked state launches the app — the WebView's JS listener may not be attached
|
// Answering from a KILLED/locked state launches the app — the WebView's JS listener may not be attached
|
||||||
// yet, so retain the event until it is. Without this the "answered" signal is lost, the server's
|
// yet, so retain the event until it is. Without this the "answered" signal is lost, the server's
|
||||||
@@ -227,13 +291,19 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
action.fulfill()
|
action.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveKit's AudioManager manages the AVAudioSession by default and coordinates with CallKit; we just
|
// CallKit owns the AVAudioSession lifecycle. Configure the session and START LiveKit's audio engine ONLY
|
||||||
// report the state to JS. (If the mic proves flaky, this is where we'd add manual AudioManager
|
// here (never before) — this is the fix for the intermittent dead mic / no-audio and late speaker routing.
|
||||||
// engine-availability coordination.)
|
|
||||||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||||
notifyListeners("audioActivated", data: ["ok": true])
|
do {
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers])
|
||||||
|
try AudioManager.shared.setEngineAvailability(.default)
|
||||||
|
notifyListeners("audioActivated", data: ["ok": true])
|
||||||
|
} catch {
|
||||||
|
notifyListeners("audioActivated", data: ["ok": false, "error": String(describing: error)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||||
|
try? AudioManager.shared.setEngineAvailability(.none) // stop the engine until the next call's didActivate
|
||||||
notifyListeners("audioDeactivated", data: ["ok": true])
|
notifyListeners("audioDeactivated", data: ["ok": true])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2556,6 +2556,7 @@ function onGroupCall(d){
|
|||||||
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
||||||
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
|
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
|
||||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
|
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
|
||||||
|
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'group', groupId:d.group, callerName:(d.groupName||(it&&it.name)||'Group call') }); // WS path → CallKit
|
||||||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
|
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
|
||||||
renderChats(searchVal());
|
renderChats(searchVal());
|
||||||
}
|
}
|
||||||
@@ -2583,6 +2584,7 @@ function onDmCall(d){
|
|||||||
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
|
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
|
||||||
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
|
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
|
||||||
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
|
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
|
||||||
|
if(d.active && d.by && d.by!==ME.id && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'dm', callerId:d.with, callerName:d.byName }); // WS path → CallKit (2nd path alongside the VoIP push)
|
||||||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
|
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
|
||||||
renderChats(searchVal());
|
renderChats(searchVal());
|
||||||
}
|
}
|
||||||
@@ -3813,6 +3815,7 @@ async function setupNativeCall(){
|
|||||||
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
|
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
|
||||||
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
|
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
|
||||||
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } });
|
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } });
|
||||||
|
NC.addListener('audioActivated', (e)=>{ pdbg('nc-audio', { ok:!!(e&&e.ok), err:(e&&e.error)||'' }); }); // CallKit audio-session activation (debug intermittent audio)
|
||||||
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
|
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
|
||||||
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
||||||
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
||||||
@@ -3839,6 +3842,14 @@ async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
|
|||||||
}
|
}
|
||||||
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
||||||
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
||||||
|
// Ring CallKit from a WebSocket call event — a 2nd, reliable path alongside the VoIP push for when the app is
|
||||||
|
// OPEN (the push can be delayed/dropped). Deduped by UUID in the plugin, so double-firing is harmless.
|
||||||
|
async function nativeReportIncoming(o){
|
||||||
|
const NC=nativeCallPlugin(); if(!NC||!o||!o.callUUID||!o.room) return;
|
||||||
|
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room:o.room }); }catch(_){}
|
||||||
|
// .catch: on older builds without this plugin method, Capacitor REJECTS the promise (not a sync throw) — swallow it.
|
||||||
|
try{ const p=NC.reportIncomingCall({ callUUID:o.callUUID, room:o.room, kind:o.kind||'dm', callerId:o.callerId||'', callerName:o.callerName||'Incoming call', groupId:o.groupId||'', groupName:o.groupName||'', hasVideo:false, url:(tk&&tk.url)||'', token:(tk&&tk.token)||'' }); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
||||||
|
}
|
||||||
|
|
||||||
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
|
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
|
||||||
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
|
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
|
||||||
|
|||||||
Reference in New Issue
Block a user