// 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 => { 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 => { 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 => { 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 => { try { if (await isTrackingLocation()) { await Location.stopLocationUpdatesAsync(DRIVER_LOCATION_TASK); } } catch (error) { console.log("[DRIVER_LOCATION_STOP]: ", error); } };