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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 22:34:27 +05:30
parent fd2eb42e25
commit f63aba0ed1
7 changed files with 289 additions and 19 deletions
+11 -11
View File
@@ -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 (_) {}
}