From a18baf9ae6bf955f4be609d014c9f5cab8dc6268 Mon Sep 17 00:00:00 2001 From: devthejo Date: Tue, 27 Jan 2026 19:36:36 +0100 Subject: [PATCH] fix(track-location): try 9 --- docs/location-tracking-qa.md | 38 ++- src/location/backgroundGeolocationConfig.js | 27 +- ...ldBackgroundGeolocationSetConfigPayload.js | 132 ++++++++ src/location/trackLocation.js | 289 +++++++----------- 4 files changed, 284 insertions(+), 202 deletions(-) create mode 100644 src/location/buildBackgroundGeolocationSetConfigPayload.js diff --git a/docs/location-tracking-qa.md b/docs/location-tracking-qa.md index b4e5dce..0c46fae 100644 --- a/docs/location-tracking-qa.md +++ b/docs/location-tracking-qa.md @@ -16,7 +16,8 @@ Applies to the BackgroundGeolocation integration: - Movement-driven recording only: - IDLE relies on the SDK's **stop-detection + stationary geofence** (`geolocation.stopOnStationary` + `geolocation.stationaryRadius`) to avoid periodic stationary updates on Android. - - When transitioning to moving (`onMotionChange(isMoving:true)`), JS requests **one** persisted fix (native `autoSync` uploads it). + - We explicitly exit moving mode on entry to IDLE (`changePace(false)`) to prevent drift-generated periodic locations. + - If the SDK later reports a real move (`onMotionChange(isMoving:true)`), JS may request **one** persisted fix as a fallback. - We intentionally do not rely on time-based updates. - ACTIVE uses `geolocation.distanceFilter: 25`. - JS may request a persisted fix when entering ACTIVE (see [`applyProfile()`](src/location/trackLocation.js:351)). @@ -33,6 +34,37 @@ Applies to the BackgroundGeolocation integration: - Elasticity disabled (`disableElasticity: true`) to avoid dynamic distanceFilter shrink. - Extra safety: any JS-triggered persisted fix requests are tagged and ignored if accuracy > 100m. +## Concise testing checklist (Android + iOS) + +### 1) Baseline setup + +- App has foreground + background location permissions. +- Motion/Activity permission granted (iOS motion, Android activity-recognition if prompted). +- Logged-in (to validate native HTTP uploads). + +### 2) IDLE (no open alert) + +1. Launch app and confirm there is **no open alert** owned by the current user. +2. Leave phone stationary for 10+ minutes (screen on and screen off). + - Expect: no periodic server uploads. +3. Walk/drive ~250m. + - Expect: a movement-triggered persisted location + upload. + +### 3) ACTIVE (open alert) + +1. Open an alert owned by the current user. +2. Move ~30m. + - Expect: at least one persisted location reaches server quickly. +3. Continue moving. + - Expect: updates align with movement (distanceFilter-based), not time. + +### 4) Lifecycle coverage + +- Foreground → background: repeat IDLE and ACTIVE steps. +- Terminated: + - Android: swipe-away from recents, then move the above distances and verify server updates. + - iOS: swipe-kill, then move significantly and verify app relaunch + upload after relaunch. + ## Basic preconditions - Location permissions: foreground + background granted. @@ -107,9 +139,9 @@ Applies to the BackgroundGeolocation integration: ## What to look for in logs - App lifecycle tagging: [`updateTrackingContextExtras()`](src/location/trackLocation.js:63) should update `tracking_ctx.app_state` on AppState changes. -- No time-based uploads: heartbeat is disabled (`heartbeatInterval: 0`), so no `Heartbeat` logs from [`onHeartbeat`](src/location/trackLocation.js:762). +- No time-based uploads: heartbeat is disabled (`heartbeatInterval: 0`). - Movement-only uploads: - - IDLE: look for `Motion change` (isMoving=true) and `IDLE movement fallback fix`. + - IDLE: look for `Motion change` (isMoving=true) and (in rare cases) `IDLE movement fallback fix`. - ACTIVE distance threshold: `distanceFilter: 25` in [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:148). - Attribution for `getCurrentPosition`: diff --git a/src/location/backgroundGeolocationConfig.js b/src/location/backgroundGeolocationConfig.js index c127aeb..38fcd28 100644 --- a/src/location/backgroundGeolocationConfig.js +++ b/src/location/backgroundGeolocationConfig.js @@ -59,8 +59,7 @@ export const BASE_GEOLOCATION_CONFIG = { // protect battery. desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High, - // Default to the IDLE profile behaviour: we still want distance-based updates - // even with no open alert (see TRACKING_PROFILES.idle). + // Default to the IDLE profile behaviour. distanceFilter: 200, // Prevent dynamic distanceFilter shrink. @@ -69,7 +68,6 @@ export const BASE_GEOLOCATION_CONFIG = { disableElasticity: true, // Stop-detection. - // NOTE: historically we set this at top-level. In v5 the knob is under `geolocation`. stopTimeout: 5, // True-stationary strategy: once stop-detection decides we're stationary, stop active @@ -141,7 +139,6 @@ export const BASE_GEOLOCATION_CONFIG = { persistence: { // Product requirement: keep only the latest geopoint. maxRecordsToPersist: 1, - maxDaysToPersist: 1, // Behavior tweaks disableProviderChangeRecord: true, @@ -167,7 +164,6 @@ export const BASE_GEOLOCATION_INVARIANTS = { }, persistence: { maxRecordsToPersist: 1, - maxDaysToPersist: 1, disableProviderChangeRecord: true, }, // NOTE: `speedJumpFilter` was a legacy Config knob; it is not part of v5 shared types. @@ -178,26 +174,14 @@ export const BASE_GEOLOCATION_INVARIANTS = { export const TRACKING_PROFILES = { idle: { geolocation: { - // Same rationale as BASE: prefer GPS-capable accuracy to avoid km-level coarse fixes - // that can trigger false motion/geofence transitions on Android. - desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High, - // IDLE runtime relies on the SDK's stop-detection + stationary geofence (stopOnStationary). - // Keep geolocation config conservative for any incidental lookups. - distanceFilter: 200, - disableElasticity: true, - // IMPORTANT: ACTIVE sets stopOnStationary:false. // Ensure we restore it when transitioning back to IDLE, otherwise the SDK may // continue recording while stationary. stopOnStationary: true, // QA helper: allow easier validation in dev/staging while keeping production at 200m. - stationaryRadius: __DEV__ || env.IS_STAGING ? 30 : 200, - - // Keep filtering enabled across profile transitions. - filter: DEFAULT_LOCATION_FILTER, - allowIdenticalLocations: false, + stationaryRadius: 200, }, app: { // Never use heartbeat-driven updates; only movement-driven. @@ -211,19 +195,12 @@ export const TRACKING_PROFILES = { }, active: { geolocation: { - desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High, // ACTIVE target: frequent updates while moving. distanceFilter: 25, - disableElasticity: true, - // While ACTIVE, do not stop updates simply because the device appears stationary. // Motion-detection + distanceFilter should govern updates. stopOnStationary: false, - - // Apply the same native filter while ACTIVE. - filter: DEFAULT_LOCATION_FILTER, - allowIdenticalLocations: false, }, app: { // Never use heartbeat-driven updates; only movement-driven. diff --git a/src/location/buildBackgroundGeolocationSetConfigPayload.js b/src/location/buildBackgroundGeolocationSetConfigPayload.js new file mode 100644 index 0000000..4fa3322 --- /dev/null +++ b/src/location/buildBackgroundGeolocationSetConfigPayload.js @@ -0,0 +1,132 @@ +import BackgroundGeolocation from "react-native-background-geolocation"; + +import { + BASE_GEOLOCATION_CONFIG, + BASE_GEOLOCATION_INVARIANTS, +} from "~/location/backgroundGeolocationConfig"; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + +const isPlainObject = (value) => + !!value && typeof value === "object" && !Array.isArray(value); + +const mergeSection = (base, invariants, override) => ({ + ...(isPlainObject(base) ? base : {}), + ...(isPlainObject(invariants) ? invariants : {}), + ...(isPlainObject(override) ? override : {}), +}); + +/** + * Build a deterministic `BackgroundGeolocation.setConfig()` payload. + * + * Goal: never rely on native deep-merge behavior for nested objects. + * + * Rules: + * - When a top-level section is touched (`geolocation|app|http|persistence`), send a complete + * section built from the base config + invariants + overrides. + * - Preserve existing runtime `http.headers` unless explicitly overridden. + * - `headers: {}` is treated as an explicit clear. + * - Preserve existing runtime `persistence.extras` unless explicitly overridden. + * - `extras: {}` is treated as an explicit clear. + */ +export default async function buildBackgroundGeolocationSetConfigPayload( + partialConfig = {}, +) { + const partial = isPlainObject(partialConfig) ? partialConfig : {}; + + let state = null; + try { + state = await BackgroundGeolocation.getState(); + } catch { + state = null; + } + + const prevHttpHeaders = isPlainObject(state?.http?.headers) + ? state.http.headers + : {}; + + const prevPersistenceExtras = isPlainObject(state?.persistence?.extras) + ? state.persistence.extras + : {}; + + const payload = {}; + + if (isPlainObject(partial.geolocation)) { + payload.geolocation = mergeSection( + BASE_GEOLOCATION_CONFIG.geolocation, + null, + partial.geolocation, + ); + } + + if (isPlainObject(partial.app)) { + payload.app = mergeSection( + BASE_GEOLOCATION_CONFIG.app, + BASE_GEOLOCATION_INVARIANTS.app, + partial.app, + ); + } + + if (isPlainObject(partial.http)) { + const http = mergeSection( + BASE_GEOLOCATION_CONFIG.http, + BASE_GEOLOCATION_INVARIANTS.http, + partial.http, + ); + + if (hasOwn(partial.http, "headers")) { + const nextHeaders = isPlainObject(partial.http.headers) + ? partial.http.headers + : {}; + + // Explicit reset: allow clearing headers (eg anonymous mode). + if (Object.keys(nextHeaders).length === 0) { + http.headers = {}; + } else { + http.headers = { ...prevHttpHeaders, ...nextHeaders }; + } + } else if (Object.keys(prevHttpHeaders).length > 0) { + // Preserve existing runtime headers (important when re-applying invariants). + http.headers = prevHttpHeaders; + } + + payload.http = http; + } + + if (isPlainObject(partial.persistence)) { + const persistence = mergeSection( + BASE_GEOLOCATION_CONFIG.persistence, + BASE_GEOLOCATION_INVARIANTS.persistence, + partial.persistence, + ); + + if (hasOwn(partial.persistence, "extras")) { + const nextExtras = isPlainObject(partial.persistence.extras) + ? partial.persistence.extras + : {}; + + // Explicit reset: allow clearing extras. + if (Object.keys(nextExtras).length === 0) { + persistence.extras = {}; + } else { + persistence.extras = { ...prevPersistenceExtras, ...nextExtras }; + } + } else if (Object.keys(prevPersistenceExtras).length > 0) { + // Preserve existing runtime extras (important when re-applying invariants). + persistence.extras = prevPersistenceExtras; + } + + payload.persistence = persistence; + } + + // Pass-through any additional config sections (eg `activity` in tracking profiles). + for (const key of Object.keys(partial)) { + if (key === "geolocation") continue; + if (key === "app") continue; + if (key === "http") continue; + if (key === "persistence") continue; + payload[key] = partial[key]; + } + + return payload; +} diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index f9c3ff5..d936ef9 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -2,7 +2,6 @@ import BackgroundGeolocation from "react-native-background-geolocation"; import { AppState } from "react-native"; import { createLogger } from "~/lib/logger"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; -import jwtDecode from "jwt-decode"; import { initEmulatorMode } from "./emulatorService"; import { @@ -25,6 +24,7 @@ import { BASE_GEOLOCATION_INVARIANTS, TRACKING_PROFILES, } from "~/location/backgroundGeolocationConfig"; +import buildBackgroundGeolocationSetConfigPayload from "~/location/buildBackgroundGeolocationSetConfigPayload"; import { ensureBackgroundGeolocationReady, setBackgroundGeolocationEventHandlers, @@ -69,10 +69,11 @@ export default function trackLocation() { const BAD_ACCURACY_THRESHOLD_M = 200; const PERSISTED_ACCURACY_GATE_M = 100; - // NOTE: IDLE previously used `startGeofences()` + a managed geofence. - // We now rely on the SDK's stop-detection + stationary geofence - // (`stopOnStationary` + `stationaryRadius`) because it is more reliable - // in background/locked scenarios. + // NOTE: IDLE previously experimented with `startGeofences()` + an app-managed exit geofence. + // That approach is now removed. + // Current design relies on the SDK's stop-detection + stationary geofence + // (`geolocation.stopOnStationary` + `geolocation.stationaryRadius`) because it is more + // reliable in background/locked scenarios. // Fallback: if the OS fails to deliver geofence EXIT while the phone is locked, allow // exactly one persisted fix when we get strong evidence of movement (motion+activity). @@ -82,13 +83,12 @@ export default function trackLocation() { let lastIdleMovementFallbackAt = 0; // Diagnostics fields retained so server-side correlation can continue to work. - // (With Option 2, these are used as a reference center rather than a managed geofence.) - let lastEnsuredIdleGeofenceAt = 0; - let lastIdleGeofenceCenter = null; - let lastIdleGeofenceCenterAccuracyM = null; - let lastIdleGeofenceCenterTimestamp = null; - let lastIdleGeofenceCenterSource = null; - let lastIdleGeofenceRadiusM = null; + // This is *not* a managed geofence anymore; it's a reference center for observability. + let lastEnsuredIdleReferenceAt = 0; + let lastIdleReferenceCenter = null; + let lastIdleReferenceCenterAccuracyM = null; + let lastIdleReferenceCenterTimestamp = null; + let lastIdleReferenceCenterSource = null; // A) Safeguard: when entering IDLE, ensure we have a reasonably accurate and recent // reference point. This does NOT persist/upload; it only updates our stored last-known @@ -123,14 +123,14 @@ export default function trackLocation() { isRecentEnough && isAccurateEnough ) { - lastIdleGeofenceCenter = { + lastIdleReferenceCenter = { latitude: storedCoords.latitude, longitude: storedCoords.longitude, }; - lastIdleGeofenceCenterAccuracyM = storedAcc; - lastIdleGeofenceCenterTimestamp = storedTs ?? null; - lastIdleGeofenceCenterSource = "stored"; - lastEnsuredIdleGeofenceAt = Date.now(); + lastIdleReferenceCenterAccuracyM = storedAcc; + lastIdleReferenceCenterTimestamp = storedTs ?? null; + lastIdleReferenceCenterSource = "stored"; + lastEnsuredIdleReferenceAt = Date.now(); void updateTrackingContextExtras("idle_reference_ok"); return; } @@ -152,17 +152,17 @@ export default function trackLocation() { if (fix?.coords?.latitude && fix?.coords?.longitude) { storeLocation(fix.coords, fix.timestamp); - lastIdleGeofenceCenter = { + lastIdleReferenceCenter = { latitude: fix.coords.latitude, longitude: fix.coords.longitude, }; - lastIdleGeofenceCenterAccuracyM = + lastIdleReferenceCenterAccuracyM = typeof fix.coords.accuracy === "number" ? fix.coords.accuracy : null; - lastIdleGeofenceCenterTimestamp = fix.timestamp ?? null; - lastIdleGeofenceCenterSource = "idle_reference_fix"; - lastEnsuredIdleGeofenceAt = Date.now(); + lastIdleReferenceCenterTimestamp = fix.timestamp ?? null; + lastIdleReferenceCenterSource = "idle_reference_fix"; + lastEnsuredIdleReferenceAt = Date.now(); void updateTrackingContextExtras("idle_reference_fixed"); } } catch (e) { @@ -271,7 +271,7 @@ export default function trackLocation() { const updateTrackingContextExtras = async (reason) => { try { const { userId } = getSessionState(); - await BackgroundGeolocation.setConfig({ + const payload = await buildBackgroundGeolocationSetConfigPayload({ persistence: { extras: { tracking_ctx: { @@ -281,22 +281,20 @@ export default function trackLocation() { auth_ready: authReady, session_user_id: userId || null, // Diagnostics: helps correlate server-side "no update" reports with - // the IDLE geofence placement parameters. - idle_geofence: { - // Option 2: managed IDLE geofence removed. - id: null, - radius_m: lastIdleGeofenceRadiusM, - center: lastIdleGeofenceCenter, - center_accuracy_m: lastIdleGeofenceCenterAccuracyM, - center_timestamp: lastIdleGeofenceCenterTimestamp, - center_source: lastIdleGeofenceCenterSource, - ensured_at: lastEnsuredIdleGeofenceAt || null, + // the last known good reference center when entering IDLE. + idle_reference: { + center: lastIdleReferenceCenter, + center_accuracy_m: lastIdleReferenceCenterAccuracyM, + center_timestamp: lastIdleReferenceCenterTimestamp, + center_source: lastIdleReferenceCenterSource, + ensured_at: lastEnsuredIdleReferenceAt || null, }, at: new Date().toISOString(), }, }, }, }); + await BackgroundGeolocation.setConfig(payload); } catch (e) { // Non-fatal: extras are only for observability/debugging. locationLogger.debug("Failed to update BGGeo tracking extras", { @@ -617,9 +615,8 @@ export default function trackLocation() { // while native state drifts (eg `trackingMode` remains geofence-only but geofences // are missing, leading to "moving but no updates"). // - // If profile is unchanged, perform a lightweight runtime ensure: - // - IDLE: ensure we're in geofence-only mode and that the managed exit geofence exists. - // - ACTIVE: ensure we're NOT stuck in geofence-only mode. + // If profile is unchanged, perform a lightweight runtime ensure. + // We no longer use geofence-only tracking; ensure we are not stuck in it. if (currentProfile === profileName) { try { const state = await BackgroundGeolocation.getState(); @@ -629,7 +626,7 @@ export default function trackLocation() { if (state?.trackingMode === 0) { await BackgroundGeolocation.start(); } - locationLogger.info("Profile unchanged; IDLE runtime ensured", { + locationLogger.debug("Profile unchanged; IDLE runtime ensured", { instanceId: TRACK_LOCATION_INSTANCE_ID, trackingMode: state?.trackingMode, enabled: state?.enabled, @@ -643,7 +640,7 @@ export default function trackLocation() { if (state?.trackingMode === 0) { await BackgroundGeolocation.start(); } - locationLogger.info("Profile unchanged; ACTIVE runtime ensured", { + locationLogger.debug("Profile unchanged; ACTIVE runtime ensured", { instanceId: TRACK_LOCATION_INSTANCE_ID, trackingMode: state?.trackingMode, enabled: state?.enabled, @@ -663,12 +660,11 @@ export default function trackLocation() { const applyStartedAt = Date.now(); - // Diagnostic: track trackingMode transitions (especially geofence-only mode) across - // identity changes and profile switches. + // Diagnostic snapshot (debug only) to help understand trackingMode transitions. let preState = null; try { preState = await BackgroundGeolocation.getState(); - locationLogger.info("Applying tracking profile (pre-state)", { + locationLogger.debug("Applying tracking profile (pre-state)", { profileName, instanceId: TRACK_LOCATION_INSTANCE_ID, enabled: preState?.enabled, @@ -700,7 +696,10 @@ export default function trackLocation() { }); try { - await BackgroundGeolocation.setConfig(profile); + const payload = await buildBackgroundGeolocationSetConfigPayload( + profile, + ); + await BackgroundGeolocation.setConfig(payload); // Motion state strategy: // - ACTIVE: force moving to begin aggressive tracking immediately. @@ -758,6 +757,9 @@ export default function trackLocation() { longitude: fix?.coords?.longitude, timestamp: fix?.timestamp, }); + + // Prevent duplicated "moving-edge" persisted fix right after entering ACTIVE. + lastMovingEdgeAt = Date.now(); } catch (error) { locationLogger.warn("ACTIVE immediate fix failed", { error: error?.message, @@ -790,18 +792,16 @@ export default function trackLocation() { void ensureIdleReferenceFix(); } - // Post-state snapshot to detect if we're unintentionally left in geofence-only mode - // after applying ACTIVE. + // Post-state snapshot (debug) to detect unintended geofence-only mode. try { const post = await BackgroundGeolocation.getState(); - locationLogger.info("Tracking profile applied (post-state)", { + locationLogger.debug("Tracking profile applied (post-state)", { profileName, instanceId: TRACK_LOCATION_INSTANCE_ID, enabled: post?.enabled, isMoving: post?.isMoving, trackingMode: post?.trackingMode, distanceFilter: post?.geolocation?.distanceFilter, - // Comparing against preState helps debug transitions. prevTrackingMode: preState?.trackingMode ?? null, }); } catch (e) { @@ -855,20 +855,21 @@ export default function trackLocation() { instanceId: TRACK_LOCATION_INSTANCE_ID, }); - // Snapshot state early so we can detect if the SDK is currently in geofence-only mode - // when auth changes (common during IDLE profile). - try { - const s = await BackgroundGeolocation.getState(); - locationLogger.debug("Auth-change BGGeo state snapshot", { - instanceId: TRACK_LOCATION_INSTANCE_ID, - enabled: s?.enabled, - isMoving: s?.isMoving, - trackingMode: s?.trackingMode, - }); - } catch (e) { - locationLogger.debug("Auth-change BGGeo state snapshot failed", { - error: e?.message, - }); + // Snapshot state early (debug only) to diagnose "no uploads" reports after auth refresh. + if (__DEV__ || env.IS_STAGING) { + try { + const s = await BackgroundGeolocation.getState(); + locationLogger.debug("Auth-change BGGeo state snapshot", { + instanceId: TRACK_LOCATION_INSTANCE_ID, + enabled: s?.enabled, + isMoving: s?.isMoving, + trackingMode: s?.trackingMode, + }); + } catch (e) { + locationLogger.debug("Auth-change BGGeo state snapshot failed", { + error: e?.message, + }); + } } // Compute identity from session store; this is our source of truth. @@ -888,13 +889,14 @@ export default function trackLocation() { ); try { - await BackgroundGeolocation.setConfig({ + const payload = await buildBackgroundGeolocationSetConfigPayload({ http: { url: "", autoSync: false, headers: {}, }, }); + await BackgroundGeolocation.setConfig(payload); didDisableUploadsForAnonymous = true; didSyncAfterAuth = false; } catch (e) { @@ -950,22 +952,24 @@ export default function trackLocation() { lastSessionUserId = null; return; } - // unsub(); locationLogger.debug("Updating background geolocation config"); - await BackgroundGeolocation.setConfig({ - http: { - // Update the sync URL for when it's changed for staging - url: env.GEOLOC_SYNC_URL, - // IMPORTANT: enable native uploading when authenticated. - // This ensures uploads continue even if JS is suspended in background. - autoSync: true, - batchSync: false, - autoSyncThreshold: 0, - headers: { - Authorization: `Bearer ${userToken}`, + { + const payload = await buildBackgroundGeolocationSetConfigPayload({ + http: { + // Update the sync URL for when it's changed for staging + url: env.GEOLOC_SYNC_URL, + // IMPORTANT: enable native uploading when authenticated. + // This ensures uploads continue even if JS is suspended in background. + autoSync: true, + batchSync: false, + autoSyncThreshold: 0, + headers: { + Authorization: `Bearer ${userToken}`, + }, }, - }, - }); + }); + await BackgroundGeolocation.setConfig(payload); + } authReady = true; @@ -981,14 +985,6 @@ export default function trackLocation() { ); 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"); @@ -1084,12 +1080,14 @@ export default function trackLocation() { setBackgroundGeolocationEventHandlers({ onLocation: async (location) => { locationLogger.debug("Location update received", { - coords: location.coords, - timestamp: location.timestamp, - activity: location.activity, - battery: location.battery, - sample: location.sample, - extras: location.extras, + uuid: location?.uuid, + sample: location?.sample, + accuracy: location?.coords?.accuracy, + latitude: location?.coords?.latitude, + longitude: location?.coords?.longitude, + timestamp: location?.timestamp, + activity: location?.activity, + extras: location?.extras, }); // Ignore sampling locations (eg, emitted during getCurrentPosition) to avoid UI/storage churn. @@ -1117,30 +1115,21 @@ export default function trackLocation() { // If we're IDLE, update reference center for later correlation. if (currentProfile === "idle") { - lastIdleGeofenceCenter = { + lastIdleReferenceCenter = { latitude: location.coords.latitude, longitude: location.coords.longitude, }; - lastIdleGeofenceCenterAccuracyM = + lastIdleReferenceCenterAccuracyM = typeof location.coords.accuracy === "number" ? location.coords.accuracy : null; - lastIdleGeofenceCenterTimestamp = location.timestamp ?? null; - lastIdleGeofenceCenterSource = "onLocation"; - lastEnsuredIdleGeofenceAt = Date.now(); + lastIdleReferenceCenterTimestamp = location.timestamp ?? null; + lastIdleReferenceCenterSource = "onLocation"; + lastEnsuredIdleReferenceAt = Date.now(); void updateTrackingContextExtras("idle_reference_updated"); } } }, - onGeofence: (event) => { - // Minimal instrumentation to diagnose action semantics. - locationLogger.info("Geofence event", { - identifier: event?.identifier, - action: event?.action, - timestamp: event?.timestamp, - hasGeofence: !!event?.geofence, - }); - }, onLocationError: (error) => { locationLogger.warn("Location error", { error: error?.message, @@ -1155,95 +1144,44 @@ export default function trackLocation() { 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". + // Lightweight instrumentation only when useful: + // - non-success responses + // - dev/staging visibility + const shouldInstrumentHttp = + !response?.success || __DEV__ || env.IS_STAGING; + if (!shouldInstrumentHttp) return; + try { const [state, count] = await Promise.all([ BackgroundGeolocation.getState(), BackgroundGeolocation.getCount(), ]); locationLogger.debug("HTTP instrumentation", { + success: response?.success, + status: response?.status, enabled: state?.enabled, isMoving: state?.isMoving, trackingMode: state?.trackingMode, - schedulerEnabled: state?.schedulerEnabled, pendingCount: count, }); } catch (e) { - locationLogger.warn("Failed HTTP instrumentation", { + locationLogger.debug("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) => { - // Diagnostic snapshot to understand periodic motion-change loops (eg Android ~5min). - // Keep it cheap: avoid heavy calls unless motion-change fires. - // NOTE: This is safe to run in background because it does not request a new location. + // Essential motion diagnostics (avoid spam; keep it one log per edge). locationLogger.info("Motion change", { + instanceId: TRACK_LOCATION_INSTANCE_ID, + profile: currentProfile, + appState, + authReady, isMoving: event?.isMoving, - location: event?.location?.coords, + accuracy: event?.location?.coords?.accuracy, + speed: event?.location?.coords?.speed, }); - // Async snapshot of BGGeo internal state/config at the time of motion-change. - // This helps correlate native behavior with our current profile + config. - (async () => { - try { - const state = await BackgroundGeolocation.getState(); - - locationLogger.info("Motion change diagnostic", { - isMoving: event?.isMoving, - appState: appState, - profile: currentProfile, - authReady, - // Time correlation - at: new Date().toISOString(), - // Core BGGeo runtime state - enabled: state?.enabled, - trackingMode: state?.trackingMode, - isMovingState: state?.isMoving, - schedulerEnabled: state?.schedulerEnabled, - // Critical config knobs related to periodic updates - distanceFilter: state?.geolocation?.distanceFilter, - disableElasticity: state?.geolocation?.disableElasticity, - stationaryRadius: state?.geolocation?.stationaryRadius, - stopOnStationary: state?.geolocation?.stopOnStationary, - useSignificantChangesOnly: - state?.geolocation?.useSignificantChangesOnly, - allowIdenticalLocations: - state?.geolocation?.allowIdenticalLocations, - filter: state?.geolocation?.filter, - heartbeatInterval: state?.app?.heartbeatInterval, - motionTriggerDelay: state?.activity?.motionTriggerDelay, - activityStopOnStationary: state?.activity?.stopOnStationary, - disableStopDetection: state?.activity?.disableStopDetection, - disableMotionActivityUpdates: - state?.activity?.disableMotionActivityUpdates, - stopTimeout: state?.geolocation?.stopTimeout, - // Location quality signal - accuracy: event?.location?.coords?.accuracy, - speed: event?.location?.coords?.speed, - }); - } catch (e) { - locationLogger.warn("Motion change diagnostic failed", { - error: e?.message, - }); - } - })(); - // Moving-edge strategy: when we enter moving state, force one persisted high-quality // point + sync so the server gets a quick update. // @@ -1303,7 +1241,7 @@ export default function trackLocation() { } }, onActivityChange: (event) => { - locationLogger.info("Activity change", { + locationLogger.debug("Activity change", { activity: event?.activity, confidence: event?.confidence, }); @@ -1325,7 +1263,7 @@ export default function trackLocation() { }); }, onConnectivityChange: (event) => { - locationLogger.info("Connectivity change", { + locationLogger.debug("Connectivity change", { connected: event?.connected, }); }, @@ -1359,7 +1297,10 @@ export default function trackLocation() { // 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); + const payload = await buildBackgroundGeolocationSetConfigPayload( + BASE_GEOLOCATION_INVARIANTS, + ); + await BackgroundGeolocation.setConfig(payload); } catch (e) { locationLogger.warn("Failed to apply BGGeo base invariants", { error: e?.message,