diff --git a/index.js b/index.js index 9844642..74316f6 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,8 @@ import "./warnFilter"; import "expo-splash-screen"; +import BackgroundGeolocation from "react-native-background-geolocation"; + import notifee from "@notifee/react-native"; import messaging from "@react-native-firebase/messaging"; @@ -19,6 +21,17 @@ import onMessageReceived from "~/notifications/onMessageReceived"; notifee.onBackgroundEvent(notificationBackgroundEvent); messaging().setBackgroundMessageHandler(onMessageReceived); +// Android Headless Mode for react-native-background-geolocation. +// Required because [`enableHeadless`](src/location/backgroundGeolocationConfig.js:16) is enabled and +// we run with [`stopOnTerminate: false`](src/location/backgroundGeolocationConfig.js:40). +// +// IMPORTANT: keep this handler lightweight. In headless state, the JS runtime may be launched +// briefly and then torn down; long tasks can be terminated by the OS. +BackgroundGeolocation.registerHeadlessTask(async (event) => { + // eslint-disable-next-line no-console + console.log("[BGGeo HeadlessTask]", event?.name, event?.params); +}); + // registerRootComponent calls AppRegistry.registerComponent('main', () => App); // It also ensures that whether you load the app in Expo Go or in a native build, // the environment is set up appropriately diff --git a/src/location/backgroundGeolocationConfig.js b/src/location/backgroundGeolocationConfig.js index bb2ecc1..c601fed 100644 --- a/src/location/backgroundGeolocationConfig.js +++ b/src/location/backgroundGeolocationConfig.js @@ -1,10 +1,13 @@ 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. // +// Product goals: +// - IDLE (no open alert): minimize battery; server updates are acceptable only on OS-level significant movement. +// - ACTIVE (open alert): first location should reach server within seconds, then continuous distance-based updates. +// // 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. @@ -18,10 +21,21 @@ export const BASE_GEOLOCATION_CONFIG = { // Default to the IDLE profile behaviour: we still want distance-based updates // even with no open alert (see TRACKING_PROFILES.idle). - distanceFilter: 50, + distanceFilter: 200, + + // Activity-recognition stop-detection. + // NOTE: Transistorsoft defaults `stopTimeout` to 5 minutes (see + // [`node_modules/react-native-background-geolocation/src/declarations/interfaces/Config.d.ts:79`](node_modules/react-native-background-geolocation/src/declarations/interfaces/Config.d.ts:79)). + // We keep the default in BASE and override it in the IDLE profile to reduce + // 5-minute stationary cycles observed on Android. + stopTimeout: 5, // debug: true, - logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE, + // Logging can become large and also adds overhead; keep verbose logs to dev/staging. + logLevel: + __DEV__ || env.IS_STAGING + ? BackgroundGeolocation.LOG_LEVEL_VERBOSE + : BackgroundGeolocation.LOG_LEVEL_ERROR, // Permission request strategy locationAuthorizationRequest: "Always", @@ -31,7 +45,9 @@ export const BASE_GEOLOCATION_CONFIG = { startOnBoot: true, // Background scheduling - heartbeatInterval: 3600, + // Disable heartbeats by default to avoid periodic background wakeups while stationary. + // ACTIVE profile will explicitly enable a fast heartbeat when needed. + heartbeatInterval: 0, // Android foreground service foregroundService: true, @@ -71,15 +87,52 @@ export const BASE_GEOLOCATION_CONFIG = { disableProviderChangeRecord: true, }; +// Options we want to be stable across launches even when the plugin loads a persisted config. +// NOTE: We intentionally do *not* include HTTP auth headers here. +export const BASE_GEOLOCATION_INVARIANTS = { + enableHeadless: true, + stopOnTerminate: false, + startOnBoot: true, + foregroundService: true, + disableProviderChangeRecord: true, + // Filter extreme GPS teleports that can create false uploads while stationary. + // Units: meters/second. 100 m/s ~= 360 km/h. + speedJumpFilter: 100, + method: "POST", + httpRootProperty: "location", + maxRecordsToPersist: 1000, + maxDaysToPersist: 7, +}; + export const TRACKING_PROFILES = { idle: { desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW, - distanceFilter: 50, - heartbeatInterval: 3600, + // Max battery-saving strategy for IDLE: + // Use Android/iOS low-power significant-change tracking where the OS produces + // only periodic fixes (several times/hour). Note many config options like + // `distanceFilter` / `stationaryRadius` are documented as having little/no + // effect in this mode. + useSignificantChangesOnly: true, + + // Defensive: if some devices/platform conditions fall back to standard tracking, + // keep the distanceFilter conservative to avoid battery drain. + distanceFilter: 200, + + // Keep the default stop-detection timing (minutes). In significant-changes + // mode, stop-detection is not the primary driver of updates. + stopTimeout: 5, + + // No periodic wakeups while idle. + heartbeatInterval: 0, }, active: { desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH, - distanceFilter: TRACK_MOVE, - heartbeatInterval: 900, + // Ensure we exit significant-changes mode when switching from IDLE. + useSignificantChangesOnly: false, + distanceFilter: 50, + heartbeatInterval: 60, + + // Keep default responsiveness during an active alert. + stopTimeout: 5, }, }; diff --git a/src/location/backgroundGeolocationService.js b/src/location/backgroundGeolocationService.js index 1acd737..ee764f9 100644 --- a/src/location/backgroundGeolocationService.js +++ b/src/location/backgroundGeolocationService.js @@ -54,6 +54,8 @@ export function setBackgroundGeolocationEventHandlers({ onLocation, onLocationError, onHttp, + onHeartbeat, + onSchedule, onMotionChange, onActivityChange, onProviderChange, @@ -65,6 +67,8 @@ export function setBackgroundGeolocationEventHandlers({ const sig = [ onLocation ? "L1" : "L0", onHttp ? "H1" : "H0", + onHeartbeat ? "HB1" : "HB0", + onSchedule ? "S1" : "S0", onMotionChange ? "M1" : "M0", onActivityChange ? "A1" : "A0", onProviderChange ? "P1" : "P0", @@ -86,6 +90,22 @@ export function setBackgroundGeolocationEventHandlers({ if (onHttp) { subscriptions.push(BackgroundGeolocation.onHttp(onHttp)); } + + if (onHeartbeat) { + if (typeof BackgroundGeolocation.onHeartbeat === "function") { + subscriptions.push(BackgroundGeolocation.onHeartbeat(onHeartbeat)); + } else { + bgGeoLogger.warn("BackgroundGeolocation.onHeartbeat is not available"); + } + } + + if (onSchedule) { + if (typeof BackgroundGeolocation.onSchedule === "function") { + subscriptions.push(BackgroundGeolocation.onSchedule(onSchedule)); + } else { + bgGeoLogger.warn("BackgroundGeolocation.onSchedule is not available"); + } + } if (onMotionChange) { subscriptions.push(BackgroundGeolocation.onMotionChange(onMotionChange)); } diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 667ba5c..55c540b 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -21,6 +21,7 @@ import env from "~/env"; import { BASE_GEOLOCATION_CONFIG, + BASE_GEOLOCATION_INVARIANTS, TRACKING_PROFILES, } from "~/location/backgroundGeolocationConfig"; import { @@ -129,6 +130,8 @@ export default function trackLocation() { } if (currentProfile === profileName) return; + const applyStartedAt = Date.now(); + const profile = TRACKING_PROFILES[profileName]; if (!profile) { locationLogger.warn("Unknown tracking profile", { profileName }); @@ -147,14 +150,71 @@ export default function trackLocation() { // 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). + // - IDLE: ensure we are not stuck in moving mode from a prior ACTIVE session. + // We explicitly exit moving mode to avoid periodic drift-generated locations + // being produced + uploaded while the user is stationary (reported on Android). + // After that, let the SDK's motion detection manage moving/stationary + // transitions so we still get distance-based updates when the user truly moves. if (profileName === "active") { - await BackgroundGeolocation.changePace(true); + const state = await BackgroundGeolocation.getState(); + if (!state?.isMoving) { + await BackgroundGeolocation.changePace(true); + } + + // Guarantee a rapid first fix for ACTIVE: request a high-accuracy persisted location + // immediately after entering moving mode. This is preferred over relying solely on + // motion-detection / distanceFilter to produce the first point. + try { + const beforeFix = Date.now(); + const fix = await BackgroundGeolocation.getCurrentPosition({ + samples: 3, + persist: true, + timeout: 30, + maximumAge: 0, + desiredAccuracy: 10, + extras: { + active_profile_enter: true, + }, + }); + locationLogger.info("ACTIVE immediate fix acquired", { + ms: Date.now() - beforeFix, + accuracy: fix?.coords?.accuracy, + latitude: fix?.coords?.latitude, + longitude: fix?.coords?.longitude, + timestamp: fix?.timestamp, + }); + } catch (error) { + locationLogger.warn("ACTIVE immediate fix failed", { + error: error?.message, + code: error?.code, + stack: error?.stack, + }); + } + } else { + const state = await BackgroundGeolocation.getState(); + if (state?.isMoving) { + await BackgroundGeolocation.changePace(false); + } } currentProfile = profileName; + + try { + const state = await BackgroundGeolocation.getState(); + locationLogger.info("Tracking profile applied", { + profileName, + ms: Date.now() - applyStartedAt, + enabled: state?.enabled, + isMoving: state?.isMoving, + trackingMode: state?.trackingMode, + }); + } catch (e) { + locationLogger.debug("Tracking profile applied (state unavailable)", { + profileName, + ms: Date.now() - applyStartedAt, + error: e?.message, + }); + } } catch (error) { locationLogger.error("Failed to apply tracking profile", { profileName, @@ -338,6 +398,40 @@ export default function trackLocation() { status: response?.status, responseText: response?.responseText, }); + + // Instrumentation: when we see periodic HTTP without a corresponding location event, + // we want to know if BGGeo is retrying an upload queue or flushing new records. + // This helps diagnose reports like "server receives updates every ~5 minutes while stationary". + try { + const [state, count] = await Promise.all([ + BackgroundGeolocation.getState(), + BackgroundGeolocation.getCount(), + ]); + locationLogger.debug("HTTP instrumentation", { + enabled: state?.enabled, + isMoving: state?.isMoving, + trackingMode: state?.trackingMode, + schedulerEnabled: state?.schedulerEnabled, + pendingCount: count, + }); + } catch (e) { + locationLogger.warn("Failed HTTP instrumentation", { + error: e?.message, + }); + } + }, + onHeartbeat: (event) => { + // If heartbeat is configured, it can trigger sync attempts even without new locations. + locationLogger.info("Heartbeat", { + enabled: event?.state?.enabled, + isMoving: event?.state?.isMoving, + location: event?.location?.coords, + }); + }, + onSchedule: (event) => { + locationLogger.info("Schedule", { + state: event?.state, + }); }, onMotionChange: (event) => { locationLogger.info("Motion change", { @@ -374,6 +468,17 @@ export default function trackLocation() { locationLogger.info("Initializing background geolocation"); await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + // Ensure critical config cannot drift due to persisted plugin state. + // (We intentionally keep auth headers separate and set them in handleAuth.) + try { + await BackgroundGeolocation.setConfig(BASE_GEOLOCATION_INVARIANTS); + } catch (e) { + locationLogger.warn("Failed to apply BGGeo base invariants", { + error: e?.message, + stack: e?.stack, + }); + } + // Only set the permission state if we already have the permission const state = await BackgroundGeolocation.getState(); locationLogger.debug("Background geolocation state", { diff --git a/src/misc/devicePrefs.js b/src/misc/devicePrefs.js index 7d90aaf..17e291a 100644 --- a/src/misc/devicePrefs.js +++ b/src/misc/devicePrefs.js @@ -1,5 +1,4 @@ // related to services/tasks/src/geocode/config.js -export const TRACK_MOVE = 10; export const DEFAULT_DEVICE_RADIUS_ALL = 500; export const DEFAULT_DEVICE_RADIUS_REACH = 25000; export const MAX_BASEUSER_DEVICE_TRACKING = 25000;