Files
waseel/lib/use-call.ts
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

440 lines
14 KiB
TypeScript

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<boolean> => {
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<void> =>
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<void>;
/** 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<void>;
declineCall: () => Promise<void>;
endCall: () => Promise<void>;
};
// 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<CallStatus>("idle");
const [peerName, setPeerName] = useState<string | null>(null);
const [incoming, setIncoming] = useState<CallRecord | null>(null);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(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<RTCPeerConnection | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const rideIdRef = useRef<number | null>(null);
const roleRef = useRef<"rider" | "driver" | null>(null);
const callIdRef = useRef<number | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | 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<RTCPeerConnection | null> => {
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,
};
};