// 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { try { await fetchAPI("/(api)/push/token", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); } catch (error) { console.log("[PUSH_UNREGISTER]: ", error); } };