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>
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
// Background location for drivers.
|
|
//
|
|
// Dispatch drops any driver whose last position ping is over 60 seconds old.
|
|
// The old foreground-only watch stopped the moment the app was backgrounded,
|
|
// so a driver who locked their phone went stale within a minute and quietly
|
|
// left the match pool — while the app still showed them as "Online".
|
|
//
|
|
// expo-location's task-based updates keep running behind an Android foreground
|
|
// service (the persistent "Waseel is finding you rides" notification), which
|
|
// both keeps the process alive and makes the location use visible to the
|
|
// driver, as it should be.
|
|
//
|
|
// The task must be defined at module scope, not inside a component: Android
|
|
// can restart the app process headlessly to deliver a location update, and the
|
|
// task has to already be registered when the JS bundle finishes evaluating.
|
|
// This module is imported from app/_layout.tsx for exactly that reason.
|
|
|
|
import * as Location from "expo-location";
|
|
import * as TaskManager from "expo-task-manager";
|
|
import { AppState } from "react-native";
|
|
|
|
import { fetchAPI, setAuthToken } from "@/lib/fetch";
|
|
import { notifyRequest } from "@/lib/notifications";
|
|
import { readStoredToken } from "@/lib/token-store";
|
|
|
|
export const DRIVER_LOCATION_TASK = "waseel-driver-location";
|
|
|
|
// Relative API paths ("/(api)/...") are resolved against the router origin,
|
|
// which is set up when the app's React tree boots. A location update can be
|
|
// delivered to a process Android restarted headlessly, where that hasn't
|
|
// necessarily happened — so the background ping addresses the server
|
|
// explicitly. Falls back to the relative path when no origin is configured,
|
|
// which is the normal in-app case.
|
|
const API_ORIGIN = (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(
|
|
/\/+$/,
|
|
"",
|
|
);
|
|
|
|
const endpoint = (path: string): string =>
|
|
API_ORIGIN ? `${API_ORIGIN}${path}` : path;
|
|
|
|
// Last position we know about, shared between the background task and the
|
|
// foreground hook. The heartbeat re-sends this on a timer even when nothing
|
|
// new arrives, because "where the driver is" and "is the driver still there"
|
|
// are different questions and only the second one has a deadline.
|
|
export type DriverFix = {
|
|
latitude: number;
|
|
longitude: number;
|
|
/** Degrees clockwise from north, or null when the device can't tell. */
|
|
heading?: number | null;
|
|
/** km/h, or null when unknown. */
|
|
speedKph?: number | null;
|
|
};
|
|
|
|
let lastKnownCoords: DriverFix | null = null;
|
|
|
|
export const setLastKnownCoords = (fix: DriverFix): void => {
|
|
lastKnownCoords = fix;
|
|
};
|
|
|
|
export const getLastKnownCoords = () => lastKnownCoords;
|
|
|
|
// Set while the driver screen's heartbeat timer is running, so the background
|
|
// task doesn't send a second ping for the same position. When the app has been
|
|
// restarted headlessly there is no hook and no timer, and the task pings.
|
|
let heartbeatActive = false;
|
|
|
|
export const setHeartbeatActive = (active: boolean): void => {
|
|
heartbeatActive = active;
|
|
};
|
|
|
|
/**
|
|
* POST a position to the server and act on whatever came back with it.
|
|
*
|
|
* Exported because the foreground hook's heartbeat uses the same path — one
|
|
* place that knows how a ping is made, so the background and foreground routes
|
|
* can't drift apart.
|
|
*/
|
|
export const pingDriverLocation = async (fix: DriverFix): Promise<void> => {
|
|
setLastKnownCoords(fix);
|
|
// On a headless restart the module-level auth token in lib/fetch is empty —
|
|
// no React tree has run to set it — so seed it from secure storage before
|
|
// the request. A no-op in the normal foreground case.
|
|
const token = await readStoredToken();
|
|
if (!token) return;
|
|
setAuthToken(token);
|
|
|
|
const res = await fetchAPI(endpoint("/(api)/driver/location"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
latitude: fix.latitude,
|
|
longitude: fix.longitude,
|
|
heading: fix.heading ?? null,
|
|
speed_kph: fix.speedKph ?? null,
|
|
}),
|
|
});
|
|
|
|
// The heartbeat carries the nearest open request this driver could take.
|
|
// When the app is in the foreground the dashboard already lists it, so we
|
|
// only interrupt with a notification when they can't see the screen.
|
|
const request = res?.data?.pending_request;
|
|
if (request && AppState.currentState !== "active") {
|
|
await notifyRequest(request);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Normalise an expo-location fix into what the server stores.
|
|
*
|
|
* expo-location reports -1 for an unknown heading and can report a negative
|
|
* speed on some devices; both mean "no reading", not "north" and "reversing".
|
|
*/
|
|
export const fixFromCoords = (
|
|
coords: Location.LocationObjectCoords,
|
|
): DriverFix => ({
|
|
latitude: coords.latitude,
|
|
longitude: coords.longitude,
|
|
heading:
|
|
typeof coords.heading === "number" && coords.heading >= 0
|
|
? coords.heading
|
|
: null,
|
|
speedKph:
|
|
typeof coords.speed === "number" && coords.speed >= 0
|
|
? coords.speed * 3.6
|
|
: null,
|
|
});
|
|
|
|
TaskManager.defineTask(DRIVER_LOCATION_TASK, async ({ data, error }) => {
|
|
if (error) {
|
|
console.log("[DRIVER_LOCATION_TASK]: ", error.message);
|
|
return;
|
|
}
|
|
|
|
const { locations } = (data ?? {}) as {
|
|
locations?: Location.LocationObject[];
|
|
};
|
|
const last = locations?.[locations.length - 1];
|
|
if (!last) return;
|
|
|
|
// Always record the position — this is what the heartbeat timer re-sends.
|
|
setLastKnownCoords(fixFromCoords(last.coords));
|
|
|
|
// The driver screen's timer owns the heartbeat whenever it's running. The
|
|
// task only pings when there is no timer, i.e. Android restarted the process
|
|
// headlessly to deliver this update and no React tree ever mounted.
|
|
if (heartbeatActive) return;
|
|
|
|
try {
|
|
await pingDriverLocation(fixFromCoords(last.coords));
|
|
} catch (err) {
|
|
// A failed ping is non-fatal — the next one retries. What takes a driver
|
|
// out of the match pool is last_seen going stale, not a single 500.
|
|
console.log("[DRIVER_LOCATION_TASK_PING]: ", err);
|
|
}
|
|
});
|
|
|
|
/** Is the background task currently delivering updates? */
|
|
export const isTrackingLocation = async (): Promise<boolean> => {
|
|
try {
|
|
return await Location.hasStartedLocationUpdatesAsync(DRIVER_LOCATION_TASK);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Start background tracking. Returns false when the OS refused, so the caller
|
|
* can fall back to the foreground-only watch rather than leaving the driver
|
|
* with no tracking at all.
|
|
*/
|
|
export const startBackgroundTracking = async (): Promise<boolean> => {
|
|
try {
|
|
if (await isTrackingLocation()) return true;
|
|
|
|
await Location.startLocationUpdatesAsync(DRIVER_LOCATION_TASK, {
|
|
accuracy: Location.Accuracy.Balanced,
|
|
timeInterval: 5000,
|
|
// Deliberately 0, not a displacement threshold. On Android the time and
|
|
// distance conditions are AND-ed (distanceInterval becomes
|
|
// setSmallestDisplacement), so a driver parked at a taxi stand — the
|
|
// single most common way to wait for a ride — produces no updates at
|
|
// all, goes stale after 60s and silently drops out of the match pool
|
|
// while the app still says "Online". The ping IS the liveness signal, so
|
|
// it has to fire whether or not the car has moved.
|
|
distanceInterval: 0,
|
|
// Position updates are worthless late, and Android will otherwise hold
|
|
// them back to save battery.
|
|
deferredUpdatesInterval: 0,
|
|
pausesUpdatesAutomatically: false,
|
|
foregroundService: {
|
|
notificationTitle: "Waseel — you're online",
|
|
notificationBody: "Receiving ride requests. Tap to open.",
|
|
notificationColor: "#0286FF",
|
|
},
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.log("[DRIVER_LOCATION_START]: ", error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Stop background tracking and tear down the foreground service. */
|
|
export const stopBackgroundTracking = async (): Promise<void> => {
|
|
try {
|
|
if (await isTrackingLocation()) {
|
|
await Location.stopLocationUpdatesAsync(DRIVER_LOCATION_TASK);
|
|
}
|
|
} catch (error) {
|
|
console.log("[DRIVER_LOCATION_STOP]: ", error);
|
|
}
|
|
};
|