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: - 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. - 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. - We intentionally do not rely on time-based updates.
- ACTIVE uses `geolocation.distanceFilter: 25`. - ACTIVE uses `geolocation.distanceFilter: 25`.
- JS may request a persisted fix when entering ACTIVE (see [`applyProfile()`](src/location/trackLocation.js:351)). - 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. - Elasticity disabled (`disableElasticity: true`) to avoid dynamic distanceFilter shrink.
- Extra safety: any JS-triggered persisted fix requests are tagged and ignored if accuracy > 100m. - 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 ## Basic preconditions
- Location permissions: foreground + background granted. - Location permissions: foreground + background granted.
@ -107,9 +139,9 @@ Applies to the BackgroundGeolocation integration:
## What to look for in logs ## What to look for in logs
- App lifecycle tagging: [`updateTrackingContextExtras()`](src/location/trackLocation.js:63) should update `tracking_ctx.app_state` on AppState changes. - 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: - 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). - ACTIVE distance threshold: `distanceFilter: 25` in [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:148).
- Attribution for `getCurrentPosition`: - Attribution for `getCurrentPosition`:

View file

@ -59,8 +59,7 @@ export const BASE_GEOLOCATION_CONFIG = {
// protect battery. // protect battery.
desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High, desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High,
// Default to the IDLE profile behaviour: we still want distance-based updates // Default to the IDLE profile behaviour.
// even with no open alert (see TRACKING_PROFILES.idle).
distanceFilter: 200, distanceFilter: 200,
// Prevent dynamic distanceFilter shrink. // Prevent dynamic distanceFilter shrink.
@ -69,7 +68,6 @@ export const BASE_GEOLOCATION_CONFIG = {
disableElasticity: true, disableElasticity: true,
// Stop-detection. // Stop-detection.
// NOTE: historically we set this at top-level. In v5 the knob is under `geolocation`.
stopTimeout: 5, stopTimeout: 5,
// True-stationary strategy: once stop-detection decides we're stationary, stop active // True-stationary strategy: once stop-detection decides we're stationary, stop active
@ -141,7 +139,6 @@ export const BASE_GEOLOCATION_CONFIG = {
persistence: { persistence: {
// Product requirement: keep only the latest geopoint. // Product requirement: keep only the latest geopoint.
maxRecordsToPersist: 1, maxRecordsToPersist: 1,
maxDaysToPersist: 1,
// Behavior tweaks // Behavior tweaks
disableProviderChangeRecord: true, disableProviderChangeRecord: true,
@ -167,7 +164,6 @@ export const BASE_GEOLOCATION_INVARIANTS = {
}, },
persistence: { persistence: {
maxRecordsToPersist: 1, maxRecordsToPersist: 1,
maxDaysToPersist: 1,
disableProviderChangeRecord: true, disableProviderChangeRecord: true,
}, },
// NOTE: `speedJumpFilter` was a legacy Config knob; it is not part of v5 shared types. // 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 = { export const TRACKING_PROFILES = {
idle: { idle: {
geolocation: { 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). // 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. // IMPORTANT: ACTIVE sets stopOnStationary:false.
// Ensure we restore it when transitioning back to IDLE, otherwise the SDK may // Ensure we restore it when transitioning back to IDLE, otherwise the SDK may
// continue recording while stationary. // continue recording while stationary.
stopOnStationary: true, stopOnStationary: true,
// QA helper: allow easier validation in dev/staging while keeping production at 200m. // QA helper: allow easier validation in dev/staging while keeping production at 200m.
stationaryRadius: __DEV__ || env.IS_STAGING ? 30 : 200, stationaryRadius: 200,
// Keep filtering enabled across profile transitions.
filter: DEFAULT_LOCATION_FILTER,
allowIdenticalLocations: false,
}, },
app: { app: {
// Never use heartbeat-driven updates; only movement-driven. // Never use heartbeat-driven updates; only movement-driven.
@ -211,19 +195,12 @@ export const TRACKING_PROFILES = {
}, },
active: { active: {
geolocation: { geolocation: {
desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High,
// ACTIVE target: frequent updates while moving. // ACTIVE target: frequent updates while moving.
distanceFilter: 25, distanceFilter: 25,
disableElasticity: true,
// While ACTIVE, do not stop updates simply because the device appears stationary. // While ACTIVE, do not stop updates simply because the device appears stationary.
// Motion-detection + distanceFilter should govern updates. // Motion-detection + distanceFilter should govern updates.
stopOnStationary: false, stopOnStationary: false,
// Apply the same native filter while ACTIVE.
filter: DEFAULT_LOCATION_FILTER,
allowIdenticalLocations: false,
}, },
app: { app: {
// Never use heartbeat-driven updates; only movement-driven. // 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 { AppState } from "react-native";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import jwtDecode from "jwt-decode";
import { initEmulatorMode } from "./emulatorService"; import { initEmulatorMode } from "./emulatorService";
import { import {
@ -25,6 +24,7 @@ import {
BASE_GEOLOCATION_INVARIANTS, BASE_GEOLOCATION_INVARIANTS,
TRACKING_PROFILES, TRACKING_PROFILES,
} from "~/location/backgroundGeolocationConfig"; } from "~/location/backgroundGeolocationConfig";
import buildBackgroundGeolocationSetConfigPayload from "~/location/buildBackgroundGeolocationSetConfigPayload";
import { import {
ensureBackgroundGeolocationReady, ensureBackgroundGeolocationReady,
setBackgroundGeolocationEventHandlers, setBackgroundGeolocationEventHandlers,
@ -69,10 +69,11 @@ export default function trackLocation() {
const BAD_ACCURACY_THRESHOLD_M = 200; const BAD_ACCURACY_THRESHOLD_M = 200;
const PERSISTED_ACCURACY_GATE_M = 100; const PERSISTED_ACCURACY_GATE_M = 100;
// NOTE: IDLE previously used `startGeofences()` + a managed geofence. // NOTE: IDLE previously experimented with `startGeofences()` + an app-managed exit geofence.
// We now rely on the SDK's stop-detection + stationary geofence // That approach is now removed.
// (`stopOnStationary` + `stationaryRadius`) because it is more reliable // Current design relies on the SDK's stop-detection + stationary geofence
// in background/locked scenarios. // (`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 // 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). // exactly one persisted fix when we get strong evidence of movement (motion+activity).
@ -82,13 +83,12 @@ export default function trackLocation() {
let lastIdleMovementFallbackAt = 0; let lastIdleMovementFallbackAt = 0;
// Diagnostics fields retained so server-side correlation can continue to work. // 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.) // This is *not* a managed geofence anymore; it's a reference center for observability.
let lastEnsuredIdleGeofenceAt = 0; let lastEnsuredIdleReferenceAt = 0;
let lastIdleGeofenceCenter = null; let lastIdleReferenceCenter = null;
let lastIdleGeofenceCenterAccuracyM = null; let lastIdleReferenceCenterAccuracyM = null;
let lastIdleGeofenceCenterTimestamp = null; let lastIdleReferenceCenterTimestamp = null;
let lastIdleGeofenceCenterSource = null; let lastIdleReferenceCenterSource = null;
let lastIdleGeofenceRadiusM = null;
// A) Safeguard: when entering IDLE, ensure we have a reasonably accurate and recent // 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 // reference point. This does NOT persist/upload; it only updates our stored last-known
@ -123,14 +123,14 @@ export default function trackLocation() {
isRecentEnough && isRecentEnough &&
isAccurateEnough isAccurateEnough
) { ) {
lastIdleGeofenceCenter = { lastIdleReferenceCenter = {
latitude: storedCoords.latitude, latitude: storedCoords.latitude,
longitude: storedCoords.longitude, longitude: storedCoords.longitude,
}; };
lastIdleGeofenceCenterAccuracyM = storedAcc; lastIdleReferenceCenterAccuracyM = storedAcc;
lastIdleGeofenceCenterTimestamp = storedTs ?? null; lastIdleReferenceCenterTimestamp = storedTs ?? null;
lastIdleGeofenceCenterSource = "stored"; lastIdleReferenceCenterSource = "stored";
lastEnsuredIdleGeofenceAt = Date.now(); lastEnsuredIdleReferenceAt = Date.now();
void updateTrackingContextExtras("idle_reference_ok"); void updateTrackingContextExtras("idle_reference_ok");
return; return;
} }
@ -152,17 +152,17 @@ export default function trackLocation() {
if (fix?.coords?.latitude && fix?.coords?.longitude) { if (fix?.coords?.latitude && fix?.coords?.longitude) {
storeLocation(fix.coords, fix.timestamp); storeLocation(fix.coords, fix.timestamp);
lastIdleGeofenceCenter = { lastIdleReferenceCenter = {
latitude: fix.coords.latitude, latitude: fix.coords.latitude,
longitude: fix.coords.longitude, longitude: fix.coords.longitude,
}; };
lastIdleGeofenceCenterAccuracyM = lastIdleReferenceCenterAccuracyM =
typeof fix.coords.accuracy === "number" typeof fix.coords.accuracy === "number"
? fix.coords.accuracy ? fix.coords.accuracy
: null; : null;
lastIdleGeofenceCenterTimestamp = fix.timestamp ?? null; lastIdleReferenceCenterTimestamp = fix.timestamp ?? null;
lastIdleGeofenceCenterSource = "idle_reference_fix"; lastIdleReferenceCenterSource = "idle_reference_fix";
lastEnsuredIdleGeofenceAt = Date.now(); lastEnsuredIdleReferenceAt = Date.now();
void updateTrackingContextExtras("idle_reference_fixed"); void updateTrackingContextExtras("idle_reference_fixed");
} }
} catch (e) { } catch (e) {
@ -271,7 +271,7 @@ export default function trackLocation() {
const updateTrackingContextExtras = async (reason) => { const updateTrackingContextExtras = async (reason) => {
try { try {
const { userId } = getSessionState(); const { userId } = getSessionState();
await BackgroundGeolocation.setConfig({ const payload = await buildBackgroundGeolocationSetConfigPayload({
persistence: { persistence: {
extras: { extras: {
tracking_ctx: { tracking_ctx: {
@ -281,22 +281,20 @@ export default function trackLocation() {
auth_ready: authReady, auth_ready: authReady,
session_user_id: userId || null, session_user_id: userId || null,
// Diagnostics: helps correlate server-side "no update" reports with // Diagnostics: helps correlate server-side "no update" reports with
// the IDLE geofence placement parameters. // the last known good reference center when entering IDLE.
idle_geofence: { idle_reference: {
// Option 2: managed IDLE geofence removed. center: lastIdleReferenceCenter,
id: null, center_accuracy_m: lastIdleReferenceCenterAccuracyM,
radius_m: lastIdleGeofenceRadiusM, center_timestamp: lastIdleReferenceCenterTimestamp,
center: lastIdleGeofenceCenter, center_source: lastIdleReferenceCenterSource,
center_accuracy_m: lastIdleGeofenceCenterAccuracyM, ensured_at: lastEnsuredIdleReferenceAt || null,
center_timestamp: lastIdleGeofenceCenterTimestamp,
center_source: lastIdleGeofenceCenterSource,
ensured_at: lastEnsuredIdleGeofenceAt || null,
}, },
at: new Date().toISOString(), at: new Date().toISOString(),
}, },
}, },
}, },
}); });
await BackgroundGeolocation.setConfig(payload);
} catch (e) { } catch (e) {
// Non-fatal: extras are only for observability/debugging. // Non-fatal: extras are only for observability/debugging.
locationLogger.debug("Failed to update BGGeo tracking extras", { 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 // while native state drifts (eg `trackingMode` remains geofence-only but geofences
// are missing, leading to "moving but no updates"). // are missing, leading to "moving but no updates").
// //
// If profile is unchanged, perform a lightweight runtime ensure: // If profile is unchanged, perform a lightweight runtime ensure.
// - IDLE: ensure we're in geofence-only mode and that the managed exit geofence exists. // We no longer use geofence-only tracking; ensure we are not stuck in it.
// - ACTIVE: ensure we're NOT stuck in geofence-only mode.
if (currentProfile === profileName) { if (currentProfile === profileName) {
try { try {
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
@ -629,7 +626,7 @@ export default function trackLocation() {
if (state?.trackingMode === 0) { if (state?.trackingMode === 0) {
await BackgroundGeolocation.start(); await BackgroundGeolocation.start();
} }
locationLogger.info("Profile unchanged; IDLE runtime ensured", { locationLogger.debug("Profile unchanged; IDLE runtime ensured", {
instanceId: TRACK_LOCATION_INSTANCE_ID, instanceId: TRACK_LOCATION_INSTANCE_ID,
trackingMode: state?.trackingMode, trackingMode: state?.trackingMode,
enabled: state?.enabled, enabled: state?.enabled,
@ -643,7 +640,7 @@ export default function trackLocation() {
if (state?.trackingMode === 0) { if (state?.trackingMode === 0) {
await BackgroundGeolocation.start(); await BackgroundGeolocation.start();
} }
locationLogger.info("Profile unchanged; ACTIVE runtime ensured", { locationLogger.debug("Profile unchanged; ACTIVE runtime ensured", {
instanceId: TRACK_LOCATION_INSTANCE_ID, instanceId: TRACK_LOCATION_INSTANCE_ID,
trackingMode: state?.trackingMode, trackingMode: state?.trackingMode,
enabled: state?.enabled, enabled: state?.enabled,
@ -663,12 +660,11 @@ export default function trackLocation() {
const applyStartedAt = Date.now(); const applyStartedAt = Date.now();
// Diagnostic: track trackingMode transitions (especially geofence-only mode) across // Diagnostic snapshot (debug only) to help understand trackingMode transitions.
// identity changes and profile switches.
let preState = null; let preState = null;
try { try {
preState = await BackgroundGeolocation.getState(); preState = await BackgroundGeolocation.getState();
locationLogger.info("Applying tracking profile (pre-state)", { locationLogger.debug("Applying tracking profile (pre-state)", {
profileName, profileName,
instanceId: TRACK_LOCATION_INSTANCE_ID, instanceId: TRACK_LOCATION_INSTANCE_ID,
enabled: preState?.enabled, enabled: preState?.enabled,
@ -700,7 +696,10 @@ export default function trackLocation() {
}); });
try { try {
await BackgroundGeolocation.setConfig(profile); const payload = await buildBackgroundGeolocationSetConfigPayload(
profile,
);
await BackgroundGeolocation.setConfig(payload);
// Motion state strategy: // Motion state strategy:
// - ACTIVE: force moving to begin aggressive tracking immediately. // - ACTIVE: force moving to begin aggressive tracking immediately.
@ -758,6 +757,9 @@ export default function trackLocation() {
longitude: fix?.coords?.longitude, longitude: fix?.coords?.longitude,
timestamp: fix?.timestamp, timestamp: fix?.timestamp,
}); });
// Prevent duplicated "moving-edge" persisted fix right after entering ACTIVE.
lastMovingEdgeAt = Date.now();
} catch (error) { } catch (error) {
locationLogger.warn("ACTIVE immediate fix failed", { locationLogger.warn("ACTIVE immediate fix failed", {
error: error?.message, error: error?.message,
@ -790,18 +792,16 @@ export default function trackLocation() {
void ensureIdleReferenceFix(); void ensureIdleReferenceFix();
} }
// Post-state snapshot to detect if we're unintentionally left in geofence-only mode // Post-state snapshot (debug) to detect unintended geofence-only mode.
// after applying ACTIVE.
try { try {
const post = await BackgroundGeolocation.getState(); const post = await BackgroundGeolocation.getState();
locationLogger.info("Tracking profile applied (post-state)", { locationLogger.debug("Tracking profile applied (post-state)", {
profileName, profileName,
instanceId: TRACK_LOCATION_INSTANCE_ID, instanceId: TRACK_LOCATION_INSTANCE_ID,
enabled: post?.enabled, enabled: post?.enabled,
isMoving: post?.isMoving, isMoving: post?.isMoving,
trackingMode: post?.trackingMode, trackingMode: post?.trackingMode,
distanceFilter: post?.geolocation?.distanceFilter, distanceFilter: post?.geolocation?.distanceFilter,
// Comparing against preState helps debug transitions.
prevTrackingMode: preState?.trackingMode ?? null, prevTrackingMode: preState?.trackingMode ?? null,
}); });
} catch (e) { } catch (e) {
@ -855,20 +855,21 @@ export default function trackLocation() {
instanceId: TRACK_LOCATION_INSTANCE_ID, instanceId: TRACK_LOCATION_INSTANCE_ID,
}); });
// Snapshot state early so we can detect if the SDK is currently in geofence-only mode // Snapshot state early (debug only) to diagnose "no uploads" reports after auth refresh.
// when auth changes (common during IDLE profile). if (__DEV__ || env.IS_STAGING) {
try { try {
const s = await BackgroundGeolocation.getState(); const s = await BackgroundGeolocation.getState();
locationLogger.debug("Auth-change BGGeo state snapshot", { locationLogger.debug("Auth-change BGGeo state snapshot", {
instanceId: TRACK_LOCATION_INSTANCE_ID, instanceId: TRACK_LOCATION_INSTANCE_ID,
enabled: s?.enabled, enabled: s?.enabled,
isMoving: s?.isMoving, isMoving: s?.isMoving,
trackingMode: s?.trackingMode, trackingMode: s?.trackingMode,
}); });
} catch (e) { } catch (e) {
locationLogger.debug("Auth-change BGGeo state snapshot failed", { locationLogger.debug("Auth-change BGGeo state snapshot failed", {
error: e?.message, error: e?.message,
}); });
}
} }
// Compute identity from session store; this is our source of truth. // Compute identity from session store; this is our source of truth.
@ -888,13 +889,14 @@ export default function trackLocation() {
); );
try { try {
await BackgroundGeolocation.setConfig({ const payload = await buildBackgroundGeolocationSetConfigPayload({
http: { http: {
url: "", url: "",
autoSync: false, autoSync: false,
headers: {}, headers: {},
}, },
}); });
await BackgroundGeolocation.setConfig(payload);
didDisableUploadsForAnonymous = true; didDisableUploadsForAnonymous = true;
didSyncAfterAuth = false; didSyncAfterAuth = false;
} catch (e) { } catch (e) {
@ -950,22 +952,24 @@ export default function trackLocation() {
lastSessionUserId = null; lastSessionUserId = null;
return; return;
} }
// unsub();
locationLogger.debug("Updating background geolocation config"); locationLogger.debug("Updating background geolocation config");
await BackgroundGeolocation.setConfig({ {
http: { const payload = await buildBackgroundGeolocationSetConfigPayload({
// Update the sync URL for when it's changed for staging http: {
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
// IMPORTANT: enable native uploading when authenticated. url: env.GEOLOC_SYNC_URL,
// This ensures uploads continue even if JS is suspended in background. // IMPORTANT: enable native uploading when authenticated.
autoSync: true, // This ensures uploads continue even if JS is suspended in background.
batchSync: false, autoSync: true,
autoSyncThreshold: 0, batchSync: false,
headers: { autoSyncThreshold: 0,
Authorization: `Bearer ${userToken}`, headers: {
Authorization: `Bearer ${userToken}`,
},
}, },
}, });
}); await BackgroundGeolocation.setConfig(payload);
}
authReady = true; authReady = true;
@ -981,14 +985,6 @@ export default function trackLocation() {
); );
const state = await BackgroundGeolocation.getState(); 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) { if (!state.enabled) {
locationLogger.info("Starting location tracking"); locationLogger.info("Starting location tracking");
@ -1084,12 +1080,14 @@ export default function trackLocation() {
setBackgroundGeolocationEventHandlers({ setBackgroundGeolocationEventHandlers({
onLocation: async (location) => { onLocation: async (location) => {
locationLogger.debug("Location update received", { locationLogger.debug("Location update received", {
coords: location.coords, uuid: location?.uuid,
timestamp: location.timestamp, sample: location?.sample,
activity: location.activity, accuracy: location?.coords?.accuracy,
battery: location.battery, latitude: location?.coords?.latitude,
sample: location.sample, longitude: location?.coords?.longitude,
extras: location.extras, timestamp: location?.timestamp,
activity: location?.activity,
extras: location?.extras,
}); });
// Ignore sampling locations (eg, emitted during getCurrentPosition) to avoid UI/storage churn. // 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 we're IDLE, update reference center for later correlation.
if (currentProfile === "idle") { if (currentProfile === "idle") {
lastIdleGeofenceCenter = { lastIdleReferenceCenter = {
latitude: location.coords.latitude, latitude: location.coords.latitude,
longitude: location.coords.longitude, longitude: location.coords.longitude,
}; };
lastIdleGeofenceCenterAccuracyM = lastIdleReferenceCenterAccuracyM =
typeof location.coords.accuracy === "number" typeof location.coords.accuracy === "number"
? location.coords.accuracy ? location.coords.accuracy
: null; : null;
lastIdleGeofenceCenterTimestamp = location.timestamp ?? null; lastIdleReferenceCenterTimestamp = location.timestamp ?? null;
lastIdleGeofenceCenterSource = "onLocation"; lastIdleReferenceCenterSource = "onLocation";
lastEnsuredIdleGeofenceAt = Date.now(); lastEnsuredIdleReferenceAt = Date.now();
void updateTrackingContextExtras("idle_reference_updated"); 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) => { onLocationError: (error) => {
locationLogger.warn("Location error", { locationLogger.warn("Location error", {
error: error?.message, error: error?.message,
@ -1155,95 +1144,44 @@ export default function trackLocation() {
responseText: response?.responseText, responseText: response?.responseText,
}); });
// Instrumentation: when we see periodic HTTP without a corresponding location event, // Lightweight instrumentation only when useful:
// we want to know if BGGeo is retrying an upload queue or flushing new records. // - non-success responses
// This helps diagnose reports like "server receives updates every ~5 minutes while stationary". // - dev/staging visibility
const shouldInstrumentHttp =
!response?.success || __DEV__ || env.IS_STAGING;
if (!shouldInstrumentHttp) return;
try { try {
const [state, count] = await Promise.all([ const [state, count] = await Promise.all([
BackgroundGeolocation.getState(), BackgroundGeolocation.getState(),
BackgroundGeolocation.getCount(), BackgroundGeolocation.getCount(),
]); ]);
locationLogger.debug("HTTP instrumentation", { locationLogger.debug("HTTP instrumentation", {
success: response?.success,
status: response?.status,
enabled: state?.enabled, enabled: state?.enabled,
isMoving: state?.isMoving, isMoving: state?.isMoving,
trackingMode: state?.trackingMode, trackingMode: state?.trackingMode,
schedulerEnabled: state?.schedulerEnabled,
pendingCount: count, pendingCount: count,
}); });
} catch (e) { } catch (e) {
locationLogger.warn("Failed HTTP instrumentation", { locationLogger.debug("Failed HTTP instrumentation", {
error: e?.message, 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) => { onMotionChange: (event) => {
// Diagnostic snapshot to understand periodic motion-change loops (eg Android ~5min). // Essential motion diagnostics (avoid spam; keep it one log per edge).
// 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.
locationLogger.info("Motion change", { locationLogger.info("Motion change", {
instanceId: TRACK_LOCATION_INSTANCE_ID,
profile: currentProfile,
appState,
authReady,
isMoving: event?.isMoving, 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 // Moving-edge strategy: when we enter moving state, force one persisted high-quality
// point + sync so the server gets a quick update. // point + sync so the server gets a quick update.
// //
@ -1303,7 +1241,7 @@ export default function trackLocation() {
} }
}, },
onActivityChange: (event) => { onActivityChange: (event) => {
locationLogger.info("Activity change", { locationLogger.debug("Activity change", {
activity: event?.activity, activity: event?.activity,
confidence: event?.confidence, confidence: event?.confidence,
}); });
@ -1325,7 +1263,7 @@ export default function trackLocation() {
}); });
}, },
onConnectivityChange: (event) => { onConnectivityChange: (event) => {
locationLogger.info("Connectivity change", { locationLogger.debug("Connectivity change", {
connected: event?.connected, connected: event?.connected,
}); });
}, },
@ -1359,7 +1297,10 @@ export default function trackLocation() {
// Ensure critical config cannot drift due to persisted plugin state. // Ensure critical config cannot drift due to persisted plugin state.
// (We intentionally keep auth headers separate and set them in handleAuth.) // (We intentionally keep auth headers separate and set them in handleAuth.)
try { try {
await BackgroundGeolocation.setConfig(BASE_GEOLOCATION_INVARIANTS); const payload = await buildBackgroundGeolocationSetConfigPayload(
BASE_GEOLOCATION_INVARIANTS,
);
await BackgroundGeolocation.setConfig(payload);
} catch (e) { } catch (e) {
locationLogger.warn("Failed to apply BGGeo base invariants", { locationLogger.warn("Failed to apply BGGeo base invariants", {
error: e?.message, error: e?.message,