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:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+119 -30
View File
@@ -1,47 +1,68 @@
import * as Location from "expo-location";
import { AppState } from "react-native";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { fetchAPI } from "@/lib/fetch";
import {
fixFromCoords,
getLastKnownCoords,
pingDriverLocation,
setHeartbeatActive,
setLastKnownCoords,
startBackgroundTracking,
stopBackgroundTracking,
type DriverFix,
} from "@/lib/location-task";
import { resetOfferNotifications } from "@/lib/notifications";
// While the driver is online, watch their position and POST it to the server
// as a heartbeat. Each ping both updates the driver's lat/lng and refreshes
// last_seen/online, which is what keeps the driver eligible for matching. The
// watch is started when `online` flips true and torn down on false/unmount.
// last_seen, which is what keeps the driver eligible for matching.
//
// The watch must also restart when the app returns to the foreground: the OS
// suspends location updates in the background, and the subscription we hold
// does not auto-revive. Without this, a driver who briefly backgrounds the app
// goes permanently stale (last_seen older than the dispatch freshness window)
// and stops receiving ride requests until they toggle offline→online again.
// Two trackers run, and they do different jobs:
//
// Pings are throttled to every PING_INTERVAL_MS so a fast-moving driver
// doesn't hammer the server, and the location permission is only requested
// once the driver actually intends to go online.
// Background — expo-location's task-based updates behind an Android
// foreground service (lib/location-task.ts). This is the one that matters:
// it keeps pinging with the screen off, so a driver who pockets their phone
// stays in the match pool instead of going stale within a minute. It also
// carries offer notifications back.
//
// Foreground — a plain watchPositionAsync, purely so this hook can hand the
// driver's current coordinates back to the UI for the map. The background
// task can't update React state; it runs outside the component tree.
//
// The foreground watch only pings the server itself when background tracking
// was refused (an OS that denied the permission), so the two don't double up.
//
// The watch must restart when the app returns to the foreground: the OS
// suspends location updates in the background and the subscription we hold
// does not auto-revive.
const PING_INTERVAL_MS = 5000;
// Returns the driver's last-known position (updated alongside each ping) so
// the caller can center a map on it, e.g. to show the rider's pickup point
// relative to where the driver actually is.
export const useDriverLocation = (online: boolean) => {
const subscriptionRef = useRef<Location.LocationSubscription | null>(null);
const onlineRef = useRef(online);
onlineRef.current = online;
// True once the foreground service is running, in which case the foreground
// watch is display-only and must not ping as well.
const backgroundActive = useRef(false);
const [coords, setCoords] = useState<{
latitude: number;
longitude: number;
} | null>(null);
useEffect(() => {
if (!online) return;
let cancelled = false;
const ping = async (latitude: number, longitude: number) => {
try {
await fetchAPI("/(api)/driver/location", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ latitude, longitude }),
});
} catch (error) {
// A failed ping is non-fatal — the next one will retry. last_seen
// going stale is what takes a driver out of the match pool, not a 500.
console.log("[DRIVER_LOCATION_PING]: ", error);
}
// A new position updates the map and the shared last-known value. It does
// NOT ping on its own — see the heartbeat effect below for why.
const record = (fix: DriverFix) => {
setCoords({ latitude: fix.latitude, longitude: fix.longitude });
setLastKnownCoords(fix);
};
const start = async () => {
@@ -50,23 +71,39 @@ export const useDriverLocation = (online: boolean) => {
if (!(await Location.hasServicesEnabledAsync())) return;
// "Allow all the time" is requested but not required: the foreground
// service is what actually keeps updates flowing on Android, and a
// driver who only grants "while using" still gets tracked while the
// service notification is up.
try {
await Location.requestBackgroundPermissionsAsync();
} catch (error) {
console.log("[DRIVER_LOCATION_BG_PERMISSION]: ", error);
}
if (cancelled) return;
backgroundActive.current = await startBackgroundTracking();
// Seed the server with the last known position immediately, so the
// driver is matchable without waiting for the first watch callback.
const cached = await Location.getLastKnownPositionAsync({
maxAge: 5 * 60 * 1000,
});
if (!cancelled && cached) {
void ping(cached.coords.latitude, cached.coords.longitude);
record(fixFromCoords(cached.coords));
}
const subscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
timeInterval: PING_INTERVAL_MS,
distanceInterval: 20,
// 0, not a displacement threshold — see the note in
// lib/location-task.ts. A stationary driver must keep reporting, or
// dispatch treats them as gone.
distanceInterval: 0,
},
({ coords }) => {
if (!cancelled) void ping(coords.latitude, coords.longitude);
if (!cancelled) record(fixFromCoords(coords));
},
);
@@ -86,8 +123,9 @@ export const useDriverLocation = (online: boolean) => {
void start();
// Restart the watch whenever the app comes back to the foreground. While
// backgrounded the OS pauses location updates and the old subscription is
// dead; without re-starting it the driver never pings again.
// backgrounded the OS pauses the in-process watch and the old
// subscription is dead; the task-based updates carry on regardless, so
// this only restores the coordinates the UI draws with.
const onAppStateChange = (state: string) => {
if (state !== "active") return;
if (!onlineRef.current) return;
@@ -100,6 +138,57 @@ export const useDriverLocation = (online: boolean) => {
cancelled = true;
stop();
subscription.remove();
backgroundActive.current = false;
// Going offline must also tear down the foreground service, or the
// driver is left with a "you're online" notification and a GPS drain
// for a shift that has ended.
void stopBackgroundTracking();
resetOfferNotifications();
};
}, [online]);
};
// The heartbeat.
//
// This is a plain timer, deliberately, and it is the thing that keeps a
// driver in the match pool. Tying the heartbeat to position callbacks —
// which is what this did before — meant liveness depended on the OS deciding
// to emit a new fix, and a driver parked at a stand with the phone on the
// dashboard emits nothing at all: Android's fused provider had no reason to
// wake, so the pings stopped, last_seen aged past 60 seconds and the driver
// silently vanished from every rider's map while their own screen still read
// "Online — receiving ride requests".
//
// "Where is the driver" and "is the driver still there" are separate
// questions, and only the second one has a deadline. So the timer re-sends
// the last known position on a fixed cadence whether or not the car has
// moved — a stationary driver reporting the same coordinates is exactly the
// signal dispatch needs.
useEffect(() => {
if (!online) return;
setHeartbeatActive(true);
const beat = async () => {
const position = getLastKnownCoords();
if (!position) return;
try {
await pingDriverLocation(position);
} catch (error) {
// Non-fatal — the next beat retries. A dropped ping only matters if
// enough of them drop in a row to age last_seen out.
console.log("[DRIVER_HEARTBEAT]: ", error);
}
};
void beat();
const timer = setInterval(() => void beat(), PING_INTERVAL_MS);
return () => {
clearInterval(timer);
setHeartbeatActive(false);
};
}, [online]);
return coords;
};