diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 7b2d1ab..00cadc0 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -24,6 +24,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) ] @@ -53,12 +54,22 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega registry.delegate = self registry.desiredPushTypes = [.voIP] 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 private func connectRoom(url: String, token: String) { 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 r = Room() room = r @@ -104,15 +115,50 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega callController.request(CXTransaction(action: start)) { [weak self] error in if let error = error { call.reject(error.localizedDescription); return } 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()) 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) { let muted = call.getBool("muted") ?? false - let r = room - Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) } + // Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the + // 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() } @@ -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 } } let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID() - // CANCEL: caller hung up / declined / timed out before answer. Still must report (or crash), so - // report then immediately end. If already reported the report errors (no re-ring); if relaunched it - // blips once then ends. + // CANCEL: caller hung up / declined / timed out. iOS requires a reported call per push, but blindly + // reporting a NEW incoming call is what caused the "rings back for a second" blip on a call we've + // already handled. So: 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() 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) @@ -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 // 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) ?? "") + // 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 // 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 @@ -227,13 +291,19 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega action.fulfill() } - // LiveKit's AudioManager manages the AVAudioSession by default and coordinates with CallKit; we just - // report the state to JS. (If the mic proves flaky, this is where we'd add manual AudioManager - // engine-availability coordination.) + // CallKit owns the AVAudioSession lifecycle. Configure the session and START LiveKit's audio engine ONLY + // here (never before) — this is the fix for the intermittent dead mic / no-audio and late speaker routing. 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) { + try? AudioManager.shared.setEngineAvailability(.none) // stop the engine until the next call's didActivate notifyListeners("audioDeactivated", data: ["ok": true]) } } diff --git a/server/public/home.html b/server/public/home.html index b832262..b432275 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -2556,6 +2556,7 @@ function onGroupCall(d){ 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. 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 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 // 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() && 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 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 // 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('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)||'' }); }); // 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(_){} }); @@ -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. 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 + // CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/