fix: track location

This commit is contained in:
devthejo 2026-01-12 18:28:22 +01:00
parent 9b92fed825
commit 29d7747b51
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
7 changed files with 595 additions and 302 deletions

View file

@ -2,9 +2,6 @@
import "./warnFilter"; import "./warnFilter";
import "expo-splash-screen"; import "expo-splash-screen";
import BackgroundGeolocation from "react-native-background-geolocation";
import { Platform } from "react-native";
import notifee from "@notifee/react-native"; import notifee from "@notifee/react-native";
import messaging from "@react-native-firebase/messaging"; import messaging from "@react-native-firebase/messaging";
@ -18,9 +15,6 @@ import App from "~/app";
import { onBackgroundEvent as notificationBackgroundEvent } from "~/notifications/onEvent"; import { onBackgroundEvent as notificationBackgroundEvent } from "~/notifications/onEvent";
import onMessageReceived from "~/notifications/onMessageReceived"; import onMessageReceived from "~/notifications/onMessageReceived";
import { createLogger } from "~/lib/logger";
// import { executeHeartbeatSync } from "~/location/backgroundTask";
// setup notification, this have to stay in index.js // setup notification, this have to stay in index.js
notifee.onBackgroundEvent(notificationBackgroundEvent); notifee.onBackgroundEvent(notificationBackgroundEvent);
messaging().setBackgroundMessageHandler(onMessageReceived); messaging().setBackgroundMessageHandler(onMessageReceived);
@ -29,29 +23,3 @@ messaging().setBackgroundMessageHandler(onMessageReceived);
// It also ensures that whether you load the app in Expo Go or in a native build, // It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately // the environment is set up appropriately
registerRootComponent(App); registerRootComponent(App);
const geolocBgLogger = createLogger({
service: "background-geolocation",
task: "headless",
});
// const HeadlessTask = async (event) => {
// try {
// switch (event?.name) {
// case "heartbeat":
// await executeHeartbeatSync();
// break;
// default:
// break;
// }
// } catch (error) {
// geolocBgLogger.error("HeadlessTask error", {
// error,
// event,
// });
// }
// };
// if (Platform.OS === "android") {
// BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
// }

View file

@ -0,0 +1,85 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { TRACK_MOVE } from "~/misc/devicePrefs";
import env from "~/env";
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile.
// High-accuracy and moving mode are enabled only when an active alert is open.
//
// Notes:
// - We avoid `reset: true` in production because it can unintentionally wipe persisted / configured state.
// In dev, `reset: true` is useful to avoid config drift while iterating.
// - `maxRecordsToPersist` must be > 1 to support offline catch-up.
export const BASE_GEOLOCATION_CONFIG = {
// Android Headless Mode (requires registering a headless task entrypoint in index.js)
enableHeadless: true,
// Default to low-power (idle) profile; will be overridden when needed.
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
// Default to the IDLE profile behaviour: we still want distance-based updates
// even with no open alert (see TRACKING_PROFILES.idle).
distanceFilter: 50,
// debug: true,
logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE,
// Permission request strategy
locationAuthorizationRequest: "Always",
// Lifecycle
stopOnTerminate: false,
startOnBoot: true,
// Background scheduling
heartbeatInterval: 3600,
// Android foreground service
foregroundService: true,
notification: {
title: "Alerte Secours",
text: "Suivi de localisation actif",
channelName: "Location tracking",
priority: BackgroundGeolocation.NOTIFICATION_PRIORITY_HIGH,
},
// Android 10+ rationale dialog
backgroundPermissionRationale: {
title:
"Autoriser Alerte-Secours à accéder à la localisation en arrière-plan",
message:
"Alerte-Secours nécessite la localisation en arrière-plan pour vous alerter en temps réel lorsqu'une personne à proximité a besoin d'aide urgente. Cette fonction est essentielle pour permettre une intervention rapide et efficace en cas d'urgence.",
positiveAction: "Autoriser",
negativeAction: "Désactiver",
},
// HTTP configuration
url: env.GEOLOC_SYNC_URL,
method: "POST",
httpRootProperty: "location",
batchSync: false,
autoSync: true,
// Persistence: keep enough records for offline catch-up.
// (The SDK already constrains with maxDaysToPersist; records are deleted after successful upload.)
maxRecordsToPersist: 1000,
maxDaysToPersist: 7,
// Development convenience
reset: !!__DEV__,
// Behavior tweaks
disableProviderChangeRecord: true,
};
export const TRACKING_PROFILES = {
idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 50,
heartbeatInterval: 3600,
},
active: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE,
heartbeatInterval: 900,
},
};

View file

@ -0,0 +1,122 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { BASE_GEOLOCATION_CONFIG } from "./backgroundGeolocationConfig";
const bgGeoLogger = createLogger({
module: BACKGROUND_SCOPES.GEOLOCATION,
feature: "bg-geo-service",
});
let readyPromise = null;
let lastReadyState = null;
let subscriptions = [];
let handlersSignature = null;
export async function ensureBackgroundGeolocationReady(
config = BASE_GEOLOCATION_CONFIG,
) {
if (readyPromise) return readyPromise;
readyPromise = (async () => {
bgGeoLogger.info("Calling BackgroundGeolocation.ready");
const state = await BackgroundGeolocation.ready(config);
lastReadyState = state;
bgGeoLogger.info("BackgroundGeolocation is ready", {
enabled: state?.enabled,
isMoving: state?.isMoving,
trackingMode: state?.trackingMode,
schedulerEnabled: state?.schedulerEnabled,
});
return state;
})().catch((error) => {
// Allow retry if ready fails.
readyPromise = null;
lastReadyState = null;
bgGeoLogger.error("BackgroundGeolocation.ready failed", {
error: error?.message,
stack: error?.stack,
code: error?.code,
});
throw error;
});
return readyPromise;
}
export function getLastReadyState() {
return lastReadyState;
}
export function setBackgroundGeolocationEventHandlers({
onLocation,
onLocationError,
onHttp,
onMotionChange,
onActivityChange,
onProviderChange,
onConnectivityChange,
onEnabledChange,
} = {}) {
// Avoid duplicate registration when `trackLocation()` is called multiple times.
// We use a simple signature so calling with identical functions is a no-op.
const sig = [
onLocation ? "L1" : "L0",
onHttp ? "H1" : "H0",
onMotionChange ? "M1" : "M0",
onActivityChange ? "A1" : "A0",
onProviderChange ? "P1" : "P0",
onConnectivityChange ? "C1" : "C0",
onEnabledChange ? "E1" : "E0",
].join("-");
if (handlersSignature === sig && subscriptions.length) {
return;
}
subscriptions.forEach((s) => s?.remove?.());
subscriptions = [];
if (onLocation) {
subscriptions.push(
BackgroundGeolocation.onLocation(onLocation, onLocationError),
);
}
if (onHttp) {
subscriptions.push(BackgroundGeolocation.onHttp(onHttp));
}
if (onMotionChange) {
subscriptions.push(BackgroundGeolocation.onMotionChange(onMotionChange));
}
if (onActivityChange) {
subscriptions.push(
BackgroundGeolocation.onActivityChange(onActivityChange),
);
}
if (onProviderChange) {
subscriptions.push(
BackgroundGeolocation.onProviderChange(onProviderChange),
);
}
if (onConnectivityChange) {
subscriptions.push(
BackgroundGeolocation.onConnectivityChange(onConnectivityChange),
);
}
if (onEnabledChange) {
subscriptions.push(BackgroundGeolocation.onEnabledChange(onEnabledChange));
}
handlersSignature = sig;
}
export async function stopBackgroundGeolocation() {
await ensureBackgroundGeolocationReady();
return BackgroundGeolocation.stop();
}
export async function startBackgroundGeolocation() {
await ensureBackgroundGeolocationReady();
return BackgroundGeolocation.start();
}

View file

@ -4,6 +4,9 @@ import { STORAGE_KEYS } from "~/storage/storageKeys";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
// Global variables // Global variables
let emulatorIntervalId = null; let emulatorIntervalId = null;
let isEmulatorModeEnabled = false; let isEmulatorModeEnabled = false;
@ -43,6 +46,8 @@ export const enableEmulatorMode = async () => {
} }
try { try {
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Call immediately once // Call immediately once
await BackgroundGeolocation.changePace(true); await BackgroundGeolocation.changePace(true);
emulatorLogger.debug("Initial changePace call successful"); emulatorLogger.debug("Initial changePace call successful");

View file

@ -9,6 +9,9 @@ import setLocationState from "./setLocationState";
import camelCaseKeys from "~/utils/string/camelCaseKeys"; import camelCaseKeys from "~/utils/string/camelCaseKeys";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second const RETRY_DELAY = 1000; // 1 second
@ -17,6 +20,10 @@ export async function getCurrentLocation() {
while (retries < MAX_RETRIES) { while (retries < MAX_RETRIES) {
try { try {
// Vendor requirement: never call APIs like getState/requestPermission/getCurrentPosition
// before `.ready()` has resolved.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Check for location permissions and services // Check for location permissions and services
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();

View file

@ -1,5 +1,4 @@
import BackgroundGeolocation from "react-native-background-geolocation"; import BackgroundGeolocation from "react-native-background-geolocation";
import { TRACK_MOVE } from "~/misc/devicePrefs";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import jwtDecode from "jwt-decode"; import jwtDecode from "jwt-decode";
@ -20,67 +19,21 @@ import { storeLocation } from "~/location/storage";
import env from "~/env"; import env from "~/env";
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile. import {
// High-accuracy and "moving" mode are only enabled when an active alert is open. BASE_GEOLOCATION_CONFIG,
const baseConfig = { TRACKING_PROFILES,
// https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode } from "~/location/backgroundGeolocationConfig";
enableHeadless: true, import {
disableProviderChangeRecord: true, ensureBackgroundGeolocationReady,
// disableMotionActivityUpdates: true, setBackgroundGeolocationEventHandlers,
// Default to low-power (idle) profile; will be overridden when needed. } from "~/location/backgroundGeolocationService";
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
// Larger distance filter in idle mode to prevent frequent GPS wakes.
distanceFilter: 200,
// debug: true, // Enable debug mode for more detailed logs
logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE,
// Disable automatic permission requests
locationAuthorizationRequest: "Always",
stopOnTerminate: false,
startOnBoot: true,
// Keep heartbeat very infrequent in idle mode.
heartbeatInterval: 3600,
// Force the plugin to start aggressively
foregroundService: true,
notification: {
title: "Alerte Secours",
text: "Suivi de localisation actif",
channelName: "Location tracking",
priority: BackgroundGeolocation.NOTIFICATION_PRIORITY_HIGH,
},
backgroundPermissionRationale: {
title:
"Autoriser Alerte-Secours à accéder à la localisation en arrière-plan",
message:
"Alerte-Secours nécessite la localisation en arrière-plan pour vous alerter en temps réel lorsqu'une personne à proximité a besoin d'aide urgente. Cette fonction est essentielle pour permettre une intervention rapide et efficace en cas d'urgence.",
positiveAction: "Autoriser",
negativeAction: "Désactiver",
},
// Enhanced HTTP configuration
url: env.GEOLOC_SYNC_URL,
method: "POST", // Explicitly set HTTP method
httpRootProperty: "location", // Specify the root property for the locations array
// Configure persistence
maxRecordsToPersist: 1, // Limit the number of records to store
maxDaysToPersist: 7, // Limit the age of records to persist
batchSync: false,
autoSync: true,
reset: true,
};
const TRACKING_PROFILES = { let trackLocationStartPromise = null;
idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 200,
heartbeatInterval: 3600,
},
active: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE,
heartbeatInterval: 900,
},
};
export default async function trackLocation() { export default function trackLocation() {
if (trackLocationStartPromise) return trackLocationStartPromise;
trackLocationStartPromise = (async () => {
const locationLogger = createLogger({ const locationLogger = createLogger({
module: BACKGROUND_SCOPES.GEOLOCATION, module: BACKGROUND_SCOPES.GEOLOCATION,
feature: "tracking", feature: "tracking",
@ -91,6 +44,67 @@ export default async function trackLocation() {
let stopAlertSubscription = null; let stopAlertSubscription = null;
let stopSessionSubscription = 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 = () => { const computeHasOwnOpenAlert = () => {
try { try {
const { userId } = getSessionState(); const { userId } = getSessionState();
@ -131,10 +145,14 @@ export default async function trackLocation() {
try { try {
await BackgroundGeolocation.setConfig(profile); await BackgroundGeolocation.setConfig(profile);
// Key battery fix: // Motion state strategy:
// - IDLE profile forces stationary mode // - ACTIVE: force moving to begin aggressive tracking immediately.
// - ACTIVE profile forces moving mode // - IDLE: do NOT force stationary. Let the SDK's motion detection manage
await BackgroundGeolocation.changePace(profileName === "active"); // 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; currentProfile = profileName;
} catch (error) { } catch (error) {
@ -154,11 +172,29 @@ export default async function trackLocation() {
// Handle auth function - no throttling or cooldown // Handle auth function - no throttling or cooldown
async function handleAuth(userToken) { async function handleAuth(userToken) {
// Defensive: ensure `.ready()` is resolved before any API call.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
locationLogger.info("Handling auth token update", { locationLogger.info("Handling auth token update", {
hasToken: !!userToken, hasToken: !!userToken,
}); });
if (!userToken) { if (!userToken) {
locationLogger.info("No auth token, stopping location tracking"); 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(); await BackgroundGeolocation.stop();
locationLogger.debug("Location tracking stopped"); locationLogger.debug("Location tracking stopped");
@ -173,6 +209,12 @@ export default async function trackLocation() {
authReady = false; authReady = false;
currentProfile = null; currentProfile = null;
if (authFixDebounceTimerId) {
clearTimeout(authFixDebounceTimerId);
authFixDebounceTimerId = null;
}
authFixInFlight = null;
return; return;
} }
// unsub(); // unsub();
@ -218,6 +260,21 @@ export default async function trackLocation() {
} }
} }
// 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. // Ensure we are NOT forcing "moving" mode by default.
// Default profile is idle unless an active alert requires higher accuracy. // Default profile is idle unless an active alert requires higher accuracy.
const shouldBeActive = computeHasOwnOpenAlert(); const shouldBeActive = computeHasOwnOpenAlert();
@ -244,14 +301,20 @@ export default async function trackLocation() {
} }
} }
BackgroundGeolocation.onLocation(async (location) => { setBackgroundGeolocationEventHandlers({
onLocation: async (location) => {
locationLogger.debug("Location update received", { locationLogger.debug("Location update received", {
coords: location.coords, coords: location.coords,
timestamp: location.timestamp, timestamp: location.timestamp,
activity: location.activity, activity: location.activity,
battery: location.battery, 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 ( if (
location.coords && location.coords &&
location.coords.latitude && location.coords.latitude &&
@ -261,20 +324,55 @@ export default async function trackLocation() {
// Also store in AsyncStorage for last known location fallback // Also store in AsyncStorage for last known location fallback
storeLocation(location.coords, location.timestamp); storeLocation(location.coords, location.timestamp);
} }
},
onLocationError: (error) => {
locationLogger.warn("Location error", {
error: error?.message,
code: error?.code,
}); });
},
BackgroundGeolocation.onHttp(async (response) => { onHttp: async (response) => {
// log status code and response // Log success/failure for visibility into token expiry, server errors, etc.
locationLogger.debug("HTTP response received", { locationLogger.debug("HTTP response received", {
success: response?.success,
status: response?.status, status: response?.status,
responseText: response?.responseText, 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 { try {
locationLogger.info("Initializing background geolocation"); locationLogger.info("Initializing background geolocation");
await BackgroundGeolocation.ready(baseConfig); await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
await BackgroundGeolocation.setConfig(baseConfig);
// Only set the permission state if we already have the permission // Only set the permission state if we already have the permission
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
@ -320,4 +418,7 @@ export default async function trackLocation() {
if (__DEV__ || env.IS_STAGING) { if (__DEV__ || env.IS_STAGING) {
initEmulatorMode(); initEmulatorMode();
} }
})();
return trackLocationStartPromise;
} }

View file

@ -22,6 +22,9 @@ import {
import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger"; import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger";
import { config as loggerConfig } from "~/lib/logger/config"; import { config as loggerConfig } from "~/lib/logger/config";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
const reset = async () => { const reset = async () => {
await authActions.logout(); await authActions.logout();
}; };
@ -75,6 +78,8 @@ export default function Developer() {
setSyncStatus("syncing"); setSyncStatus("syncing");
setSyncResult(""); setSyncResult("");
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Get the count of pending records first // Get the count of pending records first
const count = await BackgroundGeolocation.getCount(); const count = await BackgroundGeolocation.getCount();