alerte-secours/src/location/trackLocation.js
2026-01-12 18:28:22 +01:00

424 lines
14 KiB
JavaScript

import BackgroundGeolocation from "react-native-background-geolocation";
import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import jwtDecode from "jwt-decode";
import { initEmulatorMode } from "./emulatorService";
import {
getAlertState,
getAuthState,
getSessionState,
subscribeAlertState,
subscribeAuthState,
subscribeSessionState,
permissionsActions,
} from "~/stores";
import setLocationState from "~/location/setLocationState";
import { storeLocation } from "~/location/storage";
import env from "~/env";
import {
BASE_GEOLOCATION_CONFIG,
TRACKING_PROFILES,
} from "~/location/backgroundGeolocationConfig";
import {
ensureBackgroundGeolocationReady,
setBackgroundGeolocationEventHandlers,
} from "~/location/backgroundGeolocationService";
let trackLocationStartPromise = null;
export default function trackLocation() {
if (trackLocationStartPromise) return trackLocationStartPromise;
trackLocationStartPromise = (async () => {
const locationLogger = createLogger({
module: BACKGROUND_SCOPES.GEOLOCATION,
feature: "tracking",
});
let currentProfile = null;
let authReady = false;
let stopAlertSubscription = null;
let stopSessionSubscription = null;
// One-off startup refresh: when tracking is enabled at app launch, fetch a fresh fix once.
// This follows Transistorsoft docs guidance to use getCurrentPosition rather than forcing
// the SDK into moving mode with changePace(true).
let didRequestStartupFix = false;
let startupFixInFlight = null;
// When auth changes, we want a fresh persisted point for the newly effective identity.
// Debounced to avoid spamming `getCurrentPosition` if auth updates quickly (refresh/renew).
let authFixDebounceTimerId = null;
let authFixInFlight = null;
const AUTH_FIX_DEBOUNCE_MS = 1500;
const scheduleAuthFreshFix = () => {
if (authFixDebounceTimerId) {
clearTimeout(authFixDebounceTimerId);
authFixDebounceTimerId = null;
}
authFixInFlight = new Promise((resolve) => {
authFixDebounceTimerId = setTimeout(resolve, AUTH_FIX_DEBOUNCE_MS);
}).then(async () => {
try {
const before = await BackgroundGeolocation.getState();
locationLogger.info("Requesting auth-change location fix", {
enabled: before.enabled,
trackingMode: before.trackingMode,
isMoving: before.isMoving,
});
const location = await BackgroundGeolocation.getCurrentPosition({
samples: 3,
persist: true,
timeout: 30,
maximumAge: 5000,
desiredAccuracy: 50,
extras: {
auth_token_update: true,
},
});
locationLogger.info("Auth-change location fix acquired", {
accuracy: location?.coords?.accuracy,
latitude: location?.coords?.latitude,
longitude: location?.coords?.longitude,
timestamp: location?.timestamp,
});
} catch (error) {
locationLogger.warn("Auth-change location fix failed", {
error: error?.message,
code: error?.code,
stack: error?.stack,
});
} finally {
authFixDebounceTimerId = null;
authFixInFlight = null;
}
});
return authFixInFlight;
};
const computeHasOwnOpenAlert = () => {
try {
const { userId } = getSessionState();
const { alertingList } = getAlertState();
if (!userId || !Array.isArray(alertingList)) return false;
return alertingList.some(
({ oneAlert }) =>
oneAlert?.state === "open" && oneAlert?.userId === userId,
);
} catch (e) {
locationLogger.warn("Failed to compute active-alert state", {
error: e?.message,
});
return false;
}
};
const applyProfile = async (profileName) => {
if (!authReady) {
// We only apply profile once auth headers are configured.
return;
}
if (currentProfile === profileName) return;
const profile = TRACKING_PROFILES[profileName];
if (!profile) {
locationLogger.warn("Unknown tracking profile", { profileName });
return;
}
locationLogger.info("Applying tracking profile", {
profileName,
desiredAccuracy: profile.desiredAccuracy,
distanceFilter: profile.distanceFilter,
heartbeatInterval: profile.heartbeatInterval,
});
try {
await BackgroundGeolocation.setConfig(profile);
// Motion state strategy:
// - ACTIVE: force moving to begin aggressive tracking immediately.
// - IDLE: do NOT force stationary. Let the SDK's motion detection manage
// moving/stationary transitions so we still get distance-based updates
// (target: new point when moved ~50m+ even without an open alert).
if (profileName === "active") {
await BackgroundGeolocation.changePace(true);
}
currentProfile = profileName;
} catch (error) {
locationLogger.error("Failed to apply tracking profile", {
profileName,
error: error?.message,
stack: error?.stack,
});
}
};
// Log the geolocation sync URL for debugging
locationLogger.info("Geolocation sync URL configuration", {
url: env.GEOLOC_SYNC_URL,
isStaging: env.IS_STAGING,
});
// Handle auth function - no throttling or cooldown
async function handleAuth(userToken) {
// Defensive: ensure `.ready()` is resolved before any API call.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
locationLogger.info("Handling auth token update", {
hasToken: !!userToken,
});
if (!userToken) {
locationLogger.info("No auth token, stopping location tracking");
// Prevent any further uploads before stopping.
// This guards against persisted HTTP config continuing to flush queued records.
try {
await BackgroundGeolocation.setConfig({
url: "",
autoSync: false,
headers: {},
});
} catch (e) {
locationLogger.warn("Failed to clear BGGeo HTTP config on logout", {
error: e?.message,
});
}
await BackgroundGeolocation.stop();
locationLogger.debug("Location tracking stopped");
// Cleanup subscriptions when logged out.
try {
stopAlertSubscription && stopAlertSubscription();
stopSessionSubscription && stopSessionSubscription();
} finally {
stopAlertSubscription = null;
stopSessionSubscription = null;
}
authReady = false;
currentProfile = null;
if (authFixDebounceTimerId) {
clearTimeout(authFixDebounceTimerId);
authFixDebounceTimerId = null;
}
authFixInFlight = null;
return;
}
// unsub();
locationLogger.debug("Updating background geolocation config");
await BackgroundGeolocation.setConfig({
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
headers: {
Authorization: `Bearer ${userToken}`,
},
});
authReady = true;
// Log the authorization header that was set
locationLogger.debug(
"Set Authorization header for background geolocation",
{
headerSet: true,
tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null,
},
);
const state = await BackgroundGeolocation.getState();
try {
const decodedToken = jwtDecode(userToken);
locationLogger.debug("Decoded JWT token", { decodedToken });
} catch (error) {
locationLogger.error("Failed to decode JWT token", {
error: error.message,
});
}
if (!state.enabled) {
locationLogger.info("Starting location tracking");
try {
await BackgroundGeolocation.start();
locationLogger.debug("Location tracking started successfully");
} catch (error) {
locationLogger.error("Failed to start location tracking", {
error: error.message,
stack: error.stack,
});
}
}
// Always request a fresh persisted point on any token update.
// This ensures a newly connected user gets an immediate point even if they don't move.
scheduleAuthFreshFix();
// Request a single fresh location-fix on each app launch when tracking is enabled.
// - We do this only after auth headers are configured so the persisted point can sync.
// - We do NOT force moving mode.
if (!didRequestStartupFix) {
didRequestStartupFix = true;
startupFixInFlight = scheduleAuthFreshFix();
} else if (authFixInFlight) {
// Avoid concurrent fix calls if auth updates race.
await authFixInFlight;
}
// Ensure we are NOT forcing "moving" mode by default.
// Default profile is idle unless an active alert requires higher accuracy.
const shouldBeActive = computeHasOwnOpenAlert();
await applyProfile(shouldBeActive ? "active" : "idle");
// Subscribe to changes that may require switching profiles.
if (!stopSessionSubscription) {
stopSessionSubscription = subscribeSessionState(
(s) => s?.userId,
() => {
const active = computeHasOwnOpenAlert();
applyProfile(active ? "active" : "idle");
},
);
}
if (!stopAlertSubscription) {
stopAlertSubscription = subscribeAlertState(
(s) => s?.alertingList,
() => {
const active = computeHasOwnOpenAlert();
applyProfile(active ? "active" : "idle");
},
);
}
}
setBackgroundGeolocationEventHandlers({
onLocation: async (location) => {
locationLogger.debug("Location update received", {
coords: location.coords,
timestamp: location.timestamp,
activity: location.activity,
battery: location.battery,
sample: location.sample,
});
// Ignore sampling locations (eg, emitted during getCurrentPosition) to avoid UI/storage churn.
// The final persisted location will arrive with sample=false.
if (location.sample) return;
if (
location.coords &&
location.coords.latitude &&
location.coords.longitude
) {
setLocationState(location.coords);
// Also store in AsyncStorage for last known location fallback
storeLocation(location.coords, location.timestamp);
}
},
onLocationError: (error) => {
locationLogger.warn("Location error", {
error: error?.message,
code: error?.code,
});
},
onHttp: async (response) => {
// Log success/failure for visibility into token expiry, server errors, etc.
locationLogger.debug("HTTP response received", {
success: response?.success,
status: response?.status,
responseText: response?.responseText,
});
},
onMotionChange: (event) => {
locationLogger.info("Motion change", {
isMoving: event?.isMoving,
location: event?.location?.coords,
});
},
onActivityChange: (event) => {
locationLogger.info("Activity change", {
activity: event?.activity,
confidence: event?.confidence,
});
},
onProviderChange: (event) => {
locationLogger.info("Provider change", {
status: event?.status,
enabled: event?.enabled,
network: event?.network,
gps: event?.gps,
accuracyAuthorization: event?.accuracyAuthorization,
});
},
onConnectivityChange: (event) => {
locationLogger.info("Connectivity change", {
connected: event?.connected,
});
},
onEnabledChange: (enabled) => {
locationLogger.info("Enabled change", { enabled });
},
});
try {
locationLogger.info("Initializing background geolocation");
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Only set the permission state if we already have the permission
const state = await BackgroundGeolocation.getState();
locationLogger.debug("Background geolocation state", {
enabled: state.enabled,
trackingMode: state.trackingMode,
isMoving: state.isMoving,
schedulerEnabled: state.schedulerEnabled,
});
if (state.enabled) {
locationLogger.info("Background location permission confirmed");
permissionsActions.setLocationBackground(true);
} else {
locationLogger.warn(
"Background location not enabled in geolocation state",
);
}
// if (LOCAL_DEV) {
// // fixing issue on android emulator (which doesn't have accelerometer or gyroscope) by manually enabling location updates
// setInterval(
// () => {
// BackgroundGeolocation.changePace(true);
// },
// 30 * 60 * 1000,
// );
// }
} catch (error) {
locationLogger.error("Location tracking initialization failed", {
error: error.message,
stack: error.stack,
code: error.code,
});
}
const { userToken } = getAuthState();
locationLogger.debug("Setting up auth state subscription");
subscribeAuthState(({ userToken }) => userToken, handleAuth);
locationLogger.debug("Performing initial auth handling");
handleAuth(userToken);
// Initialize emulator mode only in dev/staging to avoid accidental production battery drain.
if (__DEV__ || env.IS_STAGING) {
initEmulatorMode();
}
})();
return trackLocationStartPromise;
}