import { useCallback, useEffect, useRef, useState } from "react"; import { Platform, PermissionsAndroid } from "react-native"; import InCallManager from "react-native-incall-manager"; import { mediaDevices, RTCPeerConnection, type MediaStream, } from "react-native-webrtc"; import { fetchAPI } from "@/lib/fetch"; import type { CallRecord, CallStatus } from "@/types/type"; // Poll cadence for call signaling — faster than chat (2.5s) and ride-status // (3s) so the callee sees a ring without a long wait, but not so fast it // hammers the DB. const POLL_MS = 2000; // Cap ICE gathering so a slow network can't stall the call forever; whatever // candidates were gathered by then are sent (non-trickle). const ICE_GATHER_TIMEOUT_MS = 3000; // A serializable SDP. react-native-webrtc's RTCSessionDescriptionInit isn't // exported, so we keep our own shape and pass it straight to // setLocalDescription/setRemoteDescription (both accept { type, sdp }). type SdpPayload = { type: "offer" | "answer"; sdp: string }; const sdpToString = ( desc: { type: string | null; sdp: string } | null, ): string => desc && desc.type ? JSON.stringify({ type: desc.type, sdp: desc.sdp }) : ""; const parseSdp = (raw: string | null | undefined): SdpPayload | null => { if (!raw) return null; try { return JSON.parse(raw) as SdpPayload; } catch { return null; } }; const iceServers = (): RTCIceServer[] => { const servers: RTCIceServer[] = []; const stun = process.env.EXPO_PUBLIC_STUN_URL; if (stun) servers.push({ urls: [stun] }); const turn = process.env.EXPO_PUBLIC_TURN_URL; if (turn) { servers.push({ urls: [turn], username: process.env.EXPO_PUBLIC_TURN_USERNAME || "", credential: process.env.EXPO_PUBLIC_TURN_CREDENTIAL || "", }); } return servers; }; // Android needs the RECORD_AUDIO permission granted before getUserMedia; iOS // prompts automatically on first getUserMedia call. Exported so callers (the // chat screen, the driver dashboard) can prime it as soon as a ride is // matched, rather than the first ask landing mid-handshake when the user taps // Call — PermissionsAndroid.request no-ops instantly once already granted, so // priming early costs nothing on the actual call attempt. export const ensureMicPermission = async (): Promise => { if (Platform.OS !== "android") return true; const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, { title: "Microphone permission", message: "Waseel needs microphone access to make calls.", buttonPositive: "Allow", }, ); return granted === PermissionsAndroid.RESULTS.GRANTED; }; // Resolve once ICE gathering is complete (candidate === null), or when the // timeout fires — whichever first. Non-trickle: the caller waits for this so // the local SDP it ships already contains all candidates. const waitForIceGathering = (pc: RTCPeerConnection): Promise => new Promise((resolve) => { if (pc.iceGatheringState === "complete") return resolve(); let done = false; const finish = () => { if (done) return; done = true; pc.onicecandidate = null; resolve(); }; // RN-webrtc types the icecandidate event as a bare Event; the candidate // payload is on the runtime object, so cast to read it. pc.onicecandidate = ((e: { candidate: unknown }) => { if (e.candidate === null) finish(); }) as never; setTimeout(finish, ICE_GATHER_TIMEOUT_MS); }); type UseCallResult = { status: CallStatus; peerName: string | null; incoming: CallRecord | null; localStream: MediaStream | null; remoteStream: MediaStream | null; micError: boolean; muted: boolean; speakerOn: boolean; toggleMute: () => void; toggleSpeaker: () => void; /** Caller: place the call. */ startCall: ( rideId: number, role: "rider" | "driver", peerName: string, ) => Promise; /** Callee: attach to a ride and poll for an incoming offer (no offer created). */ watch: (rideId: number, role: "rider" | "driver", peerName?: string) => void; answerCall: () => Promise; declineCall: () => Promise; endCall: () => Promise; }; // Drive a WebRTC audio call over the DB-backed polling transport. The peer // connection and streams live in refs (non-serializable); only the call // status and streams the UI binds to are state. One active ride at a time. export const useCall = (): UseCallResult => { const [status, setStatus] = useState("idle"); const [peerName, setPeerName] = useState(null); const [incoming, setIncoming] = useState(null); const [localStream, setLocalStream] = useState(null); const [remoteStream, setRemoteStream] = useState(null); const [micError, setMicError] = useState(false); // Earpiece by default (standard telephony UX); the user opts into speaker. const [speakerOn, setSpeakerOn] = useState(false); const [muted, setMuted] = useState(false); const pcRef = useRef(null); const localStreamRef = useRef(null); const rideIdRef = useRef(null); const roleRef = useRef<"rider" | "driver" | null>(null); const callIdRef = useRef(null); const pollingRef = useRef | null>(null); const teardown = useCallback(() => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } try { pcRef.current?.close(); } catch { // ignore } pcRef.current = null; localStreamRef.current?.getTracks().forEach((t) => t.stop()); localStreamRef.current = null; setLocalStream(null); setRemoteStream(null); setIncoming(null); callIdRef.current = null; InCallManager.stop(); setSpeakerOn(false); setMuted(false); }, []); // Set up the peer connection with the local mic, wire the remote-track // handler, and return the stream to attach. const createPeer = useCallback(async (): Promise => { const ok = await ensureMicPermission(); if (!ok) { setMicError(true); return null; } setMicError(false); const stream = await mediaDevices.getUserMedia({ audio: true, video: false, }); localStreamRef.current = stream; setLocalStream(stream); // Routes audio through the earpiece/speaker and engages the proximity // sensor, same as the native phone dialer. Must start before the // speaker/mute toggles below have any effect. InCallManager.start({ media: "audio" }); const pc = new RTCPeerConnection({ iceServers: iceServers() }); stream.getTracks().forEach((track) => pc.addTrack(track, stream)); // RN-webrtc delivers the remote stream via ontrack's event payload; the // type is a bare Event so cast to read .streams. pc.ontrack = ((e: { streams: MediaStream[] }) => { const remote = e.streams[0]; if (remote) setRemoteStream(remote); }) as never; pc.oniceconnectionstatechange = (() => { const state = pc.iceConnectionState; if ( state === "failed" || state === "disconnected" || state === "closed" ) { // The peer connection died — end the call through the server so the // other side sees it too. if (rideIdRef.current) void endCallInternal("ended"); } }) as never; pcRef.current = pc; return pc; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const stopPolling = useCallback(() => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } }, []); // PATCH the call row to a terminal action. Kept outside the hook's public // endCall so the iceconnectionstatechange handler can call it too. const endCallInternal = useCallback( async (action: "ended" | "declined") => { const rideId = rideIdRef.current; if (rideId === null) return; try { await fetchAPI(`/(api)/ride/${rideId}/call`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: action === "ended" ? "end" : "decline", }), }); } catch (err) { console.log("[CALL_END]: ", err); } setStatus("ended"); teardown(); }, [teardown], ); // Poll the call row and drive the state machine. The caller waits for the // callee's answer (sdp_answer) to complete the handshake; the callee, while // idle, watches for an incoming ringing offer to surface as `incoming`. const poll = useCallback(async () => { const rideId = rideIdRef.current; if (rideId === null) return; try { const res = await fetchAPI(`/(api)/ride/${rideId}/call`); const call = (res.data ?? null) as CallRecord | null; if (!call) return; callIdRef.current = call.id; const isCaller = call.is_caller; // Caller side: connect once the callee has answered with an SDP answer. if (isCaller && call.status === "answered" && call.sdp_answer) { const pc = pcRef.current; const answer = parseSdp(call.sdp_answer); if (pc && answer && pc.remoteDescription === null) { await pc.setRemoteDescription(answer); setStatus("in-call"); } return; } // Either side: a terminal status ends the call locally. if ( call.status === "ended" || call.status === "declined" || call.status === "missed" ) { setStatus("ended"); teardown(); return; } // Callee side: an incoming ringing offer surfaces as `incoming` until // answered/declined. Don't overwrite it if we're already past idle. if (!isCaller && call.status === "ringing" && call.sdp_offer) { setStatus((current) => { if (current === "idle" || current === "incoming") { setIncoming(call); return "incoming"; } return current; }); } } catch (err) { console.log("[CALL_POLL]: ", err); } }, [teardown]); const startPolling = useCallback(() => { stopPolling(); pollingRef.current = setInterval(() => void poll(), POLL_MS); }, [poll, stopPolling]); // --- Caller flow: place a call. --- const startCall = useCallback( async (rideId: number, role: "rider" | "driver", name: string) => { rideIdRef.current = rideId; roleRef.current = role; setPeerName(name); setStatus("outgoing"); const pc = await createPeer(); if (!pc) { setStatus("idle"); return; } try { const offer = await pc.createOffer({ iceRestart: false }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const localOffer = pc.localDescription ? sdpToString(pc.localDescription) : sdpToString(offer); await fetchAPI(`/(api)/ride/${rideId}/call`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sdp_offer: localOffer }), }); startPolling(); } catch (err) { console.log("[CALL_START]: ", err); setStatus("failed" as CallStatus); teardown(); } }, [createPeer, startPolling, teardown], ); // --- Callee flow: answer an incoming call. --- const answerCall = useCallback(async () => { const rideId = rideIdRef.current; const offer = incoming?.sdp_offer; if (rideId === null || !offer) return; const pc = await createPeer(); if (!pc) { setStatus("idle"); return; } try { const remoteOffer = parseSdp(offer); if (!remoteOffer) throw new Error("bad offer"); await pc.setRemoteDescription(remoteOffer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); await waitForIceGathering(pc); const localAnswer = pc.localDescription ? sdpToString(pc.localDescription) : sdpToString(answer); await fetchAPI(`/(api)/ride/${rideId}/call`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "answer", sdp_answer: localAnswer }), }); setStatus("in-call"); setIncoming(null); startPolling(); } catch (err) { console.log("[CALL_ANSWER]: ", err); setStatus("failed" as CallStatus); teardown(); } }, [createPeer, incoming, startPolling, teardown]); const declineCall = useCallback(async () => { await endCallInternal("declined"); }, [endCallInternal]); const endCall = useCallback(async () => { await endCallInternal("ended"); }, [endCallInternal]); const toggleMute = useCallback(() => { setMuted((current) => { const next = !current; // Mute at the WebRTC track level rather than InCallManager's OS-level // mute: it's what actually stops audio reaching the peer, and it works // the same on both platforms. localStreamRef.current ?.getAudioTracks() .forEach((track) => (track.enabled = !next)); return next; }); }, []); const toggleSpeaker = useCallback(() => { setSpeakerOn((current) => { const next = !current; InCallManager.setSpeakerphoneOn(next); return next; }); }, []); // --- Callee idle-watch: attach to a ride and poll for an incoming offer --- // without creating one. Used by the call screen when opened for an incoming // call (the call row already exists as 'ringing'); the poll surfaces it as // `incoming` for the Accept/Decline UI. const watch = useCallback( (rideId: number, role: "rider" | "driver", name?: string) => { rideIdRef.current = rideId; roleRef.current = role; if (name) setPeerName(name); startPolling(); }, [startPolling], ); // Clean up the peer connection on unmount. useEffect(() => () => teardown(), [teardown]); return { status, peerName, incoming, localStream, remoteStream, micError, muted, speakerOn, toggleMute, toggleSpeaker, startCall, watch, answerCall, declineCall, endCall, }; };