From f63aba0ed19fc147e1d8c50c9e81579592eb7180 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 28 Jul 2026 22:34:27 +0530 Subject: [PATCH] feat(ios): native CallKit + PushKit VoIP calling plugin + web bridge The native call feature (iOS). Backward-compatible: without the plugin (current builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older builds behave exactly as before. Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route): - PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen ring, works when the app is force-killed). - CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on didActivate so the WebView's WebRTC audio rides a call-priority session (background). - Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same active-call background-audio context. - NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps; ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit). Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup + WebAudio ring are suppressed (the system rings instead); outgoing calls are reported to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call uuid through the dm-call/group-call WS events + start responses so both sides can match the CallKit call. Needs a Codemagic build to compile the plugin; first on-device iteration expected. Co-Authored-By: Claude Opus 4.8 --- mobile/package.json | 1 + mobile/plugins/native-call/NativeCall.podspec | 23 +++ .../NativeCallPlugin/NativeCallPlugin.swift | 178 ++++++++++++++++++ mobile/plugins/native-call/package.json | 26 +++ mobile/scripts/ios-patch.sh | 6 +- server/calls.js | 22 +-- server/public/home.html | 52 ++++- 7 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 mobile/plugins/native-call/NativeCall.podspec create mode 100644 mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift create mode 100644 mobile/plugins/native-call/package.json diff --git a/mobile/package.json b/mobile/package.json index 6d17e27..b6ac992 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,6 +12,7 @@ "dependencies": { "audio-route": "file:plugins/audio-route", "media-library": "file:plugins/media-library", + "native-call": "file:plugins/native-call", "share-inbox": "file:plugins/share-inbox", "@capacitor-community/safe-area": "^7.0.0", "@capacitor/android": "^7.0.0", diff --git a/mobile/plugins/native-call/NativeCall.podspec b/mobile/plugins/native-call/NativeCall.podspec new file mode 100644 index 0000000..8a06320 --- /dev/null +++ b/mobile/plugins/native-call/NativeCall.podspec @@ -0,0 +1,23 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + # NOTE: the pod name MUST be 'NativeCall' (PascalCase of the npm package name 'native-call'). + # Capacitor's `cap sync` writes `pod 'NativeCall', :path => '../../plugins/native-call'` into the + # generated Podfile, and CocoaPods then looks for a file literally named NativeCall.podspec whose + # s.name is 'NativeCall'. Any other name → "No podspec found for `NativeCall`" and pod install fails. + s.name = 'NativeCall' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = 'https://bizgaze.com' + s.author = 'BizGaze' + s.source = { :git => 'https://bizgaze.com/native-call.git', :tag => s.version.to_s } + s.source_files = 'ios/Sources/**/*.{swift,h,m}' + s.ios.deployment_target = '14.0' + s.dependency 'Capacitor' + # CallKit + PushKit + AVFoundation are system frameworks (no external pod). + s.frameworks = 'CallKit', 'PushKit', 'AVFoundation' + s.swift_version = '5.1' +end diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift new file mode 100644 index 0000000..2231772 --- /dev/null +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -0,0 +1,178 @@ +import Foundation +import Capacitor +import PushKit +import CallKit +import AVFoundation + +// Native calling for Biz Connect (iOS). Registered by `cap sync` as a real Capacitor plugin, so it is +// available to the remote web UI as window.Capacitor.Plugins.NativeCall. +// +// WHAT IT DOES +// * PushKit: registers for VoIP pushes and reports the VoIP token to JS -> POST /api/v1/devices ('ios-voip'). +// * CallKit: on an incoming VoIP push it reports a system call (full-screen ring, works when the app is +// force-killed). Answer/decline/end come back to JS as events so the web app joins/leaves the LiveKit room. +// * Outgoing: the web app calls reportOutgoingCall() when the user places a call, so THAT call is also a +// CallKit call — which is what grants the app the active-call background-audio context. +// * Audio: CallKit owns the AVAudioSession for the call; we configure it for voice on didActivate so the +// WebRTC audio (still driven by the WebView) rides on a call-priority session that survives backgrounding. +// +// CANCELLATION: we deliberately do NOT send "cancel" VoIP pushes (iOS requires a reported call for EVERY +// VoIP push). Instead the app is already awake after the invite push, so a caller hang-up arrives over the +// normal chat WebSocket and the web app calls endCall() to dismiss the CallKit ring. +@objc(NativeCallPlugin) +public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate { + public let identifier = "NativeCallPlugin" + public let jsName = "NativeCall" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "callConnected", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) + ] + + private var pushRegistry: PKPushRegistry? + private var provider: CXProvider? + private let callController = CXCallController() + private var voipToken: String = "" + // callUUID -> the call's data (room, kind, callerId, …) so answer/end can hand it back to JS. + private var calls: [UUID: [String: Any]] = [:] + + override public func load() { + let config = CXProviderConfiguration() + config.supportsVideo = true + config.maximumCallGroups = 1 + config.maximumCallsPerCallGroup = 1 + config.supportedHandleTypes = [.generic] + let p = CXProvider(configuration: config) + p.setDelegate(self, queue: nil) + provider = p + + let registry = PKPushRegistry(queue: .main) + registry.delegate = self + registry.desiredPushTypes = [.voIP] + pushRegistry = registry + } + + // MARK: - JS-callable methods + + @objc func getToken(_ call: CAPPluginCall) { + call.resolve(["token": voipToken]) + } + + // The web app places an outgoing call -> register it with CallKit so the system knows a call is active + // (grants background-audio execution) and the OS call UI is consistent. + @objc func reportOutgoingCall(_ call: CAPPluginCall) { + guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else { + call.reject("callUUID required"); return + } + let handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call") + let start = CXStartCallAction(call: uuid, handle: handle) + start.isVideo = call.getBool("hasVideo") ?? false + var data: [String: Any] = [:] + data["room"] = call.getString("room") ?? "" + data["kind"] = call.getString("kind") ?? "dm" + calls[uuid] = data + callController.request(CXTransaction(action: start)) { error in + if let error = error { call.reject(error.localizedDescription) } else { call.resolve() } + } + } + + // Media connected -> start the CallKit timer. + @objc func callConnected(_ call: CAPPluginCall) { + if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { + provider?.reportOutgoingCall(with: uuid, connectedAt: Date()) + } + call.resolve() + } + + // End a CallKit call (remote hung up / user ended from the web UI / decline echo). No callUUID -> end all. + @objc func endCall(_ call: CAPPluginCall) { + if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { + requestEnd(uuid) + } else { + for uuid in calls.keys { requestEnd(uuid) } + } + call.resolve() + } + + private func requestEnd(_ uuid: UUID) { + callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in } + calls.removeValue(forKey: uuid) + } + + // MARK: - PushKit (VoIP) + + public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { + let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() + voipToken = token + notifyListeners("voipToken", data: ["token": token]) + } + + public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { + voipToken = "" + } + + // Incoming VoIP push. iOS 13+: we MUST report a call to CallKit before completion() or the app is killed. + public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + let dict = payload.dictionaryPayload + let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID() + let callerName = (dict["callerName"] as? String) ?? (dict["groupName"] as? String) ?? "Incoming call" + let hasVideo = (dict["hasVideo"] as? Bool) ?? false + calls[uuid] = dict + + 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 + completion() + } + } + + // MARK: - CXProviderDelegate + + public func providerDidReset(_ provider: CXProvider) { + calls.removeAll() + } + + public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + var data = calls[action.callUUID] ?? [:] + data["callUUID"] = action.callUUID.uuidString + notifyListeners("answerCall", data: data) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + var data = calls[action.callUUID] ?? [:] + data["callUUID"] = action.callUUID.uuidString + notifyListeners("endCall", data: data) + calls.removeValue(forKey: action.callUUID) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date()) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted]) + 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. + public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + try? audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) + try? audioSession.setActive(true) + notifyListeners("audioActivated", data: [:]) + } + + public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + notifyListeners("audioDeactivated", data: [:]) + } +} diff --git a/mobile/plugins/native-call/package.json b/mobile/plugins/native-call/package.json new file mode 100644 index 0000000..9b32ef9 --- /dev/null +++ b/mobile/plugins/native-call/package.json @@ -0,0 +1,26 @@ +{ + "name": "native-call", + "version": "1.0.0", + "description": "Native CallKit + PushKit VoIP calling for Biz Connect (iOS)", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "author": "BizGaze", + "license": "MIT", + "files": [ + "dist/", + "ios/", + "NativeCall.podspec" + ], + "capacitor": { + "ios": { + "src": "ios" + } + }, + "devDependencies": { + "@capacitor/core": "^7.0.0" + }, + "peerDependencies": { + "@capacitor/core": "^7.0.0" + } +} diff --git a/mobile/scripts/ios-patch.sh b/mobile/scripts/ios-patch.sh index e96562a..3d22234 100644 --- a/mobile/scripts/ios-patch.sh +++ b/mobile/scripts/ios-patch.sh @@ -46,10 +46,14 @@ set_bool LSSupportsOpeningDocumentsInPlace true # or the phone locks. Declaring background audio keeps the audio session (and the app) running so a voice # call continues in the background. (Video RENDERING still pauses while backgrounded — unavoidable in a # WebView — but audio keeps flowing, which is what matters for a call.) Idempotent: rebuild the array each run. +# 'audio' keeps the call audio session alive when backgrounded; 'voip' is REQUIRED for PushKit to deliver +# VoIP pushes (CallKit incoming-call wake). Both are legitimate for a calling app and accepted by review +# because the app uses CallKit. "$PB" -c "Delete :UIBackgroundModes" "$PLIST" 2>/dev/null || true "$PB" -c "Add :UIBackgroundModes array" "$PLIST" "$PB" -c "Add :UIBackgroundModes:0 string audio" "$PLIST" -echo "UIBackgroundModes: audio (calls keep audio when minimised)" +"$PB" -c "Add :UIBackgroundModes:1 string voip" "$PLIST" +echo "UIBackgroundModes: audio, voip (call audio + CallKit VoIP wake)" # ── Custom URL scheme so the Share Extension can bounce the user back into the app ────────────────── # The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads diff --git a/server/calls.js b/server/calls.js index 9fbd68d..2c7e6f7 100644 --- a/server/calls.js +++ b/server/calls.js @@ -63,7 +63,7 @@ async function postSystem(group, teamId, text) { async function startGroupCall(group, teamId, user) { const existing = groupCalls.get(group); - if (existing) return { room: existing.room, active: true, already: true }; + if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true }; let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); meetingRooms.set(room, new Map()); const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; @@ -72,11 +72,11 @@ async function startGroupCall(group, teamId, user) { groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {}); let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} - broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName }); + broadcast(group, { type: 'group-call', group, active: true, room, uuid: call.uuid, by: user.id, startedByName: call.startedByName, groupName: gName }); // Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available, // else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws. try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendCallNotification(mid, { callUUID: call.uuid, room, kind: 'group', groupId: group, groupName: gName, callerId: user.id, callerName: call.startedByName, title: gName, body: '📞 ' + call.startedByName + ' started a group call', hasVideo: true }); } } catch (_) {} - return { room, active: true }; + return { room, uuid: call.uuid, active: true }; } // Called from signaling when a mesh room empties — ends the group call if this room was one. @@ -88,7 +88,7 @@ async function endGroupCallByRoom(room) { if (call) { let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {} if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past - broadcast(group, { type: 'group-call', group, active: false, room }); + broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid }); } } @@ -96,7 +96,7 @@ async function endGroupCallByRoom(room) { async function startDmCall(me, otherId, teamId) { const key = pairKey(me.id, otherId); const existing = dmCalls.get(key); - if (existing) return { room: existing.room, active: true, already: true }; + if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true }; let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); meetingRooms.set(room, new Map()); const byName = me.name || me.email; @@ -118,13 +118,13 @@ async function startDmCall(me, otherId, teamId) { const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName }; try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {} try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {} - try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {} - try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {} + try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, uuid: call.uuid, with: me.id, by: me.id, byName }); } catch (_) {} + try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, uuid: call.uuid, with: otherId, by: me.id, byName }); } catch (_) {} // Notify the callee so a CLOSED app still rings — VoIP/CallKit if the device registered a VoIP token, // else a banner (the CHAT.pushToUser events above only reach a connected socket). Best-effort. On // reconnect the callee's app also re-shows the invite (replayActiveCalls), so answering works either way. try { PUSH.sendCallNotification(otherId, { callUUID: call.uuid, room, kind: 'dm', callerId: me.id, callerName: byName, title: byName, body: '📞 Incoming call', hasVideo: false }); } catch (_) {} - return { room, active: true }; + return { room, uuid: call.uuid, active: true }; } async function endDmCallByRoom(room, silent) { @@ -141,7 +141,7 @@ async function endDmCallByRoom(room, silent) { const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} }); } catch (_) {} - call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} }); + call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} }); } // Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end @@ -162,7 +162,7 @@ async function replayActiveCalls(userId, ws) { for (const [, call] of dmCalls) { if (call.answered) continue; if (call.users.includes(userId) && call.startedBy !== userId) { - try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {} + try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {} } } for (const [group, call] of groupCalls) { @@ -170,7 +170,7 @@ async function replayActiveCalls(userId, ws) { let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {} if (!member) continue; let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} - try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {} + try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {} } } catch (_) {} } diff --git a/server/public/home.html b/server/public/home.html index 5a85bb8..92af6eb 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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}; switchTab('meeting'); enterMeeting(r.room); } } + 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); } } 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?'Join':''); } @@ -2554,14 +2554,15 @@ function onGroupCall(d){ if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active); - if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in - if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing + // 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){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit renderChats(searchVal()); } 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}; 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}; if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } } 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 @@ -2579,8 +2580,10 @@ function onDmCall(d){ if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live - if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call - if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing + // 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){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit renderChats(searchVal()); } // Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss. @@ -3195,7 +3198,7 @@ async function openConvo(kind,id){ // If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat // is opened from a call notification, the original transient invite popup may already have closed — // this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification. - if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){ + if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom) && !nativeCallOn()){ // CallKit devices ring via the system, not this popup showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true); } const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput'); @@ -3772,6 +3775,40 @@ async function unsubscribePush(){ pushActive=false; console.log('[push] unsubscribed (logout)'); }catch(_){} } +// ---------- Native calling (iOS CallKit + PushKit VoIP) ---------- +// The native-call plugin rings incoming calls via CallKit (full-screen, works when the app is killed) and +// reports its VoIP token; on answer/end it fires events we bridge into the existing meeting join/leave. On +// a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup +// (onDmCall / the group invite check nativeCallOn()). +let _callkitReady=false; +function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; } +function nativeCallOn(){ return _callkitReady; } +async function setupNativeCall(){ + const NC=nativeCallPlugin(); if(!NC) return; + _callkitReady=true; + // VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes. + NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); }); + try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){} + // User answered on the CallKit screen → join that call's room (CallKit already foregrounded the app). + NC.addListener('answerCall', (d)=>{ try{ + if(!d||!d.room) return; + dismissCallInvite(d.room); + meetReturn = (d.kind==='group') ? {kind:'group', id:d.groupId} : {kind:'dm', id:d.callerId}; + switchTab('meeting'); enterMeeting(d.room); + }catch(e){ console.warn('[callkit] answer failed', e); } }); + // User declined/ended on the CallKit screen. + NC.addListener('endCall', (d)=>{ try{ + const room=d&&d.room; + 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(_){} }); + console.log('[callkit] native calling ready'); +} +// Register an OUTGOING call with CallKit so it's a system call too (→ background audio for outgoing calls). +function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo }); }catch(_){} } +// 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(_){} } + // Open the chat from an in-page notification. Navigation reliably repaints across browsers (a // notification click is not an in-page gesture, so an in-place open won't paint until you // tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first. @@ -5661,6 +5698,7 @@ window.addEventListener('message',(e)=>{ reportInstall(); // desktop/mobile shell: record this install against the signed-in user wireUpdateBanner(); // #3: desktop update-progress banner setupPush(); // register the notification service worker + subscribe to Web Push (if granted) + setupNativeCall(); // iOS CallKit: register VoIP token + bridge answer/end to the meeting join/leave { const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA) // Fast-path: when opened from a notification, show the chat immediately (only needs the