Files
waseel/lib/notifications.ts
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

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