diff --git a/docs/location-tracking-qa.md b/docs/location-tracking-qa.md index 523bfe6..b4e5dce 100644 --- a/docs/location-tracking-qa.md +++ b/docs/location-tracking-qa.md @@ -15,7 +15,9 @@ Applies to the BackgroundGeolocation integration: ## Current implementation notes - Movement-driven recording only: - - IDLE uses `geolocation.distanceFilter: 200` (aim: no updates while not moving). + - 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 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)). - Upload strategy is intentionally simple: @@ -27,6 +29,8 @@ Applies to the BackgroundGeolocation integration: - Stationary noise suppression: - Native accuracy gate for persisted/uploaded locations: `geolocation.filter.trackingAccuracyThreshold: 100`. - Identical location suppression: `geolocation.allowIdenticalLocations: false`. + - IDLE primarily relies on stop-detection + stationary geofence (`stopOnStationary: true`) to eliminate periodic stationary updates. + - Elasticity disabled (`disableElasticity: true`) to avoid dynamic distanceFilter shrink. - Extra safety: any JS-triggered persisted fix requests are tagged and ignored if accuracy > 100m. ## Basic preconditions @@ -43,7 +47,7 @@ Applies to the BackgroundGeolocation integration: 2. Stay stationary for 5+ minutes. - Expect: no repeated server updates. 3. Walk/drive ~250m. - - Expect: at least one location persisted + uploaded. + - Expect: `onMotionChange(isMoving:true)` then one persisted location + upload. ### ACTIVE (open alert) @@ -61,7 +65,7 @@ Applies to the BackgroundGeolocation integration: 2. Stay stationary. - Expect: no periodic uploads. 3. Move ~250m. - - Expect: a persisted record and upload. + - Expect: `onMotionChange(isMoving:true)` then one persisted record and upload. ### ACTIVE @@ -93,9 +97,9 @@ Applies to the BackgroundGeolocation integration: | Platform | App state | Profile | Move | Expected signals | |---|---|---|---:|---| -| Android | foreground | IDLE | ~250m | [`onLocation`](src/location/trackLocation.js:693) (sample=false), then [`onHttp`](src/location/trackLocation.js:733) | +| Android | foreground | IDLE | ~250m | [`onMotionChange`](src/location/trackLocation.js:1192) then [`onLocation`](src/location/trackLocation.js:1085) (sample=false), then [`onHttp`](src/location/trackLocation.js:1150) | | Android | background | IDLE | ~250m | same as above | -| Android | swipe-away | IDLE | ~250m | native persists + uploads; verify server + `onHttp` when app relaunches | +| Android | swipe-away | IDLE | ~250m | native geofence triggers; verify server update; app may relaunch to deliver JS logs | | Android | foreground | ACTIVE | ~30m | location + upload continues | | iOS | background | IDLE | ~250m | movement-driven update; no periodic uploads while stationary | | iOS | swipe-killed | IDLE | significant | OS relaunch on movement; upload after relaunch | @@ -105,7 +109,7 @@ Applies to the BackgroundGeolocation integration: - 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). - Movement-only uploads: - - IDLE distance threshold: `distanceFilter: 200` in [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:148). + - IDLE: look for `Motion change` (isMoving=true) and `IDLE movement fallback fix`. - ACTIVE distance threshold: `distanceFilter: 25` in [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:148). - Attribution for `getCurrentPosition`: diff --git a/plans/geolocation-periodic-uploads-investigation.md b/plans/geolocation-periodic-uploads-investigation.md new file mode 100644 index 0000000..fe37949 --- /dev/null +++ b/plans/geolocation-periodic-uploads-investigation.md @@ -0,0 +1,27 @@ +# Geolocation periodic uploads investigation + +## Observations + +From the app logs, we still see background IDLE uploads every ~5–10 minutes even when the device is stationary. + +Key points: + +- The upload is happening with `sample: undefined` (ie, persisted location) and is followed by HTTP success. +- Motion state can flip to `isMovingState: true` even while `activity: still` and low `speed`. +- In at least one diagnostic snapshot, `stopOnStationary` appeared as `undefined`, suggesting the config either: + - is not being applied as expected, or + - is being overridden by another config surface, or + - is not exposed in `getState()` on that platform/version. + +## Next steps + +1. Confirm the currently running app build includes the latest config (`useSignificantChangesOnly`, `activity.stopOnStationary`). +2. Extract precise server-side event timing distribution from Explore logs to see whether bursts correlate to motionchange or other triggers. +3. If periodic uploads persist after significant-change mode: + - evaluate whether another mechanism triggers periodic sync (eg native retry/reconnect), + - consider disabling motion-activity updates in IDLE or increasing motion trigger delay, + - optionally introduce a server-side dedupe (ignore updates if within X meters and within Y minutes) as last-resort safety. + +## Notes + +The Explore log snippet currently contains only `action` and `timestamp` (no payload coordinates), so it can confirm frequency/bursts but not whether the same point is repeatedly uploaded. diff --git a/src/location/backgroundGeolocationConfig.js b/src/location/backgroundGeolocationConfig.js index 7b15136..c127aeb 100644 --- a/src/location/backgroundGeolocationConfig.js +++ b/src/location/backgroundGeolocationConfig.js @@ -63,10 +63,21 @@ export const BASE_GEOLOCATION_CONFIG = { // even with no open alert (see TRACKING_PROFILES.idle). distanceFilter: 200, + // Prevent dynamic distanceFilter shrink. + // Elasticity can lower the effective threshold (eg while stationary), resulting in + // unexpected frequent updates. + 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 + // tracking and rely on the stationary geofence to detect significant movement. + // This is intended to eliminate periodic stationary updates on Android. + stopOnStationary: true, + stationaryRadius: 200, + // Prevent identical/noise locations from being persisted. // This reduces DB churn and avoids triggering native HTTP uploads with redundant points. allowIdenticalLocations: false, @@ -170,8 +181,19 @@ export const TRACKING_PROFILES = { // 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, - // Defensive: keep the distanceFilter conservative to avoid battery drain. + + // 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, @@ -183,8 +205,6 @@ export const TRACKING_PROFILES = { }, activity: { // Android-only: reduce false-positive motion triggers due to screen-on/unlock. - // We keep Motion API enabled (battery-optimized) but add a large delay so brief - // activity-jitter cannot repeatedly toggle moving/stationary while the user is idle. // (This is ignored on iOS.) motionTriggerDelay: 300000, }, @@ -195,6 +215,12 @@ export const TRACKING_PROFILES = { // 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, diff --git a/src/location/backgroundGeolocationService.js b/src/location/backgroundGeolocationService.js index ee764f9..4efd590 100644 --- a/src/location/backgroundGeolocationService.js +++ b/src/location/backgroundGeolocationService.js @@ -53,6 +53,7 @@ export function getLastReadyState() { export function setBackgroundGeolocationEventHandlers({ onLocation, onLocationError, + onGeofence, onHttp, onHeartbeat, onSchedule, @@ -66,6 +67,7 @@ export function setBackgroundGeolocationEventHandlers({ // We use a simple signature so calling with identical functions is a no-op. const sig = [ onLocation ? "L1" : "L0", + onGeofence ? "G1" : "G0", onHttp ? "H1" : "H0", onHeartbeat ? "HB1" : "HB0", onSchedule ? "S1" : "S0", @@ -87,6 +89,10 @@ export function setBackgroundGeolocationEventHandlers({ BackgroundGeolocation.onLocation(onLocation, onLocationError), ); } + + if (onGeofence) { + subscriptions.push(BackgroundGeolocation.onGeofence(onGeofence)); + } if (onHttp) { subscriptions.push(BackgroundGeolocation.onHttp(onHttp)); } diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 1fbf381..f9c3ff5 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -16,7 +16,7 @@ import { } from "~/stores"; import setLocationState from "~/location/setLocationState"; -import { storeLocation } from "~/location/storage"; +import { getStoredLocation, storeLocation } from "~/location/storage"; import env from "~/env"; @@ -32,6 +32,12 @@ import { let trackLocationStartPromise = null; +// Correlation ID to differentiate multiple JS runtimes (eg full `Updates.reloadAsync()`) +// from tree-level reloads (auth/account switch). +const TRACK_LOCATION_INSTANCE_ID = `${Date.now().toString(36)}-${Math.random() + .toString(16) + .slice(2, 8)}`; + export default function trackLocation() { if (trackLocationStartPromise) return trackLocationStartPromise; @@ -41,6 +47,11 @@ export default function trackLocation() { feature: "tracking", }); + locationLogger.info("trackLocation() starting", { + instanceId: TRACK_LOCATION_INSTANCE_ID, + appState: AppState.currentState, + }); + let currentProfile = null; let authReady = false; let appState = AppState.currentState; @@ -58,6 +69,160 @@ 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. + + // 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). + const IDLE_MOVEMENT_FALLBACK_COOLDOWN_MS = 15 * 60 * 1000; + let lastActivity = null; + let lastActivityConfidence = 0; + 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; + + // 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 + // location and tracking extras. + const IDLE_REFERENCE_TARGET_ACCURACY_M = 50; + const IDLE_REFERENCE_MAX_AGE_MS = 5 * 60 * 1000; + const ensureIdleReferenceFix = async () => { + try { + const stored = await getStoredLocation(); + const storedCoords = stored?.coords; + const storedAcc = + typeof storedCoords?.accuracy === "number" + ? storedCoords.accuracy + : null; + const storedTs = stored?.timestamp; + const storedAgeMs = storedTs + ? Date.now() - new Date(storedTs).getTime() + : null; + + const isRecentEnough = + typeof storedAgeMs === "number" && storedAgeMs >= 0 + ? storedAgeMs <= IDLE_REFERENCE_MAX_AGE_MS + : false; + const isAccurateEnough = + typeof storedAcc === "number" + ? storedAcc <= IDLE_REFERENCE_TARGET_ACCURACY_M + : false; + + if ( + storedCoords?.latitude && + storedCoords?.longitude && + isRecentEnough && + isAccurateEnough + ) { + lastIdleGeofenceCenter = { + latitude: storedCoords.latitude, + longitude: storedCoords.longitude, + }; + lastIdleGeofenceCenterAccuracyM = storedAcc; + lastIdleGeofenceCenterTimestamp = storedTs ?? null; + lastIdleGeofenceCenterSource = "stored"; + lastEnsuredIdleGeofenceAt = Date.now(); + void updateTrackingContextExtras("idle_reference_ok"); + return; + } + + const fix = await getCurrentPositionWithDiagnostics( + { + samples: 2, + timeout: 30, + maximumAge: 0, + desiredAccuracy: IDLE_REFERENCE_TARGET_ACCURACY_M, + extras: { + idle_reference_fix: true, + idle_ref_prev_acc: storedAcc, + idle_ref_prev_age_ms: storedAgeMs, + }, + }, + { reason: "idle_reference_fix", persist: false }, + ); + + if (fix?.coords?.latitude && fix?.coords?.longitude) { + storeLocation(fix.coords, fix.timestamp); + lastIdleGeofenceCenter = { + latitude: fix.coords.latitude, + longitude: fix.coords.longitude, + }; + lastIdleGeofenceCenterAccuracyM = + typeof fix.coords.accuracy === "number" + ? fix.coords.accuracy + : null; + lastIdleGeofenceCenterTimestamp = fix.timestamp ?? null; + lastIdleGeofenceCenterSource = "idle_reference_fix"; + lastEnsuredIdleGeofenceAt = Date.now(); + void updateTrackingContextExtras("idle_reference_fixed"); + } + } catch (e) { + locationLogger.debug("Failed to ensure IDLE reference fix", { + error: e?.message, + }); + } + }; + + const maybeRequestIdleMovementFallbackFix = async (trigger) => { + if (currentProfile !== "idle" || !authReady) return; + if ( + Date.now() - lastIdleMovementFallbackAt < + IDLE_MOVEMENT_FALLBACK_COOLDOWN_MS + ) { + return; + } + + // Option 2: primary trigger is `onMotionChange(isMoving:true)`. + // Keep `onActivityChange` as a secondary signal (lower confidence threshold). + const movingActivities = new Set([ + "walking", + "running", + "on_foot", + "in_vehicle", + "cycling", + ]); + const hasSomeActivitySignal = + movingActivities.has(lastActivity) && lastActivityConfidence >= 50; + + if (trigger === "activitychange" && !hasSomeActivitySignal) return; + + lastIdleMovementFallbackAt = Date.now(); + locationLogger.info("IDLE movement fallback fix", { + trigger, + lastActivity, + lastActivityConfidence, + }); + + try { + await getCurrentPositionWithDiagnostics( + { + samples: 2, + timeout: 30, + maximumAge: 0, + desiredAccuracy: 50, + extras: { + idle_movement_fallback: true, + }, + }, + { reason: `idle_movement_fallback:${trigger}`, persist: true }, + ); + } catch (e) { + locationLogger.warn("IDLE movement fallback fix failed", { + trigger, + error: e?.message, + }); + } + }; + const shouldUseLocationForUi = (location) => { const acc = location?.coords?.accuracy; return !(typeof acc === "number" && acc > BAD_ACCURACY_THRESHOLD_M); @@ -115,6 +280,18 @@ export default function trackLocation() { profile: currentProfile, 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, + }, at: new Date().toISOString(), }, }, @@ -433,10 +610,82 @@ export default function trackLocation() { // We only apply profile once auth headers are configured. return; } - if (currentProfile === profileName) return; + + // IMPORTANT: + // Do not assume the native SDK runtime mode still matches our JS `currentProfile`. + // During identity switch / tree reload, we can remain in the same logical profile + // 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 (currentProfile === profileName) { + try { + const state = await BackgroundGeolocation.getState(); + + if (profileName === "idle") { + // Ensure we are not stuck in geofence-only mode. + if (state?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + locationLogger.info("Profile unchanged; IDLE runtime ensured", { + instanceId: TRACK_LOCATION_INSTANCE_ID, + trackingMode: state?.trackingMode, + enabled: state?.enabled, + }); + void ensureIdleReferenceFix(); + } + + if (profileName === "active") { + // If we previously called `startGeofences()`, the SDK can remain in geofence-only + // mode until we explicitly call `start()` again. + if (state?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + locationLogger.info("Profile unchanged; ACTIVE runtime ensured", { + instanceId: TRACK_LOCATION_INSTANCE_ID, + trackingMode: state?.trackingMode, + enabled: state?.enabled, + }); + } + } catch (e) { + locationLogger.debug( + "Failed to ensure runtime for unchanged profile", + { + profileName, + error: e?.message, + }, + ); + } + return; + } const applyStartedAt = Date.now(); + // Diagnostic: track trackingMode transitions (especially geofence-only mode) across + // identity changes and profile switches. + let preState = null; + try { + preState = await BackgroundGeolocation.getState(); + locationLogger.info("Applying tracking profile (pre-state)", { + profileName, + instanceId: TRACK_LOCATION_INSTANCE_ID, + enabled: preState?.enabled, + isMoving: preState?.isMoving, + trackingMode: preState?.trackingMode, + distanceFilter: preState?.geolocation?.distanceFilter, + }); + } catch (e) { + locationLogger.debug( + "Failed to read BGGeo state before profile apply", + { + profileName, + error: e?.message, + }, + ); + } + const profile = TRACKING_PROFILES[profileName]; if (!profile) { locationLogger.warn("Unknown tracking profile", { profileName }); @@ -462,6 +711,15 @@ export default function trackLocation() { // transitions so we still get distance-based updates when the user truly moves. if (profileName === "active") { const state = await BackgroundGeolocation.getState(); + + // If we were previously in geofence-only mode, switch back to standard tracking. + // Without this, calling `changePace(true)` is not sufficient on some devices, + // and the SDK can stay in `trackingMode: 0` (geofence-only), producing no + // distance-based updates while moving. + if (state?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + if (!state?.isMoving) { await BackgroundGeolocation.changePace(true); } @@ -519,6 +777,43 @@ export default function trackLocation() { // Update extras for observability (profile transitions are a key lifecycle change). updateTrackingContextExtras(`profile:${profileName}`); + // For IDLE, ensure we are NOT in geofence-only tracking mode. + if (profileName === "idle") { + try { + const s = await BackgroundGeolocation.getState(); + if (s?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + } catch { + // ignore + } + void ensureIdleReferenceFix(); + } + + // Post-state snapshot to detect if we're unintentionally left in geofence-only mode + // after applying ACTIVE. + try { + const post = await BackgroundGeolocation.getState(); + locationLogger.info("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) { + locationLogger.debug( + "Failed to read BGGeo state after profile apply", + { + profileName, + error: e?.message, + }, + ); + } + try { const state = await BackgroundGeolocation.getState(); locationLogger.info("Tracking profile applied", { @@ -557,8 +852,25 @@ export default function trackLocation() { locationLogger.info("Handling auth token update", { hasToken: !!userToken, + 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, + }); + } + // 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.) let currentSessionUserId = null; @@ -802,8 +1114,33 @@ export default function trackLocation() { setLocationState(location.coords); // Also store in AsyncStorage for last known location fallback storeLocation(location.coords, location.timestamp); + + // If we're IDLE, update reference center for later correlation. + if (currentProfile === "idle") { + lastIdleGeofenceCenter = { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }; + lastIdleGeofenceCenterAccuracyM = + typeof location.coords.accuracy === "number" + ? location.coords.accuracy + : null; + lastIdleGeofenceCenterTimestamp = location.timestamp ?? null; + lastIdleGeofenceCenterSource = "onLocation"; + lastEnsuredIdleGeofenceAt = 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, @@ -881,8 +1218,18 @@ export default function trackLocation() { 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, @@ -948,12 +1295,25 @@ export default function trackLocation() { })(); } } + + // IDLE fallback: if we get a real motion transition while locked but geofence EXIT + // is not delivered reliably, request one persisted fix (gated + cooled down). + if (event?.isMoving && currentProfile === "idle" && authReady) { + void maybeRequestIdleMovementFallbackFix("motionchange"); + } }, onActivityChange: (event) => { locationLogger.info("Activity change", { activity: event?.activity, confidence: event?.confidence, }); + + lastActivity = event?.activity; + lastActivityConfidence = event?.confidence ?? 0; + + if (currentProfile === "idle" && authReady) { + void maybeRequestIdleMovementFallbackFix("activitychange"); + } }, onProviderChange: (event) => { locationLogger.info("Provider change", {