diff --git a/docs/location-tracking-qa.md b/docs/location-tracking-qa.md new file mode 100644 index 0000000..8bbdc89 --- /dev/null +++ b/docs/location-tracking-qa.md @@ -0,0 +1,92 @@ +# Location tracking QA checklist + +Applies to the BackgroundGeolocation integration: +- [`trackLocation()`](src/location/trackLocation.js:34) +- [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:126) + +## Goals + +1. Updates only when moved enough + - IDLE: record/upload only after moving ~200m. + - ACTIVE: record/upload after moving ~25m. +2. Works in foreground, background and terminated (Android + iOS). +3. Avoid uploads while stationary. + +## Current implementation notes + +- Movement-driven recording only: + - IDLE uses `distanceFilter: 200` (aim: no updates while not moving). + - ACTIVE uses `distanceFilter: 25`. + - JS may request a persisted fix when entering ACTIVE (see [`applyProfile()`](src/location/trackLocation.js:351)). +- Upload strategy is intentionally simple: + - Keep only the latest persisted geopoint: `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 `url` is empty so nothing is uploaded until auth is ready. + +## Basic preconditions + +- Location permissions: foreground + background granted. +- Motion permission granted. +- Network reachable. + +## Foreground behavior + +### IDLE (no open alert) + +1. Launch app, ensure no open alert. +2. Stay stationary for 5+ minutes. + - Expect: no repeated server updates. +3. Walk/drive ~250m. + - Expect: at least one location persisted + uploaded. + +### ACTIVE (open alert) + +1. Open an alert owned by the current user. +2. Move ~30m. + - Expect: at least one location persisted + uploaded. +3. Continue moving. + - Expect: periodic updates roughly aligned with movement, not time. + +## Background behavior + +### IDLE + +1. Put app in background. +2. Stay stationary. + - Expect: no periodic uploads. +3. Move ~250m. + - Expect: a persisted record and upload. + +### ACTIVE + +1. Put app in background. +2. Move ~30m. + - Expect: updates continue. + +## Terminated behavior + +### Android + +1. Ensure tracking enabled and authenticated. +2. Force-stop the app task. +3. Move ~250m in IDLE. + - Expect: native service still records + uploads. +4. Move ~30m in ACTIVE. + - Expect: native service still records + uploads. + +### iOS + +1. Swipe-kill the app. +2. Move significantly (expect iOS to relaunch app on stationary-geofence exit). + - Expect: tracking resumes and uploads after movement. + +## 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) diff --git a/index.js b/index.js index 74316f6..71e7ffb 100644 --- a/index.js +++ b/index.js @@ -22,11 +22,13 @@ notifee.onBackgroundEvent(notificationBackgroundEvent); messaging().setBackgroundMessageHandler(onMessageReceived); // Android Headless Mode for react-native-background-geolocation. -// Required because [`enableHeadless`](src/location/backgroundGeolocationConfig.js:16) is enabled and -// we run with [`stopOnTerminate: false`](src/location/backgroundGeolocationConfig.js:40). // -// IMPORTANT: keep this handler lightweight. In headless state, the JS runtime may be launched -// briefly and then torn down; long tasks can be terminated by the OS. +// We currently run with `enableHeadless: false` (see +// [`BASE_GEOLOCATION_CONFIG.enableHeadless`](src/location/backgroundGeolocationConfig.js:16)), +// meaning we do not rely on JS callbacks while the app is terminated. +// +// This registration is kept only as a safety-net: if `enableHeadless` is ever turned on again, +// we'll at least have a minimal handler. BackgroundGeolocation.registerHeadlessTask(async (event) => { // eslint-disable-next-line no-console console.log("[BGGeo HeadlessTask]", event?.name, event?.params); diff --git a/src/hooks/useLocation.js b/src/hooks/useLocation.js index 86d3e4a..e7fb4c1 100644 --- a/src/hooks/useLocation.js +++ b/src/hooks/useLocation.js @@ -26,6 +26,9 @@ export default function useLocation() { const [location, setLocation] = useState({ coords: DEFAULT_COORDS }); const [isLastKnown, setIsLastKnown] = useState(false); const [lastKnownTimestamp, setLastKnownTimestamp] = useState(null); + // UI-facing realtime tracking (foreground). + // We intentionally keep this separate from BGGeo background tracking. + // Note: this watcher should be managed by screen lifecycle (mounted maps). const watcher = useRef(); const timeoutRef = useRef(); const isWatchingRef = useRef(false); @@ -41,7 +44,6 @@ export default function useLocation() { await watcher.current.remove(); watcher.current = null; } - // Reset flags isWatchingRef.current = false; hasLocationRef.current = false; diff --git a/src/location/backgroundGeolocationConfig.js b/src/location/backgroundGeolocationConfig.js index 1904dc3..a77a919 100644 --- a/src/location/backgroundGeolocationConfig.js +++ b/src/location/backgroundGeolocationConfig.js @@ -1,9 +1,18 @@ import BackgroundGeolocation from "react-native-background-geolocation"; -import { Platform } from "react-native"; import env from "~/env"; // Common config: keep always-on tracking enabled, but default to an IDLE low-power profile. -// High-accuracy and moving mode are enabled only when an active alert is open. +// High-accuracy and tighter distance thresholds are enabled only when an active alert is open. +// +// Expected behavior (both Android + iOS): +// - Foreground: locations recorded only after moving beyond `distanceFilter`. +// - Background: same rule; native service continues even if JS is suspended. +// - Terminated: +// - Android: native service continues (`stopOnTerminate:false`); JS headless is NOT required. +// - iOS: OS will relaunch app on significant movement / stationary-geofence exit. +// +// NOTE: We avoid creating persisted records from UI-only lookups (eg map refresh), since +// persisted records can trigger native HTTP uploads even while stationary. // // Product goals: // - IDLE (no open alert): minimize battery; server updates are acceptable only on OS-level significant movement. @@ -14,8 +23,10 @@ import env from "~/env"; // In dev, `reset: true` is useful to avoid config drift while iterating. // - `maxRecordsToPersist` must be > 1 to support offline catch-up. export const BASE_GEOLOCATION_CONFIG = { - // Android Headless Mode (requires registering a headless task entrypoint in index.js) - enableHeadless: true, + // Android Headless Mode + // We do not require JS execution while terminated. Native tracking + native HTTP upload + // are sufficient for our needs (stopOnTerminate:false). + enableHeadless: false, // Default to low-power (idle) profile; will be overridden when needed. desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW, @@ -27,8 +38,6 @@ export const BASE_GEOLOCATION_CONFIG = { // Activity-recognition stop-detection. // NOTE: Transistorsoft defaults `stopTimeout` to 5 minutes (see // [`node_modules/react-native-background-geolocation/src/declarations/interfaces/Config.d.ts:79`](node_modules/react-native-background-geolocation/src/declarations/interfaces/Config.d.ts:79)). - // We keep the default in BASE and override it in the IDLE profile to reduce - // 5-minute stationary cycles observed on Android. stopTimeout: 5, // debug: true, @@ -70,19 +79,24 @@ export const BASE_GEOLOCATION_CONFIG = { }, // HTTP configuration - url: env.GEOLOC_SYNC_URL, + // IMPORTANT: Default to uploads disabled until we have an auth token. + // Authenticated mode will set `url` + `Authorization` header and enable `autoSync`. + url: "", method: "POST", httpRootProperty: "location", - batchSync: false, - // We intentionally disable autoSync and perform controlled uploads from explicit triggers - // (startup, identity-change, moving-edge, active-alert). This prevents stationary "ghost" - // uploads from low-quality locations produced by some Android devices. + // Keep uploads simple: 1 location record -> 1 HTTP request. + // (We intentionally keep only the latest record; batching provides no benefit.) autoSync: false, + // Ensure no persisted config can keep batching/threshold behavior. + batchSync: false, + autoSyncThreshold: 0, - // Persistence: keep enough records for offline catch-up. - // (The SDK already constrains with maxDaysToPersist; records are deleted after successful upload.) - maxRecordsToPersist: 1000, - maxDaysToPersist: 7, + // Persistence + // Product requirement: keep only the latest geopoint. This reduces on-device storage + // and avoids building up a queue. + // NOTE: This means we intentionally do not support offline catch-up of multiple points. + maxRecordsToPersist: 1, + maxDaysToPersist: 1, // Development convenience reset: !!__DEV__, @@ -94,7 +108,7 @@ export const BASE_GEOLOCATION_CONFIG = { // Options we want to be stable across launches even when the plugin loads a persisted config. // NOTE: We intentionally do *not* include HTTP auth headers here. export const BASE_GEOLOCATION_INVARIANTS = { - enableHeadless: true, + enableHeadless: false, stopOnTerminate: false, startOnBoot: true, foregroundService: true, @@ -105,48 +119,32 @@ export const BASE_GEOLOCATION_INVARIANTS = { method: "POST", httpRootProperty: "location", autoSync: false, - maxRecordsToPersist: 1000, - maxDaysToPersist: 7, + batchSync: false, + autoSyncThreshold: 0, + maxRecordsToPersist: 1, + maxDaysToPersist: 1, }; export const TRACKING_PROFILES = { idle: { desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW, - // Max battery-saving strategy for IDLE: - // Use Android/iOS low-power significant-change tracking where the OS produces - // only periodic fixes (several times/hour). Note many config options like - // `distanceFilter` / `stationaryRadius` are documented as having little/no - // effect in this mode. - // Some devices / OEMs can be unreliable with significant-change only. - // Use standard motion tracking for reliability, with conservative distanceFilter. - useSignificantChangesOnly: false, - - // Defensive: if some devices/platform conditions fall back to standard tracking, - // keep the distanceFilter conservative to avoid battery drain. + // Defensive: keep the distanceFilter conservative to avoid battery drain. distanceFilter: 200, + // Keep the plugin's speed-based distanceFilter scaling enabled (default). + // This yields fewer updates as speed increases (highway speeds) and helps battery. + // We intentionally do NOT set `disableElasticity: true`. + // Android-only: reduce false-positive motion triggers due to screen-on/unlock. // (This is ignored on iOS.) motionTriggerDelay: 30000, - - // Keep the default stop-detection timing (minutes). In significant-changes - // mode, stop-detection is not the primary driver of updates. - stopTimeout: 5, - - // No periodic wakeups while idle. - heartbeatInterval: 0, }, active: { desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH, - // Ensure we exit significant-changes mode when switching from IDLE. - useSignificantChangesOnly: false, - distanceFilter: 50, - heartbeatInterval: 60, + // ACTIVE target: frequent updates while moving. + distanceFilter: 25, // Android-only: do not delay motion triggers while ACTIVE. motionTriggerDelay: 0, - - // Keep default responsiveness during an active alert. - stopTimeout: 5, }, }; diff --git a/src/location/index.js b/src/location/index.js index 43796dd..1760c33 100644 --- a/src/location/index.js +++ b/src/location/index.js @@ -61,9 +61,11 @@ 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: true, + persist: false, maximumAge: 5000, desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH, samples: 1, diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 0018131..f077308 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -1,4 +1,5 @@ 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"; @@ -42,6 +43,7 @@ export default function trackLocation() { let currentProfile = null; let authReady = false; + let appState = AppState.currentState; let stopAlertSubscription = null; let stopSessionSubscription = null; @@ -58,38 +60,38 @@ export default function trackLocation() { // Track identity so we can force a first geopoint when the effective user changes. let lastSessionUserId = null; - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - - const pruneBadLocations = async () => { + const updateTrackingContextExtras = async (reason) => { try { - const locations = await BackgroundGeolocation.getLocations(); - if (!Array.isArray(locations) || !locations.length) return 0; - - let deleted = 0; - // Defensive: only scan a bounded amount to avoid heavy work. - const toScan = locations.slice(-200); - for (const loc of toScan) { - const acc = loc?.coords?.accuracy; - const uuid = loc?.uuid; - if ( - typeof acc === "number" && - acc > BAD_ACCURACY_THRESHOLD_M && - uuid - ) { - try { - await BackgroundGeolocation.destroyLocation(uuid); - deleted++; - } catch (e) { - // ignore - } - } - } - return deleted; + const { userId } = getSessionState(); + await BackgroundGeolocation.setConfig({ + extras: { + tracking_ctx: { + reason, + app_state: appState, + profile: currentProfile, + auth_ready: authReady, + session_user_id: userId || null, + at: new Date().toISOString(), + }, + }, + }); } catch (e) { - return 0; + // 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++) { @@ -181,8 +183,10 @@ export default function trackLocation() { 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). This is intentionally different - // from auth-refresh fixes, which are non-persisted to avoid unlock/resume noise. + // 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(); @@ -192,6 +196,13 @@ export default function trackLocation() { isMoving: before.isMoving, }); + if (currentProfile !== "active") { + locationLogger.info("Skipping startup persisted fix (not ACTIVE)", { + currentProfile, + }); + return; + } + const t0 = Date.now(); const location = await BackgroundGeolocation.getCurrentPosition({ samples: 1, @@ -230,7 +241,7 @@ export default function trackLocation() { } }; - // When auth changes, we want a fresh persisted point for the newly effective identity. + // 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; @@ -301,6 +312,9 @@ export default function trackLocation() { 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", { @@ -410,6 +424,9 @@ export default function trackLocation() { currentProfile = profileName; + // Update extras for observability (profile transitions are a key lifecycle change). + updateTrackingContextExtras(`profile:${profileName}`); + try { const state = await BackgroundGeolocation.getState(); locationLogger.info("Tracking profile applied", { @@ -508,6 +525,9 @@ export default function trackLocation() { 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; @@ -528,7 +548,11 @@ export default function trackLocation() { locationLogger.debug("Updating background geolocation config"); await BackgroundGeolocation.setConfig({ url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging - autoSync: false, + // 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}`, }, @@ -536,6 +560,8 @@ export default function trackLocation() { authReady = true; + updateTrackingContextExtras("auth:ready"); + // Log the authorization header that was set locationLogger.debug( "Set Authorization header for background geolocation", @@ -600,16 +626,16 @@ export default function trackLocation() { } } - // Always request a fresh persisted point on any token update. - // This ensures a newly connected user gets an immediate point even if they don't move. + // 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; - startupFixInFlight = requestStartupPersistedFix(); + // 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; @@ -620,6 +646,11 @@ export default function trackLocation() { 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( @@ -655,27 +686,14 @@ export default function trackLocation() { // The final persisted location will arrive with sample=false. if (location.sample) return; - // Quality gate: delete very poor-accuracy locations to prevent them from being synced - // and ignore them for UI/state. + // 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. const acc = location?.coords?.accuracy; if (typeof acc === "number" && acc > BAD_ACCURACY_THRESHOLD_M) { locationLogger.info("Ignoring poor-accuracy location", { accuracy: acc, uuid: location?.uuid, }); - if (location?.uuid) { - try { - await BackgroundGeolocation.destroyLocation(location.uuid); - locationLogger.debug("Destroyed poor-accuracy location", { - uuid: location.uuid, - }); - } catch (e) { - locationLogger.warn("Failed to destroy poor-accuracy location", { - uuid: location?.uuid, - error: e?.message, - }); - } - } return; } @@ -812,6 +830,24 @@ export default function trackLocation() { 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 { @@ -823,6 +859,9 @@ export default function trackLocation() { }); } + // 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", { diff --git a/src/scenes/Developer/index.js b/src/scenes/Developer/index.js index df7a098..f88d5fc 100644 --- a/src/scenes/Developer/index.js +++ b/src/scenes/Developer/index.js @@ -46,6 +46,8 @@ export default function Developer() { const [emulatorMode, setEmulatorMode] = useState(false); const [syncStatus, setSyncStatus] = useState(null); // null, 'syncing', 'success', 'error' const [syncResult, setSyncResult] = useState(""); + const [bgGeoStatus, setBgGeoStatus] = useState(null); // null, 'loading', 'success', 'error' + const [bgGeoResult, setBgGeoResult] = useState(""); const [logLevel, setLogLevel] = useState(LOG_LEVELS.DEBUG); // Initialize emulator mode and log level when component mounts @@ -80,15 +82,23 @@ export default function Developer() { await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); + 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)`; + } records (${count} pending before, ${pendingAfter} pending after). enabled=${String( + state?.enabled, + )} isMoving=${String(state?.isMoving)} trackingMode=${String( + state?.trackingMode, + )}`; setSyncResult(result); setSyncStatus("success"); } catch (error) { @@ -97,6 +107,31 @@ export default function Developer() { setSyncStatus("error"); } }; + + const showBgGeoStatus = async () => { + try { + 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)}`; + setBgGeoResult(result); + setBgGeoStatus("success"); + } catch (error) { + console.error("BGGeo status failed:", error); + setBgGeoResult(`Status failed: ${error.message}`); + setBgGeoStatus("error"); + } + }; const triggerNullError = () => { try { // Wrap the null error in try-catch @@ -276,6 +311,32 @@ export default function Developer() { {syncResult} )} + + + + {bgGeoStatus && bgGeoResult ? ( + + {bgGeoResult} + + ) : null}