1420 lines
50 KiB
JavaScript
1420 lines
50 KiB
JavaScript
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 {
|
|
getAlertState,
|
|
getAuthState,
|
|
getSessionState,
|
|
subscribeAlertState,
|
|
subscribeAuthState,
|
|
subscribeSessionState,
|
|
permissionsActions,
|
|
} from "~/stores";
|
|
|
|
import setLocationState from "~/location/setLocationState";
|
|
import { getStoredLocation, storeLocation } from "~/location/storage";
|
|
|
|
import env from "~/env";
|
|
|
|
import {
|
|
BASE_GEOLOCATION_CONFIG,
|
|
BASE_GEOLOCATION_INVARIANTS,
|
|
TRACKING_PROFILES,
|
|
} from "~/location/backgroundGeolocationConfig";
|
|
import {
|
|
ensureBackgroundGeolocationReady,
|
|
setBackgroundGeolocationEventHandlers,
|
|
} from "~/location/backgroundGeolocationService";
|
|
|
|
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;
|
|
|
|
trackLocationStartPromise = (async () => {
|
|
const locationLogger = createLogger({
|
|
module: BACKGROUND_SCOPES.GEOLOCATION,
|
|
feature: "tracking",
|
|
});
|
|
|
|
locationLogger.info("trackLocation() starting", {
|
|
instanceId: TRACK_LOCATION_INSTANCE_ID,
|
|
appState: AppState.currentState,
|
|
});
|
|
|
|
let currentProfile = null;
|
|
let authReady = false;
|
|
let appState = AppState.currentState;
|
|
let stopAlertSubscription = null;
|
|
let stopSessionSubscription = null;
|
|
|
|
// Pre-login behavior: keep BGGeo running (so we can collect a first point), but disable
|
|
// uploads until we have an auth token.
|
|
let didDisableUploadsForAnonymous = false;
|
|
let didSyncAfterAuth = false;
|
|
let didSyncAfterStartupFix = false;
|
|
let lastMovingEdgeAt = 0;
|
|
const MOVING_EDGE_COOLDOWN_MS = 5 * 60 * 1000;
|
|
|
|
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);
|
|
};
|
|
|
|
// Gate persisted/uploaded points (native layer also filters, but this protects any
|
|
// JS-triggered persisted-fix code-paths).
|
|
const shouldAllowPersistedFix = (location) => {
|
|
const acc = location?.coords?.accuracy;
|
|
return !(typeof acc === "number" && acc > PERSISTED_ACCURACY_GATE_M);
|
|
};
|
|
|
|
const getCurrentPositionWithDiagnostics = async (
|
|
options,
|
|
{ reason, persist },
|
|
) => {
|
|
const opts = {
|
|
...options,
|
|
persist,
|
|
extras: {
|
|
...(options?.extras || {}),
|
|
// Attribution for log correlation.
|
|
req_reason: reason,
|
|
req_persist: !!persist,
|
|
req_at: new Date().toISOString(),
|
|
req_app_state: appState,
|
|
req_profile: currentProfile,
|
|
},
|
|
};
|
|
|
|
locationLogger.debug("Requesting getCurrentPosition", {
|
|
reason,
|
|
persist: !!persist,
|
|
desiredAccuracy: opts?.desiredAccuracy,
|
|
samples: opts?.samples,
|
|
maximumAge: opts?.maximumAge,
|
|
timeout: opts?.timeout,
|
|
});
|
|
|
|
return BackgroundGeolocation.getCurrentPosition(opts);
|
|
};
|
|
|
|
// Track identity so we can force a first geopoint when the effective user changes.
|
|
let lastSessionUserId = null;
|
|
|
|
const updateTrackingContextExtras = async (reason) => {
|
|
try {
|
|
const { userId } = getSessionState();
|
|
await BackgroundGeolocation.setConfig({
|
|
persistence: {
|
|
extras: {
|
|
tracking_ctx: {
|
|
reason,
|
|
app_state: appState,
|
|
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(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
// Non-fatal: extras are only for observability/debugging.
|
|
locationLogger.debug("Failed to update BGGeo tracking extras", {
|
|
reason,
|
|
error: e?.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
// NOTE: Do not delete records from JS as a primary upload-filtering mechanism.
|
|
// When JS is suspended in background, deletions won't happen but native autoSync will.
|
|
// We keep this as a placeholder in case we later introduce a *server-side* filtering
|
|
// strategy or a safer native-side filter.
|
|
const pruneBadLocations = async () => 0;
|
|
|
|
const safeSync = async (reason) => {
|
|
// Sync can fail transiently (SDK busy, network warming up, etc). Retry a few times.
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
try {
|
|
const [state, pendingBefore] = await Promise.all([
|
|
BackgroundGeolocation.getState(),
|
|
BackgroundGeolocation.getCount(),
|
|
]);
|
|
|
|
const pruned = await pruneBadLocations();
|
|
|
|
locationLogger.info("Attempting BGGeo sync", {
|
|
reason,
|
|
attempt,
|
|
enabled: state?.enabled,
|
|
isMoving: state?.isMoving,
|
|
trackingMode: state?.trackingMode,
|
|
pendingBefore,
|
|
pruned,
|
|
});
|
|
|
|
const records = await BackgroundGeolocation.sync();
|
|
const pendingAfter = await BackgroundGeolocation.getCount();
|
|
|
|
locationLogger.info("BGGeo sync success", {
|
|
reason,
|
|
attempt,
|
|
synced: records?.length,
|
|
pendingAfter,
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
const msg =
|
|
typeof e === "string"
|
|
? e
|
|
: e?.message || e?.error || JSON.stringify(e);
|
|
locationLogger.warn("BGGeo sync failed", {
|
|
reason,
|
|
attempt,
|
|
error: msg,
|
|
stack: e?.stack,
|
|
});
|
|
await sleep(attempt * 1000);
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const requestIdentityPersistedFixAndSync = async ({ reason, userId }) => {
|
|
try {
|
|
const t0 = Date.now();
|
|
const location = await getCurrentPositionWithDiagnostics(
|
|
{
|
|
samples: 1,
|
|
timeout: 30,
|
|
maximumAge: 0,
|
|
desiredAccuracy: 50,
|
|
extras: {
|
|
identity_fix: true,
|
|
identity_reason: reason,
|
|
session_user_id: userId,
|
|
},
|
|
},
|
|
{ reason: `identity_fix:${reason}`, persist: true },
|
|
);
|
|
|
|
if (!shouldAllowPersistedFix(location)) {
|
|
locationLogger.info(
|
|
"Identity persisted fix ignored due to poor accuracy",
|
|
{
|
|
reason,
|
|
userId,
|
|
accuracy: location?.coords?.accuracy,
|
|
},
|
|
);
|
|
}
|
|
locationLogger.info("Identity persisted fix acquired", {
|
|
reason,
|
|
userId,
|
|
ms: Date.now() - t0,
|
|
accuracy: location?.coords?.accuracy,
|
|
latitude: location?.coords?.latitude,
|
|
longitude: location?.coords?.longitude,
|
|
timestamp: location?.timestamp,
|
|
});
|
|
} catch (e) {
|
|
locationLogger.warn("Identity persisted fix failed", {
|
|
reason,
|
|
userId,
|
|
error: e?.message,
|
|
stack: e?.stack,
|
|
});
|
|
}
|
|
|
|
await safeSync(`identity-fix:${reason}`);
|
|
};
|
|
|
|
// One-off startup refresh: when tracking is enabled at app launch, fetch a fresh fix once.
|
|
// This follows Transistorsoft docs guidance to use getCurrentPosition rather than forcing
|
|
// the SDK into moving mode with changePace(true).
|
|
let didRequestStartupFix = false;
|
|
let startupFixInFlight = null;
|
|
|
|
// Startup fix should be persisted so it can be auto-synced immediately (user expects
|
|
// to appear on server soon after first app open).
|
|
//
|
|
// IMPORTANT: restrict this to the ACTIVE profile only.
|
|
// Persisted startup fixes while IDLE can create "no-move" uploads on some devices.
|
|
const requestStartupPersistedFix = async () => {
|
|
try {
|
|
const before = await BackgroundGeolocation.getState();
|
|
locationLogger.info("Requesting startup persisted location fix", {
|
|
enabled: before.enabled,
|
|
trackingMode: before.trackingMode,
|
|
isMoving: before.isMoving,
|
|
});
|
|
|
|
if (currentProfile !== "active") {
|
|
locationLogger.info("Skipping startup persisted fix (not ACTIVE)", {
|
|
currentProfile,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const t0 = Date.now();
|
|
const location = await getCurrentPositionWithDiagnostics(
|
|
{
|
|
samples: 1,
|
|
timeout: 30,
|
|
maximumAge: 10000,
|
|
desiredAccuracy: 100,
|
|
extras: {
|
|
startup_fix: true,
|
|
},
|
|
},
|
|
{ reason: "startup_fix", persist: true },
|
|
);
|
|
|
|
if (!shouldAllowPersistedFix(location)) {
|
|
locationLogger.info(
|
|
"Startup persisted fix ignored due to poor accuracy",
|
|
{
|
|
accuracy: location?.coords?.accuracy,
|
|
timestamp: location?.timestamp,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
locationLogger.info("Startup persisted fix acquired", {
|
|
ms: Date.now() - t0,
|
|
accuracy: location?.coords?.accuracy,
|
|
latitude: location?.coords?.latitude,
|
|
longitude: location?.coords?.longitude,
|
|
timestamp: location?.timestamp,
|
|
});
|
|
|
|
// If uploads are currently disabled (pre-login), we'll flush this record once auth
|
|
// becomes available.
|
|
|
|
// If uploads are enabled, proactively flush now to guarantee server receives the
|
|
// first point quickly even if the SDK doesn't auto-sync immediately.
|
|
if (authReady && !didSyncAfterStartupFix) {
|
|
const ok = await safeSync("startup-fix");
|
|
if (ok) didSyncAfterStartupFix = true;
|
|
}
|
|
} catch (error) {
|
|
locationLogger.warn("Startup persisted fix failed", {
|
|
error: error?.message,
|
|
code: error?.code,
|
|
stack: error?.stack,
|
|
});
|
|
}
|
|
};
|
|
|
|
// When auth changes, we want a fresh location fix (UI-only) to refresh the app state.
|
|
// Debounced to avoid spamming `getCurrentPosition` if auth updates quickly (refresh/renew).
|
|
let authFixDebounceTimerId = null;
|
|
let authFixInFlight = null;
|
|
const AUTH_FIX_DEBOUNCE_MS = 1500;
|
|
const AUTH_FIX_COOLDOWN_MS = 15 * 60 * 1000;
|
|
let lastAuthFixAt = 0;
|
|
|
|
// Avoid periodic UI-only getCurrentPosition while app is backgrounded, since
|
|
// this is a common source of "updates while stationary" (it can also influence
|
|
// motion state / generate provider churn on some Android devices).
|
|
const shouldAllowUiFixes = () => appState === "active";
|
|
|
|
const scheduleAuthFreshFix = () => {
|
|
// Do not perform UI refresh fixes while backgrounded.
|
|
if (!shouldAllowUiFixes()) {
|
|
return authFixInFlight;
|
|
}
|
|
|
|
// Avoid generating persisted + auto-synced locations as a side-effect of frequent
|
|
// auth refreshes (eg app resume / screen unlock).
|
|
if (Date.now() - lastAuthFixAt < AUTH_FIX_COOLDOWN_MS) {
|
|
return authFixInFlight;
|
|
}
|
|
|
|
if (authFixDebounceTimerId) {
|
|
clearTimeout(authFixDebounceTimerId);
|
|
authFixDebounceTimerId = null;
|
|
}
|
|
|
|
authFixInFlight = new Promise((resolve) => {
|
|
authFixDebounceTimerId = setTimeout(resolve, AUTH_FIX_DEBOUNCE_MS);
|
|
}).then(async () => {
|
|
try {
|
|
const before = await BackgroundGeolocation.getState();
|
|
locationLogger.info("Requesting auth-change location fix", {
|
|
enabled: before.enabled,
|
|
trackingMode: before.trackingMode,
|
|
isMoving: before.isMoving,
|
|
});
|
|
|
|
// If we're already in ACTIVE, the profile transition will request an immediate
|
|
// high-accuracy persisted fix. Avoid duplicating work here.
|
|
if (currentProfile === "active") {
|
|
return;
|
|
}
|
|
|
|
const location = await getCurrentPositionWithDiagnostics(
|
|
{
|
|
samples: 1,
|
|
timeout: 20,
|
|
maximumAge: 10000,
|
|
desiredAccuracy: 100,
|
|
extras: {
|
|
auth_token_update: true,
|
|
},
|
|
},
|
|
{ reason: "auth_change_ui_fix", persist: false },
|
|
);
|
|
|
|
// If the fix is very poor accuracy, treat it as noise and do nothing.
|
|
// (We intentionally do not persist in this path.)
|
|
const acc = location?.coords?.accuracy;
|
|
if (typeof acc === "number" && acc > 100) {
|
|
locationLogger.info(
|
|
"Auth-change fix ignored due to poor accuracy",
|
|
{
|
|
accuracy: acc,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
locationLogger.info("Auth-change location fix acquired", {
|
|
accuracy: location?.coords?.accuracy,
|
|
latitude: location?.coords?.latitude,
|
|
longitude: location?.coords?.longitude,
|
|
timestamp: location?.timestamp,
|
|
});
|
|
|
|
// NOTE: This is a non-persisted fix; it updates UI only.
|
|
// We intentionally do not trigger sync here to avoid network activity
|
|
// without a movement-triggered persisted record.
|
|
lastAuthFixAt = Date.now();
|
|
} catch (error) {
|
|
locationLogger.warn("Auth-change location fix failed", {
|
|
error: error?.message,
|
|
code: error?.code,
|
|
stack: error?.stack,
|
|
});
|
|
} finally {
|
|
authFixDebounceTimerId = null;
|
|
authFixInFlight = null;
|
|
}
|
|
});
|
|
|
|
return authFixInFlight;
|
|
};
|
|
|
|
const computeHasOwnOpenAlert = () => {
|
|
try {
|
|
const { userId } = getSessionState();
|
|
const { alertingList } = getAlertState();
|
|
if (!userId || !Array.isArray(alertingList)) return false;
|
|
return alertingList.some(
|
|
({ oneAlert }) =>
|
|
oneAlert?.state === "open" && oneAlert?.userId === userId,
|
|
);
|
|
} catch (e) {
|
|
locationLogger.warn("Failed to compute active-alert state", {
|
|
error: e?.message,
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const applyProfile = async (profileName) => {
|
|
if (!authReady) {
|
|
// We only apply profile once auth headers are configured.
|
|
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 });
|
|
return;
|
|
}
|
|
|
|
locationLogger.info("Applying tracking profile", {
|
|
profileName,
|
|
desiredAccuracy: profile?.geolocation?.desiredAccuracy,
|
|
distanceFilter: profile?.geolocation?.distanceFilter,
|
|
heartbeatInterval: profile?.app?.heartbeatInterval,
|
|
});
|
|
|
|
try {
|
|
await BackgroundGeolocation.setConfig(profile);
|
|
|
|
// Motion state strategy:
|
|
// - ACTIVE: force moving to begin aggressive tracking immediately.
|
|
// - IDLE: ensure we are not stuck in moving mode from a prior ACTIVE session.
|
|
// We explicitly exit moving mode to avoid periodic drift-generated locations
|
|
// being produced + uploaded while the user is stationary (reported on Android).
|
|
// After that, let the SDK's motion detection manage moving/stationary
|
|
// transitions so we still get distance-based updates when the user truly moves.
|
|
if (profileName === "active") {
|
|
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);
|
|
}
|
|
|
|
// Guarantee a rapid first fix for ACTIVE: request a high-accuracy persisted location
|
|
// immediately after entering moving mode. This is preferred over relying solely on
|
|
// motion-detection / distanceFilter to produce the first point.
|
|
try {
|
|
const beforeFix = Date.now();
|
|
const fix = await getCurrentPositionWithDiagnostics(
|
|
{
|
|
samples: 3,
|
|
timeout: 30,
|
|
maximumAge: 0,
|
|
desiredAccuracy: 10,
|
|
extras: {
|
|
active_profile_enter: true,
|
|
},
|
|
},
|
|
{ reason: "active_profile_enter", persist: true },
|
|
);
|
|
|
|
if (!shouldAllowPersistedFix(fix)) {
|
|
locationLogger.info(
|
|
"ACTIVE immediate persisted fix ignored due to poor accuracy",
|
|
{
|
|
accuracy: fix?.coords?.accuracy,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
locationLogger.info("ACTIVE immediate fix acquired", {
|
|
ms: Date.now() - beforeFix,
|
|
accuracy: fix?.coords?.accuracy,
|
|
latitude: fix?.coords?.latitude,
|
|
longitude: fix?.coords?.longitude,
|
|
timestamp: fix?.timestamp,
|
|
});
|
|
} catch (error) {
|
|
locationLogger.warn("ACTIVE immediate fix failed", {
|
|
error: error?.message,
|
|
code: error?.code,
|
|
stack: error?.stack,
|
|
});
|
|
}
|
|
} else {
|
|
const state = await BackgroundGeolocation.getState();
|
|
if (state?.isMoving) {
|
|
await BackgroundGeolocation.changePace(false);
|
|
}
|
|
}
|
|
|
|
currentProfile = profileName;
|
|
|
|
// 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", {
|
|
profileName,
|
|
ms: Date.now() - applyStartedAt,
|
|
enabled: state?.enabled,
|
|
isMoving: state?.isMoving,
|
|
trackingMode: state?.trackingMode,
|
|
});
|
|
} catch (e) {
|
|
locationLogger.debug("Tracking profile applied (state unavailable)", {
|
|
profileName,
|
|
ms: Date.now() - applyStartedAt,
|
|
error: e?.message,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
locationLogger.error("Failed to apply tracking profile", {
|
|
profileName,
|
|
error: error?.message,
|
|
stack: error?.stack,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Log the geolocation sync URL for debugging
|
|
locationLogger.info("Geolocation sync URL configuration", {
|
|
url: env.GEOLOC_SYNC_URL,
|
|
isStaging: env.IS_STAGING,
|
|
});
|
|
|
|
// Handle auth function - no throttling or cooldown
|
|
async function handleAuth(userToken) {
|
|
// Defensive: ensure `.ready()` is resolved before any API call.
|
|
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
|
|
|
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;
|
|
try {
|
|
currentSessionUserId = getSessionState()?.userId ?? null;
|
|
} catch (e) {
|
|
currentSessionUserId = null;
|
|
}
|
|
if (!userToken && !currentSessionUserId) {
|
|
// Pre-login mode: keep tracking enabled but disable uploads.
|
|
// Also applies to logout: keep tracking on (per product requirement: track all the time),
|
|
// but stop sending anything to server without auth.
|
|
locationLogger.info(
|
|
"No auth token: disabling BGGeo uploads (keeping tracking on)",
|
|
);
|
|
|
|
try {
|
|
await BackgroundGeolocation.setConfig({
|
|
http: {
|
|
url: "",
|
|
autoSync: false,
|
|
headers: {},
|
|
},
|
|
});
|
|
didDisableUploadsForAnonymous = true;
|
|
didSyncAfterAuth = false;
|
|
} catch (e) {
|
|
locationLogger.warn("Failed to disable BGGeo uploads (anonymous)", {
|
|
error: e?.message,
|
|
});
|
|
}
|
|
|
|
const state = await BackgroundGeolocation.getState();
|
|
if (!state.enabled) {
|
|
try {
|
|
await BackgroundGeolocation.start();
|
|
locationLogger.debug("Location tracking started in anonymous mode");
|
|
} catch (error) {
|
|
locationLogger.error(
|
|
"Failed to start location tracking in anonymous mode",
|
|
{
|
|
error: error.message,
|
|
stack: error.stack,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// Cleanup subscriptions when logged out.
|
|
try {
|
|
stopAlertSubscription && stopAlertSubscription();
|
|
stopSessionSubscription && stopSessionSubscription();
|
|
} finally {
|
|
stopAlertSubscription = null;
|
|
stopSessionSubscription = null;
|
|
}
|
|
|
|
authReady = false;
|
|
currentProfile = null;
|
|
|
|
// Ensure server/debug can see the app lifecycle context even pre-auth.
|
|
updateTrackingContextExtras("auth:anonymous");
|
|
|
|
if (authFixDebounceTimerId) {
|
|
clearTimeout(authFixDebounceTimerId);
|
|
authFixDebounceTimerId = null;
|
|
}
|
|
authFixInFlight = null;
|
|
|
|
// Still request a one-time persisted fix at startup in anonymous mode so we have
|
|
// something to flush immediately after auth.
|
|
if (!didRequestStartupFix) {
|
|
didRequestStartupFix = true;
|
|
startupFixInFlight = requestStartupPersistedFix();
|
|
}
|
|
|
|
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}`,
|
|
},
|
|
},
|
|
});
|
|
|
|
authReady = true;
|
|
|
|
updateTrackingContextExtras("auth:ready");
|
|
|
|
// Log the authorization header that was set
|
|
locationLogger.debug(
|
|
"Set Authorization header for background geolocation",
|
|
{
|
|
headerSet: true,
|
|
tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null,
|
|
},
|
|
);
|
|
|
|
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");
|
|
try {
|
|
await BackgroundGeolocation.start();
|
|
locationLogger.debug("Location tracking started successfully");
|
|
} catch (error) {
|
|
locationLogger.error("Failed to start location tracking", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
});
|
|
}
|
|
}
|
|
|
|
// If identity has changed (including first login), force a persisted fix for this identity
|
|
// and sync immediately so the new identity has an immediate first geopoint.
|
|
if (currentSessionUserId && currentSessionUserId !== lastSessionUserId) {
|
|
const reason = lastSessionUserId ? "user-switch" : "first-login";
|
|
locationLogger.info("Identity change detected", {
|
|
reason,
|
|
from: lastSessionUserId,
|
|
to: currentSessionUserId,
|
|
});
|
|
lastSessionUserId = currentSessionUserId;
|
|
await requestIdentityPersistedFixAndSync({
|
|
reason,
|
|
userId: currentSessionUserId,
|
|
});
|
|
}
|
|
|
|
// If we were previously in anonymous mode, flush any queued persisted locations now.
|
|
if (didDisableUploadsForAnonymous && !didSyncAfterAuth) {
|
|
try {
|
|
if (startupFixInFlight) {
|
|
await startupFixInFlight;
|
|
}
|
|
const ok = await safeSync("pre-auth-flush");
|
|
didSyncAfterAuth = ok;
|
|
} catch (e) {
|
|
locationLogger.warn("Pre-auth flush failed", {
|
|
error: e?.message,
|
|
stack: e?.stack,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Always request a fresh UI-only fix on any token update.
|
|
scheduleAuthFreshFix();
|
|
|
|
// Request a single fresh location-fix on each app launch when tracking is enabled.
|
|
// - We do this only after auth headers are configured so the persisted point can sync.
|
|
// - We do NOT force moving mode.
|
|
// - We only persist in ACTIVE.
|
|
if (!didRequestStartupFix) {
|
|
didRequestStartupFix = true;
|
|
// Profile isn't applied yet. We'll request the startup fix after we apply the profile.
|
|
} else if (authFixInFlight) {
|
|
// Avoid concurrent fix calls if auth updates race.
|
|
await authFixInFlight;
|
|
}
|
|
|
|
// Ensure we are NOT forcing "moving" mode by default.
|
|
// Default profile is idle unless an active alert requires higher accuracy.
|
|
const shouldBeActive = computeHasOwnOpenAlert();
|
|
await applyProfile(shouldBeActive ? "active" : "idle");
|
|
|
|
// Now that profile is applied, execute the persisted startup fix if needed.
|
|
if (didRequestStartupFix && !startupFixInFlight) {
|
|
startupFixInFlight = requestStartupPersistedFix();
|
|
}
|
|
|
|
// Subscribe to changes that may require switching profiles.
|
|
if (!stopSessionSubscription) {
|
|
stopSessionSubscription = subscribeSessionState(
|
|
(s) => s?.userId,
|
|
() => {
|
|
const active = computeHasOwnOpenAlert();
|
|
applyProfile(active ? "active" : "idle");
|
|
},
|
|
);
|
|
}
|
|
if (!stopAlertSubscription) {
|
|
stopAlertSubscription = subscribeAlertState(
|
|
(s) => s?.alertingList,
|
|
() => {
|
|
const active = computeHasOwnOpenAlert();
|
|
applyProfile(active ? "active" : "idle");
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
// Ignore sampling locations (eg, emitted during getCurrentPosition) to avoid UI/storage churn.
|
|
// The final persisted location will arrive with sample=false.
|
|
if (location.sample) return;
|
|
|
|
// Quality gate (UI-only): if accuracy is very poor, ignore for UI/state.
|
|
// Do NOT delete the record here; native uploads may happen while JS is suspended.
|
|
if (!shouldUseLocationForUi(location)) {
|
|
locationLogger.info("Ignoring poor-accuracy location", {
|
|
accuracy: location?.coords?.accuracy,
|
|
uuid: location?.uuid,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
location.coords &&
|
|
location.coords.latitude &&
|
|
location.coords.longitude
|
|
) {
|
|
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,
|
|
code: error?.code,
|
|
});
|
|
},
|
|
onHttp: async (response) => {
|
|
// Log success/failure for visibility into token expiry, server errors, etc.
|
|
locationLogger.debug("HTTP response received", {
|
|
success: response?.success,
|
|
status: response?.status,
|
|
responseText: response?.responseText,
|
|
});
|
|
|
|
// Instrumentation: when we see periodic HTTP without a corresponding location event,
|
|
// we want to know if BGGeo is retrying an upload queue or flushing new records.
|
|
// This helps diagnose reports like "server receives updates every ~5 minutes while stationary".
|
|
try {
|
|
const [state, count] = await Promise.all([
|
|
BackgroundGeolocation.getState(),
|
|
BackgroundGeolocation.getCount(),
|
|
]);
|
|
locationLogger.debug("HTTP instrumentation", {
|
|
enabled: state?.enabled,
|
|
isMoving: state?.isMoving,
|
|
trackingMode: state?.trackingMode,
|
|
schedulerEnabled: state?.schedulerEnabled,
|
|
pendingCount: count,
|
|
});
|
|
} catch (e) {
|
|
locationLogger.warn("Failed HTTP instrumentation", {
|
|
error: e?.message,
|
|
});
|
|
}
|
|
},
|
|
onHeartbeat: (event) => {
|
|
// If heartbeat is configured, it can trigger sync attempts even without new locations.
|
|
locationLogger.info("Heartbeat", {
|
|
enabled: event?.state?.enabled,
|
|
isMoving: event?.state?.isMoving,
|
|
location: event?.location?.coords,
|
|
});
|
|
},
|
|
onSchedule: (event) => {
|
|
locationLogger.info("Schedule", {
|
|
state: event?.state,
|
|
});
|
|
},
|
|
onMotionChange: (event) => {
|
|
// 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.
|
|
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,
|
|
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.
|
|
//
|
|
// IMPORTANT: Restrict this to ACTIVE tracking only. On Android, motion detection can
|
|
// produce false-positive moving transitions while the device is stationary (screen-off),
|
|
// which would otherwise trigger unwanted background uploads.
|
|
// Cooldown to avoid repeated work due to motion jitter.
|
|
if (event?.isMoving && authReady && currentProfile === "active") {
|
|
const now = Date.now();
|
|
if (now - lastMovingEdgeAt >= MOVING_EDGE_COOLDOWN_MS) {
|
|
lastMovingEdgeAt = now;
|
|
(async () => {
|
|
try {
|
|
const fix = await getCurrentPositionWithDiagnostics(
|
|
{
|
|
samples: 1,
|
|
timeout: 30,
|
|
maximumAge: 0,
|
|
desiredAccuracy: 50,
|
|
extras: {
|
|
moving_edge: true,
|
|
},
|
|
},
|
|
{ reason: "moving_edge", persist: true },
|
|
);
|
|
|
|
if (!shouldAllowPersistedFix(fix)) {
|
|
locationLogger.info(
|
|
"Moving-edge persisted fix ignored due to poor accuracy",
|
|
{
|
|
accuracy: fix?.coords?.accuracy,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
locationLogger.info("Moving-edge fix acquired", {
|
|
accuracy: fix?.coords?.accuracy,
|
|
latitude: fix?.coords?.latitude,
|
|
longitude: fix?.coords?.longitude,
|
|
timestamp: fix?.timestamp,
|
|
});
|
|
} catch (e) {
|
|
locationLogger.warn("Moving-edge fix failed", {
|
|
error: e?.message,
|
|
stack: e?.stack,
|
|
});
|
|
}
|
|
await safeSync("moving-edge");
|
|
})();
|
|
}
|
|
}
|
|
|
|
// 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", {
|
|
status: event?.status,
|
|
enabled: event?.enabled,
|
|
network: event?.network,
|
|
gps: event?.gps,
|
|
accuracyAuthorization: event?.accuracyAuthorization,
|
|
});
|
|
},
|
|
onConnectivityChange: (event) => {
|
|
locationLogger.info("Connectivity change", {
|
|
connected: event?.connected,
|
|
});
|
|
},
|
|
onEnabledChange: (enabled) => {
|
|
locationLogger.info("Enabled change", { enabled });
|
|
},
|
|
});
|
|
|
|
try {
|
|
locationLogger.info("Initializing background geolocation");
|
|
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
|
|
|
// Tag app foreground/background transitions so we can reason about uploads & locations.
|
|
// Note: there is no reliable JS signal for "terminated" when `enableHeadless:false`.
|
|
try {
|
|
const sub = AppState.addEventListener("change", (next) => {
|
|
const prev = appState;
|
|
appState = next;
|
|
locationLogger.info("AppState changed", { from: prev, to: next });
|
|
updateTrackingContextExtras("app_state");
|
|
});
|
|
// Keep the subscription alive for the app lifetime.
|
|
// (trackLocation is a singleton init; no teardown is expected.)
|
|
void sub;
|
|
} catch (e) {
|
|
locationLogger.debug("Failed to register AppState listener", {
|
|
error: e?.message,
|
|
});
|
|
}
|
|
|
|
// Ensure critical config cannot drift due to persisted plugin state.
|
|
// (We intentionally keep auth headers separate and set them in handleAuth.)
|
|
try {
|
|
await BackgroundGeolocation.setConfig(BASE_GEOLOCATION_INVARIANTS);
|
|
} catch (e) {
|
|
locationLogger.warn("Failed to apply BGGeo base invariants", {
|
|
error: e?.message,
|
|
stack: e?.stack,
|
|
});
|
|
}
|
|
|
|
// Initial extras snapshot (even before auth) for observability.
|
|
updateTrackingContextExtras("startup");
|
|
|
|
// Only set the permission state if we already have the permission
|
|
const state = await BackgroundGeolocation.getState();
|
|
locationLogger.debug("Background geolocation state", {
|
|
enabled: state.enabled,
|
|
trackingMode: state.trackingMode,
|
|
isMoving: state.isMoving,
|
|
schedulerEnabled: state.schedulerEnabled,
|
|
});
|
|
|
|
if (state.enabled) {
|
|
locationLogger.info("Background location permission confirmed");
|
|
permissionsActions.setLocationBackground(true);
|
|
} else {
|
|
locationLogger.warn(
|
|
"Background location not enabled in geolocation state",
|
|
);
|
|
}
|
|
|
|
// if (LOCAL_DEV) {
|
|
// // fixing issue on android emulator (which doesn't have accelerometer or gyroscope) by manually enabling location updates
|
|
// setInterval(
|
|
// () => {
|
|
// BackgroundGeolocation.changePace(true);
|
|
// },
|
|
// 30 * 60 * 1000,
|
|
// );
|
|
// }
|
|
} catch (error) {
|
|
locationLogger.error("Location tracking initialization failed", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
code: error.code,
|
|
});
|
|
}
|
|
const { userToken } = getAuthState();
|
|
locationLogger.debug("Setting up auth state subscription");
|
|
subscribeAuthState(({ userToken }) => userToken, handleAuth);
|
|
locationLogger.debug("Performing initial auth handling");
|
|
handleAuth(userToken);
|
|
|
|
// Initialize emulator mode only in dev/staging to avoid accidental production battery drain.
|
|
if (__DEV__ || env.IS_STAGING) {
|
|
initEmulatorMode();
|
|
}
|
|
})();
|
|
|
|
return trackLocationStartPromise;
|
|
}
|