From e09d17f1a7af5bfcf4ac23adcb8eb08e68abe47d Mon Sep 17 00:00:00 2001 From: sravan Date: Wed, 29 Jul 2026 22:48:24 +0530 Subject: [PATCH] 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 --- .../NativeCallPlugin/NativeCallPlugin.swift | 44 ++++++++++--------- server/calls.js | 6 +++ server/public/home.html | 11 +++++ 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 174bab5..2fac069 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -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) { diff --git a/server/calls.js b/server/calls.js index 0dbbcc8..a3c3003 100644 --- a/server/calls.js +++ b/server/calls.js @@ -146,6 +146,12 @@ async function endDmCallByRoom(room, silent) { call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} }); // Stop any CallKit ring on a killed/backgrounded device (no WS to receive the dm-call above). try { for (const uid of call.users) PUSH.sendCallCancel(uid, call.uuid); } catch (_) {} + // Missed-call banner to the callee (a plain notification, like a phone's missed call) when the call ended + // UNANSWERED — timeout or the caller hung up before pickup. Skipped on decline (silent): they chose to. + if (!silent && !call.answered) { + const callee = call.users.find((u) => u !== call.startedBy); + if (callee) { try { PUSH.sendToUser(callee, { title: call.startedByName || 'Missed call', body: '📞 Missed call', kind: 'dm', id: call.startedBy, tag: 'missed:' + room, data: { kind: 'dm', id: call.startedBy } }); } catch (_) {} } + } } // Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end diff --git a/server/public/home.html b/server/public/home.html index 761b409..1253053 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -3806,6 +3806,17 @@ async function setupNativeCall(){ if(room && (typeof meetRoom!=='undefined') && meetRoom===room){ leaveMeeting(); } // already in it → leave else if(room){ postJSON('/api/calls/decline',{room}).catch(()=>{}); dismissCallInvite(room); } // ringing, not joined → decline }catch(_){} }); + // After answer, CallKit releases the mic + audio session back to us (an active CallKit call reserves the + // mic → dead mic + earpiece). Force the loudspeaker (CallKit used the earpiece), then RE-ACQUIRE the mic + // (it was captured dead while CallKit held it). Retry the mic toggle a few times in case the WebView is + // still joining the room when the handoff fires. + NC.addListener('callHandoff', async ()=>{ + try{ const ar=nativeAudioRoute(); if(ar) await ar.setSpeaker({on:true}); }catch(_){} + for(let i=0;i<5;i++){ + try{ const lp=(typeof SFU!=='undefined'&&SFU.room&&SFU.room.localParticipant)||null; if(lp && typeof sfuSetMic==='function'){ await sfuSetMic(false); await sfuSetMic(true); break; } }catch(_){} + await new Promise(r=>setTimeout(r,700)); + } + }); console.log('[callkit] native calling ready'); } // Register an OUTGOING call with CallKit so it's a system call too (→ background audio for outgoing calls).