From 88fbd72e5144c36c01d9e047ea43faf69014c9f6 Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 8 Feb 2026 10:36:20 +0100 Subject: [PATCH] fix(track-location): try 11 --- docs/location-tracking-qa.md | 35 +- src/location/backgroundGeolocationConfig.js | 29 +- src/location/backgroundGeolocationService.js | 9 +- .../bggeo/createTrackingController.js | 560 +++++++ src/location/bggeo/diagnostics.js | 75 + src/location/emulatorService.js | 12 + src/location/index.js | 69 +- src/location/trackLocation.js | 1393 +---------------- src/scenes/Developer/index.js | 107 +- 9 files changed, 839 insertions(+), 1450 deletions(-) create mode 100644 src/location/bggeo/createTrackingController.js create mode 100644 src/location/bggeo/diagnostics.js diff --git a/docs/location-tracking-qa.md b/docs/location-tracking-qa.md index 0f8e852..c995e7b 100644 --- a/docs/location-tracking-qa.md +++ b/docs/location-tracking-qa.md @@ -1,8 +1,9 @@ # Location tracking QA checklist Applies to the BackgroundGeolocation integration: -- [`trackLocation()`](src/location/trackLocation.js:34) -- [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:126) +- [`trackLocation()`](src/location/trackLocation.js:11) +- [`createTrackingController()`](src/location/bggeo/createTrackingController.js:1) +- [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:190) ## Goals @@ -20,12 +21,12 @@ Applies to the BackgroundGeolocation integration: - If the SDK later reports a real move (`onMotionChange(isMoving:true)`), JS may request **one** persisted fix as a fallback. - We intentionally do not rely on time-based updates. - ACTIVE uses `geolocation.distanceFilter: 25`. - - JS may request a persisted fix when entering ACTIVE (see [`applyProfile()`](src/location/trackLocation.js:351)). + - JS may request a persisted fix when entering ACTIVE (see [`applyProfile()`](src/location/bggeo/createTrackingController.js:170)). - Upload strategy is intentionally simple: - Keep only the latest persisted geopoint: `persistence.maxRecordsToPersist: 1`. - No batching / thresholds: `batchSync: false`, `autoSyncThreshold: 0`. - When authenticated, each persisted location should upload immediately via native HTTP (works while JS is suspended). - - Pre-auth: tracking may persist locally but `http.url` is empty so nothing is uploaded until auth is ready. + - Pre-auth: BGGeo tracking is disabled (do not start). UI-only location uses `expo-location`. - Stationary noise suppression: - Native accuracy gate for persisted/uploaded locations: `geolocation.filter.trackingAccuracyThreshold: 100`. @@ -129,7 +130,7 @@ Applies to the BackgroundGeolocation integration: | Platform | App state | Profile | Move | Expected signals | |---|---|---|---:|---| -| 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 | foreground | IDLE | ~250m | [`onMotionChange`](src/location/bggeo/createTrackingController.js:311) then [`onLocation`](src/location/bggeo/createTrackingController.js:286) (sample=false), then [`onHttp`](src/location/bggeo/createTrackingController.js:302) | | Android | background | IDLE | ~250m | same as above | | 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 | @@ -138,10 +139,9 @@ Applies to the BackgroundGeolocation integration: ## What to look for in logs -- App lifecycle tagging: [`updateTrackingContextExtras()`](src/location/trackLocation.js:63) should update `tracking_ctx.app_state` on AppState changes. - No time-based uploads: heartbeat is disabled (`heartbeatInterval: 0`). - Movement-only uploads: - - IDLE: look for `Motion change` (isMoving=true) and (in rare cases) `IDLE movement fallback fix`. + - IDLE: look for `Motion change` (isMoving=true). - ACTIVE distance threshold: `distanceFilter: 25` in [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:148). - Attribution for `getCurrentPosition`: @@ -154,13 +154,10 @@ Applies to the BackgroundGeolocation integration: ## Debugging tips -- Observe logs in app: - - `tracking_ctx` extras are updated on AppState changes and profile changes. - - See [`updateTrackingContextExtras()`](src/location/trackLocation.js:63). -- Correlate: - - `onLocation` events - - `onHttp` events - - pending queue (`BackgroundGeolocation.getCount()` in logs) +- Observe logs in app (dev/staging): + - `Motion change` edges + - `HTTP response` when uploads fail or in dev/staging + - pending queue (`BackgroundGeolocation.getCount()` via [`bggeoGetStatusSnapshot()`](src/location/bggeo/diagnostics.js:15)) ## Android-specific note (stationary-geofence EXIT loop) @@ -172,10 +169,12 @@ Mitigation applied: - Android IDLE disables `geolocation.stopOnStationary` (we do **not** rely on stationary-geofence mode in IDLE on Android). - See [`BASE_GEOLOCATION_CONFIG.geolocation.stopOnStationary`](src/location/backgroundGeolocationConfig.js:1) and [`TRACKING_PROFILES.idle.geolocation.stopOnStationary`](src/location/backgroundGeolocationConfig.js:1). -- Android IDLE uses `geolocation.useSignificantChangesOnly: true` to rely on OS-level significant movement events. - - See [`TRACKING_PROFILES.idle.geolocation.useSignificantChangesOnly`](src/location/backgroundGeolocationConfig.js:1). +- Android IDLE no longer uses `geolocation.useSignificantChangesOnly`. + - Reason: this mode can record only "several times/hour" and was observed to miss timely updates + after moving ~200–300m while the app is backgrounded on some devices. + - IDLE now relies on `distanceFilter: 200` plus native drift filtering. + - See [`TRACKING_PROFILES.idle`](src/location/backgroundGeolocationConfig.js:190). Diagnostics: -- `onGeofence` events are logged (identifier/action/accuracy + current BGGeo state) to confirm whether the SDK is emitting stationary geofence events. - - See [`setBackgroundGeolocationEventHandlers({ onGeofence })`](src/location/trackLocation.js:1). +- `onGeofence` events are not explicitly logged anymore (we rely on motion/location/http + the in-app diagnostics helpers). diff --git a/src/location/backgroundGeolocationConfig.js b/src/location/backgroundGeolocationConfig.js index 67ee1ab..a50ebf9 100644 --- a/src/location/backgroundGeolocationConfig.js +++ b/src/location/backgroundGeolocationConfig.js @@ -4,6 +4,7 @@ import env from "~/env"; const LOCATION_ACCURACY_GATE_M = 100; const IS_ANDROID = Platform.OS === "android"; +const IS_DEBUG_LOGGING = __DEV__ || env.IS_STAGING; // Native filter to reduce GPS drift and suppress stationary jitter. // This is the primary mechanism to prevent unwanted persisted/uploaded points while the device @@ -41,12 +42,12 @@ export const BASE_GEOLOCATION_CONFIG = { // Logger config logger: { - // debug: true, - // Logging can become large and also adds overhead; keep verbose logs to dev/staging. - logLevel: - __DEV__ || env.IS_STAGING - ? BackgroundGeolocation.LogLevel.Verbose - : BackgroundGeolocation.LogLevel.Error, + // Logging can become large and also adds overhead. + // Keep verbose logs to dev/staging. + debug: IS_DEBUG_LOGGING, + logLevel: IS_DEBUG_LOGGING + ? BackgroundGeolocation.LogLevel.Verbose + : BackgroundGeolocation.LogLevel.Error, }, // Geolocation config @@ -200,7 +201,10 @@ export const TRACKING_PROFILES = { // Android IDLE: rely on OS-level significant movement only. // This avoids periodic wakeups/records due to poor fused-location fixes while the phone // is stationary (screen-off / locked scenarios). - useSignificantChangesOnly: IS_ANDROID, + // However, this mode can also delay updates for many minutes ("several times / hour"), + // resulting in missed updates after moving ~200-300m while backgrounded. + // Product requirement prefers reliability of distance-based updates in IDLE. + useSignificantChangesOnly: false, // QA helper: allow easier validation in dev/staging while keeping production at 200m. stationaryRadius: 200, @@ -212,8 +216,17 @@ export const TRACKING_PROFILES = { activity: { // Android-only: reduce false-positive motion triggers due to screen-on/unlock. // (This is ignored on iOS.) - motionTriggerDelay: 300000, + // 5 minutes was observed to be too aggressive and can prevent a moving transition during + // normal short trips, leading to "moving but no updates". + motionTriggerDelay: IS_ANDROID ? 60000 : 0, }, + + // Android-only: require meaningful motion-activity transitions before engaging moving-state. + // This helps avoid false positives while still allowing IDLE distance-based updates. + // (Ignored on iOS.) + triggerActivities: IS_ANDROID + ? "in_vehicle,on_foot,waking,running,walking,cycling" + : undefined, }, active: { geolocation: { diff --git a/src/location/backgroundGeolocationService.js b/src/location/backgroundGeolocationService.js index 4efd590..53ec321 100644 --- a/src/location/backgroundGeolocationService.js +++ b/src/location/backgroundGeolocationService.js @@ -15,6 +15,12 @@ let lastReadyState = null; let subscriptions = []; let handlersSignature = null; +export function clearBackgroundGeolocationEventHandlers() { + subscriptions.forEach((s) => s?.remove?.()); + subscriptions = []; + handlersSignature = null; +} + export async function ensureBackgroundGeolocationReady( config = BASE_GEOLOCATION_CONFIG, ) { @@ -81,8 +87,7 @@ export function setBackgroundGeolocationEventHandlers({ return; } - subscriptions.forEach((s) => s?.remove?.()); - subscriptions = []; + clearBackgroundGeolocationEventHandlers(); if (onLocation) { subscriptions.push( diff --git a/src/location/bggeo/createTrackingController.js b/src/location/bggeo/createTrackingController.js new file mode 100644 index 0000000..b37f63f --- /dev/null +++ b/src/location/bggeo/createTrackingController.js @@ -0,0 +1,560 @@ +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 env from "~/env"; + +import { + getAlertState, + getAuthState, + getSessionState, + subscribeAlertState, + subscribeSessionState, +} from "~/stores"; + +import setLocationState from "~/location/setLocationState"; +import { storeLocation } from "~/location/storage"; + +import { + BASE_GEOLOCATION_CONFIG, + BASE_GEOLOCATION_INVARIANTS, + TRACKING_PROFILES, +} from "~/location/backgroundGeolocationConfig"; + +import buildBackgroundGeolocationSetConfigPayload from "~/location/buildBackgroundGeolocationSetConfigPayload"; + +import { + ensureBackgroundGeolocationReady, + clearBackgroundGeolocationEventHandlers, + setBackgroundGeolocationEventHandlers, +} from "~/location/backgroundGeolocationService"; + +// Correlation ID to differentiate multiple JS runtimes (eg full `Updates.reloadAsync()`). +const TRACKING_INSTANCE_ID = `${Date.now().toString(36)}-${Math.random() + .toString(16) + .slice(2, 8)}`; + +const MOVING_EDGE_COOLDOWN_MS = 5 * 60 * 1000; +const PERSISTED_ACCURACY_GATE_M = 100; +const UI_ACCURACY_GATE_M = 200; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const shouldAllowPersistedFix = (location) => { + const acc = location?.coords?.accuracy; + return !(typeof acc === "number" && acc > PERSISTED_ACCURACY_GATE_M); +}; + +const shouldUseLocationForUi = (location) => { + const acc = location?.coords?.accuracy; + return !(typeof acc === "number" && acc > UI_ACCURACY_GATE_M); +}; + +/** + * Creates a BGGeo tracking controller. + * + * Policy constraints enforced: + * - Pre-auth: BGGeo must remain stopped (no tracking). We also avoid calling `.ready()` pre-auth. + * - Authenticated: BGGeo configured with `http.url` + `Authorization` header + `autoSync:true`. + * - No time-based polling (heartbeat remains disabled). + */ +export function createTrackingController() { + const log = createLogger({ + module: BACKGROUND_SCOPES.GEOLOCATION, + feature: "tracking-controller", + }); + + /** @type {ReturnType | null} */ + let appStateSub = null; + let appState = AppState.currentState; + + let currentProfile = null; + let authReady = false; + + // Vendor constraint: never call BGGeo APIs before `.ready()`. + // This flag tracks whether we've successfully executed `.ready()` in this JS runtime. + let didReady = false; + + let stopAlertSubscription = null; + let stopSessionSubscription = null; + + let lastMovingEdgeAt = 0; + + // Track identity so we can force a first geopoint when the effective user changes. + let lastSessionUserId = null; + + 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) { + log.warn("Failed to compute active-alert state", { error: e?.message }); + return false; + } + }; + + 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(), + ]); + + log.info("Attempting BGGeo sync", { + reason, + attempt, + enabled: state?.enabled, + isMoving: state?.isMoving, + trackingMode: state?.trackingMode, + pendingBefore, + }); + + const records = await BackgroundGeolocation.sync(); + const pendingAfter = await BackgroundGeolocation.getCount(); + + log.info("BGGeo sync success", { + reason, + attempt, + synced: records?.length, + pendingAfter, + }); + return true; + } catch (e) { + log.warn("BGGeo sync failed", { + reason, + attempt, + error: e?.message, + stack: e?.stack, + }); + await sleep(attempt * 1000); + } + } + return false; + }; + + const getCurrentPositionWithDiagnostics = async ( + options, + { reason, persist }, + ) => { + const opts = { + ...options, + persist, + extras: { + ...(options?.extras || {}), + req_reason: reason, + req_persist: !!persist, + req_at: new Date().toISOString(), + req_app_state: appState, + req_profile: currentProfile, + }, + }; + log.debug("Requesting getCurrentPosition", { + reason, + persist: !!persist, + desiredAccuracy: opts?.desiredAccuracy, + samples: opts?.samples, + maximumAge: opts?.maximumAge, + timeout: opts?.timeout, + }); + return BackgroundGeolocation.getCurrentPosition(opts); + }; + + const applyProfile = async (profileName) => { + if (!authReady) return; + if (currentProfile === profileName) { + // Ensure we're not stuck in geofence-only mode. + try { + const s = await BackgroundGeolocation.getState(); + if (s?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + } catch { + // ignore + } + return; + } + + const profile = TRACKING_PROFILES[profileName]; + if (!profile) { + log.warn("Unknown tracking profile", { profileName }); + return; + } + + try { + const payload = await buildBackgroundGeolocationSetConfigPayload(profile); + await BackgroundGeolocation.setConfig(payload); + + const state = await BackgroundGeolocation.getState(); + if (state?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + + if (profileName === "active") { + if (!state?.isMoving) { + await BackgroundGeolocation.changePace(true); + } + + // ACTIVE: request one immediate persisted fix to ensure first point reaches server quickly. + try { + 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)) { + log.info("ACTIVE immediate persisted fix ignored (poor accuracy)", { + accuracy: fix?.coords?.accuracy, + }); + } + lastMovingEdgeAt = Date.now(); + } catch (e) { + log.warn("ACTIVE immediate fix failed", { + error: e?.message, + stack: e?.stack, + }); + } + } else { + // IDLE: explicitly exit moving mode if needed. + if (state?.isMoving) { + await BackgroundGeolocation.changePace(false); + } + } + + currentProfile = profileName; + + log.info("Tracking profile applied", { + profileName, + instanceId: TRACKING_INSTANCE_ID, + }); + } catch (e) { + log.error("Failed to apply tracking profile", { + profileName, + error: e?.message, + stack: e?.stack, + }); + } + }; + + const subscribeProfileInputs = () => { + if (stopSessionSubscription || stopAlertSubscription) return; + + stopSessionSubscription = subscribeSessionState( + (s) => s?.userId, + () => { + const active = computeHasOwnOpenAlert(); + applyProfile(active ? "active" : "idle"); + }, + ); + stopAlertSubscription = subscribeAlertState( + (s) => s?.alertingList, + () => { + const active = computeHasOwnOpenAlert(); + applyProfile(active ? "active" : "idle"); + }, + ); + }; + + const unsubscribeProfileInputs = () => { + try { + stopAlertSubscription && stopAlertSubscription(); + } finally { + stopAlertSubscription = null; + } + try { + stopSessionSubscription && stopSessionSubscription(); + } finally { + stopSessionSubscription = null; + } + }; + + const registerEventHandlersOnceReady = () => { + setBackgroundGeolocationEventHandlers({ + onLocation: async (location) => { + // Ignore sampling locations (eg, emitted during getCurrentPosition). + if (location?.sample) return; + if (!shouldUseLocationForUi(location)) return; + + if (location?.coords?.latitude && location?.coords?.longitude) { + setLocationState(location.coords); + storeLocation(location.coords, location.timestamp); + } + }, + onLocationError: (error) => { + log.warn("Location error", { + error: error?.message, + code: error?.code, + }); + }, + onHttp: (response) => { + // Keep minimal; noisy logs only in dev/staging. + if (!response?.success || __DEV__ || env.IS_STAGING) { + log.debug("HTTP response", { + success: response?.success, + status: response?.status, + }); + } + }, + onMotionChange: (event) => { + log.info("Motion change", { + instanceId: TRACKING_INSTANCE_ID, + profile: currentProfile, + appState, + authReady, + isMoving: event?.isMoving, + accuracy: event?.location?.coords?.accuracy, + speed: event?.location?.coords?.speed, + }); + + // ACTIVE only: on moving edge, force one persisted fix + sync (cooldown). + if (event?.isMoving && authReady && currentProfile === "active") { + const now = Date.now(); + if (now - lastMovingEdgeAt < MOVING_EDGE_COOLDOWN_MS) return; + 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)) { + log.info("Moving-edge persisted fix ignored (poor accuracy)", { + accuracy: fix?.coords?.accuracy, + }); + return; + } + } catch (e) { + log.warn("Moving-edge fix failed", { + error: e?.message, + stack: e?.stack, + }); + } + await safeSync("moving-edge"); + })(); + } + }, + onProviderChange: (event) => { + log.info("Provider change", { + status: event?.status, + enabled: event?.enabled, + network: event?.network, + gps: event?.gps, + accuracyAuthorization: event?.accuracyAuthorization, + }); + }, + }); + }; + + const ensureReadyAndApplyInvariants = async () => { + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + didReady = true; + + // Ensure critical config cannot drift due to persisted plugin state. + // (We intentionally keep auth headers separate and set them in handleAuthToken.) + const payload = await buildBackgroundGeolocationSetConfigPayload( + BASE_GEOLOCATION_INVARIANTS, + ); + await BackgroundGeolocation.setConfig(payload); + + registerEventHandlersOnceReady(); + }; + + const configureUploadsForAuth = async (token) => { + const payload = await buildBackgroundGeolocationSetConfigPayload({ + http: { + url: env.GEOLOC_SYNC_URL, + autoSync: true, + batchSync: false, + autoSyncThreshold: 0, + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }); + await BackgroundGeolocation.setConfig(payload); + }; + + const disableUploads = async () => { + const payload = await buildBackgroundGeolocationSetConfigPayload({ + http: { + url: "", + autoSync: false, + batchSync: false, + autoSyncThreshold: 0, + headers: {}, + }, + }); + await BackgroundGeolocation.setConfig(payload); + }; + + const ensureStarted = async () => { + const state = await BackgroundGeolocation.getState(); + if (!state?.enabled) { + await BackgroundGeolocation.start(); + } + + // Extra guard against geofence-only mode. + const s2 = await BackgroundGeolocation.getState(); + if (s2?.trackingMode === 0) { + await BackgroundGeolocation.start(); + } + }; + + const stopAndDetach = async () => { + try { + // Stop native service first (policy: no tracking while logged-out). + if (didReady) { + await BackgroundGeolocation.stop(); + } + } catch (e) { + log.debug("BGGeo stop failed (ignored)", { error: e?.message }); + } + + unsubscribeProfileInputs(); + clearBackgroundGeolocationEventHandlers(); + authReady = false; + currentProfile = null; + lastSessionUserId = null; + }; + + const handleAuthToken = async (token) => { + const sessionUserId = (() => { + try { + return getSessionState()?.userId ?? null; + } catch { + return null; + } + })(); + + if (!token || !sessionUserId) { + // Pre-auth policy: BGGeo must remain stopped. + log.info("No auth: ensuring BGGeo is stopped", { + hasToken: !!token, + hasSessionUserId: !!sessionUserId, + instanceId: TRACKING_INSTANCE_ID, + }); + + // Safety net: if BGGeo was previously enabled (eg user logs out, or a prior run left + // tracking enabled), remove upload credentials and stop native tracking. + // + // NOTE: This calls `.ready()` to comply with vendor rules, but does NOT start tracking. + try { + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + didReady = true; + await disableUploads(); + } catch (e) { + log.debug("Failed to ready/disable uploads during logout", { + error: e?.message, + }); + } + + await stopAndDetach(); + return; + } + + // Authenticated path. + log.info("Auth ready: configuring and starting BGGeo", { + instanceId: TRACKING_INSTANCE_ID, + }); + + await ensureReadyAndApplyInvariants(); + await configureUploadsForAuth(token); + + authReady = true; + + await ensureStarted(); + + // Identity change: force a persisted fix + sync for a fast first point. + if (sessionUserId !== lastSessionUserId) { + const reason = lastSessionUserId ? "user-switch" : "first-login"; + lastSessionUserId = sessionUserId; + try { + const fix = await getCurrentPositionWithDiagnostics( + { + samples: 1, + timeout: 30, + maximumAge: 0, + desiredAccuracy: 50, + extras: { + identity_fix: true, + identity_reason: reason, + session_user_id: sessionUserId, + }, + }, + { reason: `identity_fix:${reason}`, persist: true }, + ); + + if (!shouldAllowPersistedFix(fix)) { + log.info("Identity persisted fix ignored (poor accuracy)", { + accuracy: fix?.coords?.accuracy, + }); + } + } catch (e) { + log.warn("Identity persisted fix failed", { + error: e?.message, + stack: e?.stack, + }); + } + + await safeSync(`identity-fix:${reason}`); + } + + // Apply the right profile and subscribe to future changes. + await applyProfile(computeHasOwnOpenAlert() ? "active" : "idle"); + subscribeProfileInputs(); + }; + + const init = async () => { + log.info("Tracking controller init", { + instanceId: TRACKING_INSTANCE_ID, + appState, + }); + + // AppState listener does not call BGGeo; safe pre-auth. + try { + appStateSub = AppState.addEventListener("change", (next) => { + appState = next; + }); + } catch (e) { + log.debug("Failed to register AppState listener", { error: e?.message }); + } + + // Note: we intentionally do NOT call `.ready()` here (pre-auth policy). + }; + + const destroy = async () => { + try { + appStateSub?.remove?.(); + } finally { + appStateSub = null; + } + await stopAndDetach(); + }; + + return { + init, + destroy, + handleAuthToken, + }; +} diff --git a/src/location/bggeo/diagnostics.js b/src/location/bggeo/diagnostics.js new file mode 100644 index 0000000..a634dd7 --- /dev/null +++ b/src/location/bggeo/diagnostics.js @@ -0,0 +1,75 @@ +import BackgroundGeolocation from "react-native-background-geolocation"; + +import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; +import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; + +/** + * BGGeo diagnostics helpers. + * + * Intentionally lives outside UI code so we don't scatter direct BGGeo calls. + * + * NOTE: Calling these will execute `.ready()` (vendor requirement), but they do not start + * tracking by themselves. + */ + +export async function bggeoGetStatusSnapshot() { + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + const [state, count] = await Promise.all([ + BackgroundGeolocation.getState(), + BackgroundGeolocation.getCount(), + ]); + + return { + enabled: !!state?.enabled, + isMoving: !!state?.isMoving, + trackingMode: state?.trackingMode ?? null, + schedulerEnabled: !!state?.schedulerEnabled, + pending: count ?? null, + }; +} + +export async function bggeoSyncNow() { + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + const pendingBefore = await BackgroundGeolocation.getCount(); + const records = await BackgroundGeolocation.sync(); + const pendingAfter = await BackgroundGeolocation.getCount(); + + return { + pendingBefore: pendingBefore ?? null, + synced: records?.length ?? 0, + pendingAfter: pendingAfter ?? null, + }; +} + +export async function bggeoGetDiagnosticsSnapshot() { + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + + const [state, count, locations] = await Promise.all([ + BackgroundGeolocation.getState(), + BackgroundGeolocation.getCount(), + BackgroundGeolocation.getLocations(), + ]); + + const last = + Array.isArray(locations) && locations.length + ? locations[locations.length - 1] + : null; + + return { + state: { + enabled: !!state?.enabled, + isMoving: !!state?.isMoving, + trackingMode: state?.trackingMode ?? null, + schedulerEnabled: !!state?.schedulerEnabled, + }, + pending: count ?? null, + lastLocation: last + ? { + latitude: last?.coords?.latitude ?? null, + longitude: last?.coords?.longitude ?? null, + accuracy: last?.coords?.accuracy ?? null, + timestamp: last?.timestamp ?? null, + } + : null, + }; +} diff --git a/src/location/emulatorService.js b/src/location/emulatorService.js index 490a8f2..dda97a2 100644 --- a/src/location/emulatorService.js +++ b/src/location/emulatorService.js @@ -4,6 +4,8 @@ import { STORAGE_KEYS } from "~/storage/storageKeys"; import { createLogger } from "~/lib/logger"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; +import { getAuthState } from "~/stores"; + import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; @@ -46,6 +48,16 @@ export const enableEmulatorMode = async () => { } try { + const { userToken } = getAuthState(); + if (!userToken) { + emulatorLogger.warn( + "Emulator mode requires authentication (BGGeo is disabled pre-auth)", + ); + isEmulatorModeEnabled = false; + await AsyncStorage.setItem(STORAGE_KEYS.EMULATOR_MODE_ENABLED, "false"); + return; + } + await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); // Call immediately once diff --git a/src/location/index.js b/src/location/index.js index 8ca9952..47d945e 100644 --- a/src/location/index.js +++ b/src/location/index.js @@ -1,5 +1,5 @@ -import BackgroundGeolocation from "react-native-background-geolocation"; import { Alert } from "react-native"; +import * as Location from "expo-location"; import { getLocationState } from "~/stores"; @@ -7,11 +7,6 @@ import openSettings from "~/lib/native/openSettings"; import setLocationState from "./setLocationState"; -import camelCaseKeys from "~/utils/string/camelCaseKeys"; - -import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; -import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; - const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1 second @@ -20,15 +15,11 @@ export async function getCurrentLocation() { while (retries < MAX_RETRIES) { try { - // Vendor requirement: never call APIs like getState/requestPermission/getCurrentPosition - // before `.ready()` has resolved. - await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + // UI-only location must NOT depend on BGGeo. + // Policy: pre-auth, BGGeo remains completely unused. - // Check for location permissions and services - const state = await BackgroundGeolocation.getState(); - - if (!state.enabled) { - // Prompt the user to enable location services manually + const servicesEnabled = await Location.hasServicesEnabledAsync(); + if (!servicesEnabled) { Alert.alert( "Services de localisation désactivés", "Veuillez activer les services de localisation pour utiliser cette fonctionnalité.", @@ -39,17 +30,16 @@ export async function getCurrentLocation() { ); return null; } - const authorizationStatus = - await BackgroundGeolocation.requestPermission(); - const isAuthorized = - authorizationStatus === - BackgroundGeolocation.AuthorizationStatus?.Always || - authorizationStatus === - BackgroundGeolocation.AuthorizationStatus?.WhenInUse; + const perm = await Location.getForegroundPermissionsAsync(); + let status = perm?.status; - if (!isAuthorized) { - // If unable to get permissions, provide a link to settings + if (status !== "granted") { + const req = await Location.requestForegroundPermissionsAsync(); + status = req?.status; + } + + if (status !== "granted") { Alert.alert( "Autorisation de localisation requise", "Veuillez accorder l'autorisation de localisation pour utiliser cette fonctionnalité.", @@ -61,18 +51,27 @@ export async function getCurrentLocation() { return null; } - // UI lookup: do not persist. Persisting can create a DB record and trigger - // native HTTP upload even if the user has not moved. - const location = await BackgroundGeolocation.getCurrentPosition({ - timeout: 30, - persist: false, - maximumAge: 5000, - desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High, - samples: 1, - }); - const coords = camelCaseKeys(location.coords); - setLocationState(coords); - return coords; + // Add a lightweight timeout wrapper to avoid hanging UI. + const TIMEOUT_MS = 30000; + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Location timeout")), TIMEOUT_MS), + ); + + const loc = await Promise.race([ + Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + mayShowUserSettingsDialog: false, + }), + timeout, + ]); + + const coords = loc?.coords; + if (coords) { + setLocationState(coords); + return coords; + } + + return null; } catch (error) { console.log( `Erreur lors de l'obtention de la position actuelle (tentative ${ diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 438a31f..274ba9c 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -1,1391 +1,74 @@ -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 { 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 buildBackgroundGeolocationSetConfigPayload from "~/location/buildBackgroundGeolocationSetConfigPayload"; -import { - ensureBackgroundGeolocationReady, - setBackgroundGeolocationEventHandlers, -} from "~/location/backgroundGeolocationService"; +import { createTrackingController } from "~/location/bggeo/createTrackingController"; 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)}`; +let controller = null; export default function trackLocation() { if (trackLocationStartPromise) return trackLocationStartPromise; trackLocationStartPromise = (async () => { - const locationLogger = createLogger({ + const log = createLogger({ module: BACKGROUND_SCOPES.GEOLOCATION, feature: "tracking", }); - locationLogger.info("trackLocation() starting", { - instanceId: TRACK_LOCATION_INSTANCE_ID, - appState: AppState.currentState, - }); + controller = createTrackingController(); + await controller.init(); - 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 experimented with `startGeofences()` + an app-managed exit geofence. - // That approach is now removed. - // Current design relies on the SDK's stop-detection + stationary geofence - // (`geolocation.stopOnStationary` + `geolocation.stationaryRadius`) because it is more - // reliable in background/locked scenarios. - - // Fallback: if the OS fails to deliver geofence EXIT while the phone is locked, allow - // exactly one persisted fix when we get strong evidence of movement (motion+activity). - 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. - // This is *not* a managed geofence anymore; it's a reference center for observability. - let lastEnsuredIdleReferenceAt = 0; - let lastIdleReferenceCenter = null; - let lastIdleReferenceCenterAccuracyM = null; - let lastIdleReferenceCenterTimestamp = null; - let lastIdleReferenceCenterSource = null; - - // A) Safeguard: when entering IDLE, ensure we have a reasonably accurate and recent - // reference point. This does NOT persist/upload; it only updates our stored last-known - // 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 - ) { - lastIdleReferenceCenter = { - latitude: storedCoords.latitude, - longitude: storedCoords.longitude, - }; - lastIdleReferenceCenterAccuracyM = storedAcc; - lastIdleReferenceCenterTimestamp = storedTs ?? null; - lastIdleReferenceCenterSource = "stored"; - lastEnsuredIdleReferenceAt = 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); - lastIdleReferenceCenter = { - latitude: fix.coords.latitude, - longitude: fix.coords.longitude, - }; - lastIdleReferenceCenterAccuracyM = - typeof fix.coords.accuracy === "number" - ? fix.coords.accuracy - : null; - lastIdleReferenceCenterTimestamp = fix.timestamp ?? null; - lastIdleReferenceCenterSource = "idle_reference_fix"; - lastEnsuredIdleReferenceAt = 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(); - const payload = await buildBackgroundGeolocationSetConfigPayload({ - 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 last known good reference center when entering IDLE. - idle_reference: { - center: lastIdleReferenceCenter, - center_accuracy_m: lastIdleReferenceCenterAccuracyM, - center_timestamp: lastIdleReferenceCenterTimestamp, - center_source: lastIdleReferenceCenterSource, - ensured_at: lastEnsuredIdleReferenceAt || null, - }, - at: new Date().toISOString(), - }, - }, - }, - }); - await BackgroundGeolocation.setConfig(payload); - } catch (e) { - // Non-fatal: extras are only for observability/debugging. - locationLogger.debug("Failed to update BGGeo tracking extras", { - 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). + // Identity switches can update auth.userToken and session.userId in separate steps. + // In particular, [`authActions.confirmLoginRequest()`](src/stores/auth.js:203) triggers a + // reload where `userToken` may be set before `session.userId` is populated. // - // 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. - // We no longer use geofence-only tracking; ensure we are not stuck in it. - 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.debug("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.debug("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 snapshot (debug only) to help understand trackingMode transitions. - let preState = null; - try { - preState = await BackgroundGeolocation.getState(); - locationLogger.debug("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, + // To guarantee the initial geopoint after identity change, re-run auth handling when the + // session userId changes. + subscribeSessionState( + (s) => s?.userId, + () => { + const { userToken } = getAuthState(); + // Avoid doing anything if controller isn't ready. + controller?.handleAuthToken(userToken).catch((e) => { + log.error("handleAuthToken failed (session change)", { 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 { - const payload = await buildBackgroundGeolocationSetConfigPayload( - profile, - ); - await BackgroundGeolocation.setConfig(payload); - - // 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, - }); - - // Prevent duplicated "moving-edge" persisted fix right after entering ACTIVE. - lastMovingEdgeAt = Date.now(); - } 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 (debug) to detect unintended geofence-only mode. - try { - const post = await BackgroundGeolocation.getState(); - locationLogger.debug("Tracking profile applied (post-state)", { - profileName, - instanceId: TRACK_LOCATION_INSTANCE_ID, - enabled: post?.enabled, - isMoving: post?.isMoving, - trackingMode: post?.trackingMode, - distanceFilter: post?.geolocation?.distanceFilter, - prevTrackingMode: preState?.trackingMode ?? null, + stack: e?.stack, + userId: getSessionState()?.userId ?? 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 (debug only) to diagnose "no uploads" reports after auth refresh. - if (__DEV__ || env.IS_STAGING) { - try { - const s = await BackgroundGeolocation.getState(); - locationLogger.debug("Auth-change BGGeo state snapshot", { - instanceId: TRACK_LOCATION_INSTANCE_ID, - enabled: s?.enabled, - isMoving: s?.isMoving, - trackingMode: s?.trackingMode, - }); - } catch (e) { - locationLogger.debug("Auth-change BGGeo state snapshot failed", { - error: e?.message, - }); - } - } - - // Compute identity from session store; this is our source of truth. - // (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 { - const payload = await buildBackgroundGeolocationSetConfigPayload({ - http: { - url: "", - autoSync: false, - headers: {}, - }, - }); - await BackgroundGeolocation.setConfig(payload); - 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; - } - locationLogger.debug("Updating background geolocation config"); - { - const payload = await buildBackgroundGeolocationSetConfigPayload({ - http: { - // Update the sync URL for when it's changed for staging - url: env.GEOLOC_SYNC_URL, - // IMPORTANT: enable native uploading when authenticated. - // This ensures uploads continue even if JS is suspended in background. - autoSync: true, - batchSync: false, - autoSyncThreshold: 0, - headers: { - Authorization: `Bearer ${userToken}`, - }, - }, - }); - await BackgroundGeolocation.setConfig(payload); - } - - authReady = true; - - 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(); - - 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", { + // Auth is the gate: pre-auth we do not START BGGeo tracking. + // (We may still call `.ready()` defensively to clear stale config and force a stop.) + const { userToken } = getAuthState(); + subscribeAuthState( + ({ userToken }) => userToken, + (nextToken) => { + controller?.handleAuthToken(nextToken).catch((e) => { + log.error("handleAuthToken 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", { - uuid: location?.uuid, - sample: location?.sample, - accuracy: location?.coords?.accuracy, - latitude: location?.coords?.latitude, - longitude: location?.coords?.longitude, - timestamp: location?.timestamp, - activity: location?.activity, - extras: location?.extras, - }); - - // Ignore sampling locations (eg, emitted during getCurrentPosition) to avoid UI/storage churn. - // 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") { - lastIdleReferenceCenter = { - latitude: location.coords.latitude, - longitude: location.coords.longitude, - }; - lastIdleReferenceCenterAccuracyM = - typeof location.coords.accuracy === "number" - ? location.coords.accuracy - : null; - lastIdleReferenceCenterTimestamp = location.timestamp ?? null; - lastIdleReferenceCenterSource = "onLocation"; - lastEnsuredIdleReferenceAt = Date.now(); - void updateTrackingContextExtras("idle_reference_updated"); - } - } - }, - onGeofence: async (event) => { - // Diagnostic only: geofences are still used internally by the SDK (eg stationary geofence) - // even when we don't manage any app-defined geofences. - try { - const state = await BackgroundGeolocation.getState(); - locationLogger.info("Geofence event", { - identifier: event?.identifier, - action: event?.action, - accuracy: event?.location?.coords?.accuracy, - latitude: event?.location?.coords?.latitude, - longitude: event?.location?.coords?.longitude, - enabled: state?.enabled, - isMoving: state?.isMoving, - trackingMode: state?.trackingMode, - profile: currentProfile, - appState, - }); - } catch (e) { - locationLogger.info("Geofence event", { - identifier: event?.identifier, - action: event?.action, - accuracy: event?.location?.coords?.accuracy, - latitude: event?.location?.coords?.latitude, - longitude: event?.location?.coords?.longitude, - profile: currentProfile, - appState, - error: e?.message, - }); - } - }, - 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, - }); + ); - // Lightweight instrumentation only when useful: - // - non-success responses - // - dev/staging visibility - const shouldInstrumentHttp = - !response?.success || __DEV__ || env.IS_STAGING; - if (!shouldInstrumentHttp) return; - - try { - const [state, count] = await Promise.all([ - BackgroundGeolocation.getState(), - BackgroundGeolocation.getCount(), - ]); - locationLogger.debug("HTTP instrumentation", { - success: response?.success, - status: response?.status, - enabled: state?.enabled, - isMoving: state?.isMoving, - trackingMode: state?.trackingMode, - pendingCount: count, - }); - } catch (e) { - locationLogger.debug("Failed HTTP instrumentation", { - error: e?.message, - }); - } - }, - onMotionChange: (event) => { - // Essential motion diagnostics (avoid spam; keep it one log per edge). - locationLogger.info("Motion change", { - instanceId: TRACK_LOCATION_INSTANCE_ID, - profile: currentProfile, - appState, - authReady, - isMoving: event?.isMoving, - accuracy: event?.location?.coords?.accuracy, - speed: event?.location?.coords?.speed, - }); - - // 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.debug("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.debug("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 { - const payload = await buildBackgroundGeolocationSetConfigPayload( - BASE_GEOLOCATION_INVARIANTS, - ); - await BackgroundGeolocation.setConfig(payload); - } 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(); - } - })(); + // Initial auth handling. + await controller.handleAuthToken(userToken); + })().catch((e) => { + // Allow retry if init fails. + trackLocationStartPromise = null; + controller = null; + throw e; + }); return trackLocationStartPromise; } diff --git a/src/scenes/Developer/index.js b/src/scenes/Developer/index.js index f88d5fc..364246a 100644 --- a/src/scenes/Developer/index.js +++ b/src/scenes/Developer/index.js @@ -1,7 +1,6 @@ import React, { useState, useEffect } from "react"; import { View, StyleSheet, ScrollView } from "react-native"; import * as Sentry from "@sentry/react-native"; -import BackgroundGeolocation from "react-native-background-geolocation"; import { Button, Card, @@ -22,8 +21,11 @@ import { import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger"; import { config as loggerConfig } from "~/lib/logger/config"; -import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; -import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; +import { + bggeoGetDiagnosticsSnapshot, + bggeoGetStatusSnapshot, + bggeoSyncNow, +} from "~/location/bggeo/diagnostics"; const reset = async () => { await authActions.logout(); @@ -48,6 +50,8 @@ export default function Developer() { const [syncResult, setSyncResult] = useState(""); const [bgGeoStatus, setBgGeoStatus] = useState(null); // null, 'loading', 'success', 'error' const [bgGeoResult, setBgGeoResult] = useState(""); + const [bgGeoDiagStatus, setBgGeoDiagStatus] = useState(null); + const [bgGeoDiagResult, setBgGeoDiagResult] = useState(""); const [logLevel, setLogLevel] = useState(LOG_LEVELS.DEBUG); // Initialize emulator mode and log level when component mounts @@ -80,25 +84,18 @@ export default function Developer() { setSyncStatus("syncing"); setSyncResult(""); - await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + const [{ enabled, isMoving, trackingMode }, sync] = await Promise.all([ + bggeoGetStatusSnapshot(), + bggeoSyncNow(), + ]); - const state = await BackgroundGeolocation.getState(); - - // Get the count of pending records first - const count = await BackgroundGeolocation.getCount(); - - // Perform the sync - const records = await BackgroundGeolocation.sync(); - - const pendingAfter = await BackgroundGeolocation.getCount(); - - const result = `Synced ${ - records?.length || 0 - } records (${count} pending before, ${pendingAfter} pending after). enabled=${String( - state?.enabled, - )} isMoving=${String(state?.isMoving)} trackingMode=${String( - state?.trackingMode, - )}`; + const result = `Synced ${String(sync?.synced)} records (${String( + sync?.pendingBefore, + )} pending before, ${String( + sync?.pendingAfter, + )} pending after). enabled=${String(enabled)} isMoving=${String( + isMoving, + )} trackingMode=${String(trackingMode)}`; setSyncResult(result); setSyncStatus("success"); } catch (error) { @@ -113,17 +110,12 @@ export default function Developer() { setBgGeoStatus("loading"); setBgGeoResult(""); - await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); - const [state, count] = await Promise.all([ - BackgroundGeolocation.getState(), - BackgroundGeolocation.getCount(), - ]); - - const result = `enabled=${String(state?.enabled)} isMoving=${String( - state?.isMoving, - )} trackingMode=${String(state?.trackingMode)} schedulerEnabled=${String( - state?.schedulerEnabled, - )} pending=${String(count)}`; + const snap = await bggeoGetStatusSnapshot(); + const result = `enabled=${String(snap?.enabled)} isMoving=${String( + snap?.isMoving, + )} trackingMode=${String(snap?.trackingMode)} schedulerEnabled=${String( + snap?.schedulerEnabled, + )} pending=${String(snap?.pending)}`; setBgGeoResult(result); setBgGeoStatus("success"); } catch (error) { @@ -132,6 +124,40 @@ export default function Developer() { setBgGeoStatus("error"); } }; + + // Diagnostics: provide a single snapshot that helps debug "no updates" without logcat. + // Includes state + pending queue + last persisted locations (if any). + const showBgGeoDiagnostics = async () => { + try { + setBgGeoDiagStatus("loading"); + setBgGeoDiagResult(""); + + const diag = await bggeoGetDiagnosticsSnapshot(); + + const last = diag?.lastLocation; + const lastStr = last + ? `last={lat:${String(last.latitude).slice(0, 10)} lng:${String( + last.longitude, + ).slice(0, 10)} acc:${String(last.accuracy)} ts:${String( + last.timestamp, + )}}` + : "last=null"; + + const state = diag?.state; + const result = `enabled=${String(state?.enabled)} isMoving=${String( + state?.isMoving, + )} trackingMode=${String(state?.trackingMode)} schedulerEnabled=${String( + state?.schedulerEnabled, + )} pending=${String(diag?.pending)} ${lastStr}`; + + setBgGeoDiagResult(result); + setBgGeoDiagStatus("success"); + } catch (error) { + console.error("BGGeo diagnostics failed:", error); + setBgGeoDiagResult(`Diagnostics failed: ${error.message}`); + setBgGeoDiagStatus("error"); + } + }; const triggerNullError = () => { try { // Wrap the null error in try-catch @@ -212,6 +238,23 @@ export default function Developer() { +
+ + {bgGeoDiagResult ? ( + + {bgGeoDiagResult} + + ) : null} +
+
Log Level