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>
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
// Client half of ride-offer notifications.
|
|
//
|
|
// The driver dashboard polls every few seconds, but a poll only runs while the
|
|
// app is foregrounded and an offer expires in 15 seconds — so a locked phone
|
|
// was silently skipped by dispatch and the driver never learned a ride had
|
|
// been offered to them.
|
|
//
|
|
// There are two ways to fix that, and this app uses both:
|
|
//
|
|
// 1. LOCAL notifications, which work with no credentials at all. While a
|
|
// driver is online the app runs a location foreground service (see
|
|
// lib/location-task.ts), so JS is alive even with the screen off. Each
|
|
// location ping tells us whether an offer is waiting, and we raise a
|
|
// local notification for it. This is the path that works today.
|
|
//
|
|
// 2. REMOTE push through Expo, which additionally reaches a driver whose app
|
|
// has been killed outright. It needs an EAS project id and FCM/APNs
|
|
// credentials, neither of which is configured yet — so registerForPush
|
|
// returns null and the server simply has no tokens to send to. Nothing
|
|
// breaks; it lights up on its own once those credentials exist.
|
|
|
|
import * as Notifications from "expo-notifications";
|
|
import { Platform } from "react-native";
|
|
|
|
import { OFFER_CHANNEL_ID } from "@/constants/dispatch";
|
|
import { fetchAPI } from "@/lib/fetch";
|
|
|
|
/**
|
|
* How a notification behaves when it lands while the app is open. A ride offer
|
|
* is time-critical, so it is shown rather than swallowed — the driver may be
|
|
* on another screen, and four seconds of poll latency is a quarter of the
|
|
* window they have to answer.
|
|
*/
|
|
export const configureNotificationHandler = (): void => {
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: async () => ({
|
|
shouldShowAlert: true,
|
|
shouldPlaySound: true,
|
|
shouldSetBadge: false,
|
|
}),
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Android routes every notification through a channel, and the channel — not
|
|
* the message — decides whether it makes a sound, vibrates, or is allowed to
|
|
* interrupt. A ride offer needs all three, so it gets its own channel at MAX
|
|
* importance instead of riding on the default one.
|
|
*/
|
|
export const ensureOfferChannel = async (): Promise<void> => {
|
|
if (Platform.OS !== "android") return;
|
|
|
|
try {
|
|
await Notifications.setNotificationChannelAsync(OFFER_CHANNEL_ID, {
|
|
name: "Ride requests",
|
|
importance: Notifications.AndroidImportance.MAX,
|
|
// Distinctive double-buzz so an offer is recognisable from a pocket.
|
|
vibrationPattern: [0, 250, 150, 400],
|
|
sound: "default",
|
|
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
|
|
lightColor: "#0286FF",
|
|
});
|
|
} catch (error) {
|
|
console.log("[NOTIF_CHANNEL]: ", error);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Ask for the notification permission. Called when a driver goes online, which
|
|
* is the first moment the app has a concrete reason to interrupt them.
|
|
*/
|
|
export const ensureNotificationPermission = async (): Promise<boolean> => {
|
|
try {
|
|
await ensureOfferChannel();
|
|
|
|
const existing = await Notifications.getPermissionsAsync();
|
|
if (existing.status === "granted") return true;
|
|
|
|
const asked = await Notifications.requestPermissionsAsync();
|
|
return asked.status === "granted";
|
|
} catch (error) {
|
|
console.log("[NOTIF_PERMISSION]: ", error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// An open request is re-reported by every location ping until this driver
|
|
// offers on it or it dies, so the notification has to be raised once per ride
|
|
// rather than once per ping — otherwise a driver gets a buzz every five
|
|
// seconds. Module-level because the location task is not a React component
|
|
// and has no state of its own.
|
|
let lastNotifiedRideId: number | null = null;
|
|
|
|
/**
|
|
* Raise a local notification for an open request nearby, at most once per
|
|
* ride. Returns whether a notification was actually presented.
|
|
*/
|
|
export const notifyRequest = async (request: {
|
|
ride_id: number;
|
|
origin_address: string;
|
|
fare_price: number;
|
|
}): Promise<boolean> => {
|
|
if (lastNotifiedRideId === request.ride_id) return false;
|
|
lastNotifiedRideId = request.ride_id;
|
|
|
|
try {
|
|
await ensureOfferChannel();
|
|
await Notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: "New ride request nearby",
|
|
body: `$${(request.fare_price / 100).toFixed(2)} · pickup at ${request.origin_address}`,
|
|
sound: "default",
|
|
priority: Notifications.AndroidNotificationPriority.MAX,
|
|
vibrate: [0, 250, 150, 400],
|
|
data: { type: "ride_request", rideId: request.ride_id },
|
|
},
|
|
// null means "present it now" rather than scheduling for later.
|
|
trigger: null,
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.log("[NOTIF_REQUEST]: ", error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Clear the dedupe memory — called when the driver goes offline. */
|
|
export const resetOfferNotifications = (): void => {
|
|
lastNotifiedRideId = null;
|
|
};
|
|
|
|
/**
|
|
* Register this device for REMOTE push. Dormant until an EAS project id and
|
|
* FCM/APNs credentials are configured: without them getExpoPushTokenAsync
|
|
* throws, we log it and return null, and the server just has no token to send
|
|
* to. Local notifications above are unaffected.
|
|
*/
|
|
// Remembered so sign-out can release this device without the caller having to
|
|
// thread the token through the session.
|
|
let currentPushToken: string | null = null;
|
|
|
|
export const registerForPush = async (): Promise<string | null> => {
|
|
try {
|
|
const granted = await ensureNotificationPermission();
|
|
if (!granted) return null;
|
|
|
|
const { data: token } = await Notifications.getExpoPushTokenAsync();
|
|
if (!token) return null;
|
|
|
|
await fetchAPI("/(api)/push/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ token, platform: Platform.OS }),
|
|
});
|
|
|
|
currentPushToken = token;
|
|
return token;
|
|
} catch (error) {
|
|
// Expected until push credentials exist. Not fatal by design.
|
|
console.log("[PUSH_REGISTER]: ", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Hand this device back on sign-out. Phones get shared — without this the
|
|
* previous account keeps receiving ride offers on a phone someone else is now
|
|
* signed in on. Safe to call when nothing was ever registered.
|
|
*/
|
|
export const releaseCurrentPush = async (): Promise<void> => {
|
|
const token = currentPushToken;
|
|
currentPushToken = null;
|
|
resetOfferNotifications();
|
|
if (token) await unregisterPush(token);
|
|
};
|
|
|
|
/**
|
|
* Release this device on sign-out, so the next person to use the phone doesn't
|
|
* receive the previous account's ride offers.
|
|
*/
|
|
export const unregisterPush = async (token: string): Promise<void> => {
|
|
try {
|
|
await fetchAPI("/(api)/push/token", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
} catch (error) {
|
|
console.log("[PUSH_UNREGISTER]: ", error);
|
|
}
|
|
};
|