// Drive a self-managed Telecom call session alongside the active DM call // (docs/plans/telecom_migration.md, Phase B). Mirror of // useAndroidCallForegroundSync: keyed to the widget's JoinCall signal (via // useCallJoined), not the mere presence of callEmbedAtom. // // What this gives us once TELECOM_ENABLED is flipped on: // - the Vojo call is registered with the OS as a real call → cellular-call // interop (an incoming GSM call holds/arbitrates against it), audio-focus // arbitration, and a system "ongoing call" presence; // - a remote/system Telecom disconnect (the user takes a GSM call, or a // Bluetooth/Android-Auto "end call") tears the Vojo call down too; // - Telecom owns the audio route, so its active endpoint is mirrored into the // speaker-toggle atom (earpiece / speaker / Bluetooth all reflected). // // §4 (validated on Samsung One UI: the call gets system call-audio focus and // the route endpoint flow emits cleanly): Telecom owns routing. The speaker // toggle now drives Telecom endpoints (see useCallSpeaker) and AudioRoutePlugin // is kept off the audio session while a Telecom call is live. // // Android-only, and additionally gated on TELECOM_ENABLED + API>=26 inside // telecomCall — on web / iOS / old Android every call here is a no-op. import { useEffect } from 'react'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed'; import { telecomCall, type TelecomRoute } from '../plugins/call/telecomCall'; import { useCallJoined } from './useCallEmbed'; export const useTelecomConnectionSync = (): void => { const callEmbed = useAtomValue(callEmbedAtom); const joined = useCallJoined(callEmbed); const store = useStore(); const setSpeaker = useSetAtom(callSpeakerAtom); useEffect(() => { if (!telecomCall.enabled()) return undefined; if (!callEmbed || !joined) return undefined; let cancelled = false; const removers: Array<() => void> = []; const track = (p: ReturnType) => { p.then((handle) => { if (cancelled) handle.remove(); else removers.push(() => handle.remove()); }).catch(() => { /* listener registration best-effort */ }); }; telecomCall .startCall({ roomId: callEmbed.roomId, // For a DM the room name resolves to the peer's display name; good // enough for the system call label. Refined per-direction later. displayName: callEmbed.room.name || 'Vojo', video: !callEmbed.voiceOnly, incoming: false, }) .catch((err: unknown) => { // eslint-disable-next-line no-console console.warn('[telecom] startCall failed', err); }); // A Telecom-side disconnect we did NOT initiate (interrupting cellular // call, BT/Auto "end", system) ends the Vojo call too. hangup() lets // Element Call tear LiveKit down; the resulting Hangup event clears the // atom, which unmounts this effect and fires endCall() below. Idempotent: // our own hangup re-entering here is a no-op via the current-embed guard. track( telecomCall.addListener('telecomDisconnect', () => { if (store.get(callEmbedAtom) !== callEmbed) return; callEmbed.hangup().catch(() => { /* widget already gone */ }); }) ); // Telecom owns the route; reflect the active endpoint in the speaker-toggle // atom so the UI tracks reality (speaker vs earpiece/Bluetooth/wired). track( telecomCall.addListener('telecomEndpoint', (data: { route: TelecomRoute }) => { setSpeaker(data.route === 'SPEAKER'); }) ); return () => { cancelled = true; removers.forEach((remove) => remove()); telecomCall.endCall().catch(() => { /* best-effort teardown */ }); }; }, [callEmbed, joined, store, setSpeaker]); };