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>
195 lines
7.0 KiB
TypeScript
195 lines
7.0 KiB
TypeScript
import * as Location from "expo-location";
|
|
import { AppState } from "react-native";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
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, which is what keeps the driver eligible for matching.
|
|
//
|
|
// Two trackers run, and they do different jobs:
|
|
//
|
|
// 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;
|
|
|
|
// 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 () => {
|
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
if (cancelled || status !== "granted") return;
|
|
|
|
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) {
|
|
record(fixFromCoords(cached.coords));
|
|
}
|
|
|
|
const subscription = await Location.watchPositionAsync(
|
|
{
|
|
accuracy: Location.Accuracy.Balanced,
|
|
timeInterval: PING_INTERVAL_MS,
|
|
// 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) record(fixFromCoords(coords));
|
|
},
|
|
);
|
|
|
|
if (cancelled) {
|
|
await subscription.remove();
|
|
return;
|
|
}
|
|
subscriptionRef.current = subscription;
|
|
};
|
|
|
|
const stop = () => {
|
|
const sub = subscriptionRef.current;
|
|
subscriptionRef.current = null;
|
|
void sub?.remove();
|
|
};
|
|
|
|
void start();
|
|
|
|
// Restart the watch whenever the app comes back to the foreground. While
|
|
// 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;
|
|
stop();
|
|
if (!cancelled) void start();
|
|
};
|
|
const subscription = AppState.addEventListener("change", onAppStateChange);
|
|
|
|
return () => {
|
|
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;
|
|
};
|