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>
440 lines
14 KiB
TypeScript
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,
|
|
};
|
|
};
|