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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
+439
@@ -0,0 +1,439 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user