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(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; };