diff --git a/index.js b/index.js index 00aca88..9844642 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,6 @@ import "./warnFilter"; import "expo-splash-screen"; -import BackgroundGeolocation from "react-native-background-geolocation"; - -import { Platform } from "react-native"; import notifee from "@notifee/react-native"; import messaging from "@react-native-firebase/messaging"; @@ -18,9 +15,6 @@ import App from "~/app"; import { onBackgroundEvent as notificationBackgroundEvent } from "~/notifications/onEvent"; import onMessageReceived from "~/notifications/onMessageReceived"; -import { createLogger } from "~/lib/logger"; -// import { executeHeartbeatSync } from "~/location/backgroundTask"; - // setup notification, this have to stay in index.js notifee.onBackgroundEvent(notificationBackgroundEvent); 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, // the environment is set up appropriately 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); -// } diff --git a/src/location/backgroundGeolocationConfig.js b/src/location/backgroundGeolocationConfig.js new file mode 100644 index 0000000..bb2ecc1 --- /dev/null +++ b/src/location/backgroundGeolocationConfig.js @@ -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, + }, +}; diff --git a/src/location/backgroundGeolocationService.js b/src/location/backgroundGeolocationService.js new file mode 100644 index 0000000..1acd737 --- /dev/null +++ b/src/location/backgroundGeolocationService.js @@ -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(); +} diff --git a/src/location/emulatorService.js b/src/location/emulatorService.js index 4789231..490a8f2 100644 --- a/src/location/emulatorService.js +++ b/src/location/emulatorService.js @@ -4,6 +4,9 @@ import { STORAGE_KEYS } from "~/storage/storageKeys"; import { createLogger } from "~/lib/logger"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; +import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; +import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; + // Global variables let emulatorIntervalId = null; let isEmulatorModeEnabled = false; @@ -43,6 +46,8 @@ export const enableEmulatorMode = async () => { } try { + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + // Call immediately once await BackgroundGeolocation.changePace(true); emulatorLogger.debug("Initial changePace call successful"); diff --git a/src/location/index.js b/src/location/index.js index 07c0eeb..43796dd 100644 --- a/src/location/index.js +++ b/src/location/index.js @@ -9,6 +9,9 @@ import setLocationState from "./setLocationState"; import camelCaseKeys from "~/utils/string/camelCaseKeys"; +import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; +import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; + const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1 second @@ -17,6 +20,10 @@ export async function getCurrentLocation() { while (retries < MAX_RETRIES) { 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 const state = await BackgroundGeolocation.getState(); diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 00aa3c1..667ba5c 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -1,5 +1,4 @@ import BackgroundGeolocation from "react-native-background-geolocation"; -import { TRACK_MOVE } from "~/misc/devicePrefs"; import { createLogger } from "~/lib/logger"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import jwtDecode from "jwt-decode"; @@ -20,304 +19,406 @@ import { storeLocation } from "~/location/storage"; 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 only enabled when an active alert is open. -const baseConfig = { - // https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode - enableHeadless: true, - disableProviderChangeRecord: true, - // disableMotionActivityUpdates: true, - // Default to low-power (idle) profile; will be overridden when needed. - 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, -}; +import { + BASE_GEOLOCATION_CONFIG, + TRACKING_PROFILES, +} from "~/location/backgroundGeolocationConfig"; +import { + ensureBackgroundGeolocationReady, + setBackgroundGeolocationEventHandlers, +} from "~/location/backgroundGeolocationService"; -const TRACKING_PROFILES = { - idle: { - desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW, - distanceFilter: 200, - heartbeatInterval: 3600, - }, - active: { - desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH, - distanceFilter: TRACK_MOVE, - heartbeatInterval: 900, - }, -}; +let trackLocationStartPromise = null; -export default async function trackLocation() { - const locationLogger = createLogger({ - module: BACKGROUND_SCOPES.GEOLOCATION, - feature: "tracking", - }); +export default function trackLocation() { + if (trackLocationStartPromise) return trackLocationStartPromise; - let currentProfile = null; - let authReady = false; - let stopAlertSubscription = null; - let stopSessionSubscription = null; - - 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, + trackLocationStartPromise = (async () => { + const locationLogger = createLogger({ + module: BACKGROUND_SCOPES.GEOLOCATION, + feature: "tracking", }); - try { - await BackgroundGeolocation.setConfig(profile); + let currentProfile = null; + let authReady = false; + let stopAlertSubscription = null; + let stopSessionSubscription = null; - // Key battery fix: - // - IDLE profile forces stationary mode - // - ACTIVE profile forces moving mode - await BackgroundGeolocation.changePace(profileName === "active"); + // 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; - currentProfile = profileName; - } catch (error) { - locationLogger.error("Failed to apply tracking profile", { - profileName, - error: error?.message, - stack: error?.stack, - }); - } - }; + // 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; - // 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) { - locationLogger.info("Handling auth token update", { - hasToken: !!userToken, - }); - if (!userToken) { - locationLogger.info("No auth token, stopping location tracking"); - await BackgroundGeolocation.stop(); - locationLogger.debug("Location tracking stopped"); - - // Cleanup subscriptions when logged out. - try { - stopAlertSubscription && stopAlertSubscription(); - stopSessionSubscription && stopSessionSubscription(); - } finally { - stopAlertSubscription = null; - stopSessionSubscription = null; + const scheduleAuthFreshFix = () => { + if (authFixDebounceTimerId) { + clearTimeout(authFixDebounceTimerId); + authFixDebounceTimerId = null; } - authReady = false; - currentProfile = 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}`, - }, - }); + 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, + }); - authReady = true; + const location = await BackgroundGeolocation.getCurrentPosition({ + samples: 3, + persist: true, + timeout: 30, + maximumAge: 5000, + desiredAccuracy: 50, + extras: { + auth_token_update: 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, + 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; + } }); - } - if (!state.enabled) { - locationLogger.info("Starting location tracking"); + return authFixInFlight; + }; + + const computeHasOwnOpenAlert = () => { try { - await BackgroundGeolocation.start(); - locationLogger.debug("Location tracking started successfully"); + 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 start location tracking", { - error: error.message, - stack: error.stack, + locationLogger.error("Failed to apply tracking profile", { + profileName, + error: error?.message, + stack: error?.stack, }); } - } + }; - // 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"); + // Log the geolocation sync URL for debugging + locationLogger.info("Geolocation sync URL configuration", { + url: env.GEOLOC_SYNC_URL, + isStaging: env.IS_STAGING, + }); - // Subscribe to changes that may require switching profiles. - if (!stopSessionSubscription) { - stopSessionSubscription = subscribeSessionState( - (s) => s?.userId, - () => { - const active = computeHasOwnOpenAlert(); - applyProfile(active ? "active" : "idle"); + // 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, }, ); - } - if (!stopAlertSubscription) { - stopAlertSubscription = subscribeAlertState( - (s) => s?.alertingList, - () => { - const active = computeHasOwnOpenAlert(); - applyProfile(active ? "active" : "idle"); - }, - ); - } - } - BackgroundGeolocation.onLocation(async (location) => { - locationLogger.debug("Location update received", { - coords: location.coords, - timestamp: location.timestamp, - activity: location.activity, - battery: location.battery, - }); + 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 ( - 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); - } - }); + 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, + }); + } + } - BackgroundGeolocation.onHttp(async (response) => { - // log status code and response - locationLogger.debug("HTTP response received", { - status: response?.status, - responseText: response?.responseText, - }); - }); + // 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(); - try { - locationLogger.info("Initializing background geolocation"); - await BackgroundGeolocation.ready(baseConfig); - await BackgroundGeolocation.setConfig(baseConfig); + // 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; + } - // 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, - }); + // 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"); - if (state.enabled) { - locationLogger.info("Background location permission confirmed"); - permissionsActions.setLocationBackground(true); - } else { - locationLogger.warn( - "Background location not enabled in geolocation state", - ); + // 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"); + }, + ); + } } - // 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); + setBackgroundGeolocationEventHandlers({ + onLocation: async (location) => { + locationLogger.debug("Location update received", { + coords: location.coords, + timestamp: location.timestamp, + activity: location.activity, + battery: location.battery, + sample: location.sample, + }); - // Initialize emulator mode only in dev/staging to avoid accidental production battery drain. - if (__DEV__ || env.IS_STAGING) { - initEmulatorMode(); - } + // 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; } diff --git a/src/scenes/Developer/index.js b/src/scenes/Developer/index.js index 992d57f..df7a098 100644 --- a/src/scenes/Developer/index.js +++ b/src/scenes/Developer/index.js @@ -22,6 +22,9 @@ import { import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger"; import { config as loggerConfig } from "~/lib/logger/config"; +import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; +import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; + const reset = async () => { await authActions.logout(); }; @@ -75,6 +78,8 @@ export default function Developer() { setSyncStatus("syncing"); setSyncResult(""); + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + // Get the count of pending records first const count = await BackgroundGeolocation.getCount();