Files
waseel/lib/location-task.ts
T
KrikoriosandClaude Opus 5 8807ff41c5 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>
2026-08-26 02:17:55 +03:00

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