fix(track-location): try 9

This commit is contained in:
devthejo 2026-01-27 19:36:36 +01:00
parent 33eb0cfa13
commit a18baf9ae6
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
4 changed files with 284 additions and 202 deletions

View file

@ -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`:

View file

@ -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.

View file

@ -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;
}

View file

@ -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,8 +855,8 @@ 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).
// 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", {
@ -870,6 +870,7 @@ export default function trackLocation() {
error: e?.message,
});
}
}
// Compute identity from session store; this is our source of truth.
// (A token refresh for the same user should not force a new persisted fix.)
@ -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,9 +952,9 @@ export default function trackLocation() {
lastSessionUserId = null;
return;
}
// unsub();
locationLogger.debug("Updating background geolocation config");
await BackgroundGeolocation.setConfig({
{
const payload = await buildBackgroundGeolocationSetConfigPayload({
http: {
// Update the sync URL for when it's changed for staging
url: env.GEOLOC_SYNC_URL,
@ -966,6 +968,8 @@ export default function trackLocation() {
},
},
});
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,94 +1144,43 @@ 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", {
isMoving: event?.isMoving,
location: event?.location?.coords,
});
// 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,
instanceId: TRACK_LOCATION_INSTANCE_ID,
profile: currentProfile,
appState,
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
isMoving: event?.isMoving,
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,