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:
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -2546,7 +2546,7 @@ function refreshGroupRowTick(gid){
|
||||
}
|
||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||
async function startOrJoinGroupCall(group){
|
||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; const _g=rowFor('group',group); if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', true); switchTab('meeting'); enterMeeting(r.room); } }
|
||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); /* Outgoing stays on the WebView (no CallKit) so the mic works. */ } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
||||
@@ -2562,7 +2562,7 @@ function onGroupCall(d){
|
||||
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
||||
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||||
async function startOrJoinDmCall(otherId){
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer. Outgoing stays on the WebView (no CallKit) so the mic works. */ } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||||
|
||||
Reference in New Issue
Block a user