fix(track-location): try 5
This commit is contained in:
parent
b21a91bb9e
commit
69753bc7e1
7 changed files with 295 additions and 99 deletions
92
docs/location-tracking-qa.md
Normal file
92
docs/location-tracking-qa.md
Normal file
|
|
@ -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)
|
||||||
10
index.js
10
index.js
|
|
@ -22,11 +22,13 @@ notifee.onBackgroundEvent(notificationBackgroundEvent);
|
||||||
messaging().setBackgroundMessageHandler(onMessageReceived);
|
messaging().setBackgroundMessageHandler(onMessageReceived);
|
||||||
|
|
||||||
// Android Headless Mode for react-native-background-geolocation.
|
// 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
|
// We currently run with `enableHeadless: false` (see
|
||||||
// briefly and then torn down; long tasks can be terminated by the OS.
|
// [`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) => {
|
BackgroundGeolocation.registerHeadlessTask(async (event) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("[BGGeo HeadlessTask]", event?.name, event?.params);
|
console.log("[BGGeo HeadlessTask]", event?.name, event?.params);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ export default function useLocation() {
|
||||||
const [location, setLocation] = useState({ coords: DEFAULT_COORDS });
|
const [location, setLocation] = useState({ coords: DEFAULT_COORDS });
|
||||||
const [isLastKnown, setIsLastKnown] = useState(false);
|
const [isLastKnown, setIsLastKnown] = useState(false);
|
||||||
const [lastKnownTimestamp, setLastKnownTimestamp] = useState(null);
|
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 watcher = useRef();
|
||||||
const timeoutRef = useRef();
|
const timeoutRef = useRef();
|
||||||
const isWatchingRef = useRef(false);
|
const isWatchingRef = useRef(false);
|
||||||
|
|
@ -41,7 +44,6 @@ export default function useLocation() {
|
||||||
await watcher.current.remove();
|
await watcher.current.remove();
|
||||||
watcher.current = null;
|
watcher.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset flags
|
// Reset flags
|
||||||
isWatchingRef.current = false;
|
isWatchingRef.current = false;
|
||||||
hasLocationRef.current = false;
|
hasLocationRef.current = false;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import BackgroundGeolocation from "react-native-background-geolocation";
|
import BackgroundGeolocation from "react-native-background-geolocation";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
|
|
||||||
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile.
|
// 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:
|
// Product goals:
|
||||||
// - IDLE (no open alert): minimize battery; server updates are acceptable only on OS-level significant movement.
|
// - 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.
|
// In dev, `reset: true` is useful to avoid config drift while iterating.
|
||||||
// - `maxRecordsToPersist` must be > 1 to support offline catch-up.
|
// - `maxRecordsToPersist` must be > 1 to support offline catch-up.
|
||||||
export const BASE_GEOLOCATION_CONFIG = {
|
export const BASE_GEOLOCATION_CONFIG = {
|
||||||
// Android Headless Mode (requires registering a headless task entrypoint in index.js)
|
// Android Headless Mode
|
||||||
enableHeadless: true,
|
// 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.
|
// Default to low-power (idle) profile; will be overridden when needed.
|
||||||
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
|
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
|
||||||
|
|
@ -27,8 +38,6 @@ export const BASE_GEOLOCATION_CONFIG = {
|
||||||
// Activity-recognition stop-detection.
|
// Activity-recognition stop-detection.
|
||||||
// NOTE: Transistorsoft defaults `stopTimeout` to 5 minutes (see
|
// 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)).
|
// [`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,
|
stopTimeout: 5,
|
||||||
|
|
||||||
// debug: true,
|
// debug: true,
|
||||||
|
|
@ -70,19 +79,24 @@ export const BASE_GEOLOCATION_CONFIG = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// HTTP configuration
|
// 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",
|
method: "POST",
|
||||||
httpRootProperty: "location",
|
httpRootProperty: "location",
|
||||||
batchSync: false,
|
// Keep uploads simple: 1 location record -> 1 HTTP request.
|
||||||
// We intentionally disable autoSync and perform controlled uploads from explicit triggers
|
// (We intentionally keep only the latest record; batching provides no benefit.)
|
||||||
// (startup, identity-change, moving-edge, active-alert). This prevents stationary "ghost"
|
|
||||||
// uploads from low-quality locations produced by some Android devices.
|
|
||||||
autoSync: false,
|
autoSync: false,
|
||||||
|
// Ensure no persisted config can keep batching/threshold behavior.
|
||||||
|
batchSync: false,
|
||||||
|
autoSyncThreshold: 0,
|
||||||
|
|
||||||
// Persistence: keep enough records for offline catch-up.
|
// Persistence
|
||||||
// (The SDK already constrains with maxDaysToPersist; records are deleted after successful upload.)
|
// Product requirement: keep only the latest geopoint. This reduces on-device storage
|
||||||
maxRecordsToPersist: 1000,
|
// and avoids building up a queue.
|
||||||
maxDaysToPersist: 7,
|
// NOTE: This means we intentionally do not support offline catch-up of multiple points.
|
||||||
|
maxRecordsToPersist: 1,
|
||||||
|
maxDaysToPersist: 1,
|
||||||
|
|
||||||
// Development convenience
|
// Development convenience
|
||||||
reset: !!__DEV__,
|
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.
|
// 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.
|
// NOTE: We intentionally do *not* include HTTP auth headers here.
|
||||||
export const BASE_GEOLOCATION_INVARIANTS = {
|
export const BASE_GEOLOCATION_INVARIANTS = {
|
||||||
enableHeadless: true,
|
enableHeadless: false,
|
||||||
stopOnTerminate: false,
|
stopOnTerminate: false,
|
||||||
startOnBoot: true,
|
startOnBoot: true,
|
||||||
foregroundService: true,
|
foregroundService: true,
|
||||||
|
|
@ -105,48 +119,32 @@ export const BASE_GEOLOCATION_INVARIANTS = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
httpRootProperty: "location",
|
httpRootProperty: "location",
|
||||||
autoSync: false,
|
autoSync: false,
|
||||||
maxRecordsToPersist: 1000,
|
batchSync: false,
|
||||||
maxDaysToPersist: 7,
|
autoSyncThreshold: 0,
|
||||||
|
maxRecordsToPersist: 1,
|
||||||
|
maxDaysToPersist: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TRACKING_PROFILES = {
|
export const TRACKING_PROFILES = {
|
||||||
idle: {
|
idle: {
|
||||||
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
|
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
|
||||||
// Max battery-saving strategy for IDLE:
|
// Defensive: keep the distanceFilter conservative to avoid battery drain.
|
||||||
// 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.
|
|
||||||
distanceFilter: 200,
|
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.
|
// Android-only: reduce false-positive motion triggers due to screen-on/unlock.
|
||||||
// (This is ignored on iOS.)
|
// (This is ignored on iOS.)
|
||||||
motionTriggerDelay: 30000,
|
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: {
|
active: {
|
||||||
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
|
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
|
||||||
// Ensure we exit significant-changes mode when switching from IDLE.
|
// ACTIVE target: frequent updates while moving.
|
||||||
useSignificantChangesOnly: false,
|
distanceFilter: 25,
|
||||||
distanceFilter: 50,
|
|
||||||
heartbeatInterval: 60,
|
|
||||||
|
|
||||||
// Android-only: do not delay motion triggers while ACTIVE.
|
// Android-only: do not delay motion triggers while ACTIVE.
|
||||||
motionTriggerDelay: 0,
|
motionTriggerDelay: 0,
|
||||||
|
|
||||||
// Keep default responsiveness during an active alert.
|
|
||||||
stopTimeout: 5,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,11 @@ export async function getCurrentLocation() {
|
||||||
return null;
|
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({
|
const location = await BackgroundGeolocation.getCurrentPosition({
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
persist: true,
|
persist: false,
|
||||||
maximumAge: 5000,
|
maximumAge: 5000,
|
||||||
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
|
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
|
||||||
samples: 1,
|
samples: 1,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import BackgroundGeolocation from "react-native-background-geolocation";
|
import BackgroundGeolocation from "react-native-background-geolocation";
|
||||||
|
import { AppState } from "react-native";
|
||||||
import { createLogger } from "~/lib/logger";
|
import { createLogger } from "~/lib/logger";
|
||||||
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
|
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
|
||||||
import jwtDecode from "jwt-decode";
|
import jwtDecode from "jwt-decode";
|
||||||
|
|
@ -42,6 +43,7 @@ export default function trackLocation() {
|
||||||
|
|
||||||
let currentProfile = null;
|
let currentProfile = null;
|
||||||
let authReady = false;
|
let authReady = false;
|
||||||
|
let appState = AppState.currentState;
|
||||||
let stopAlertSubscription = null;
|
let stopAlertSubscription = null;
|
||||||
let stopSessionSubscription = 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.
|
// Track identity so we can force a first geopoint when the effective user changes.
|
||||||
let lastSessionUserId = null;
|
let lastSessionUserId = null;
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const updateTrackingContextExtras = async (reason) => {
|
||||||
|
|
||||||
const pruneBadLocations = async () => {
|
|
||||||
try {
|
try {
|
||||||
const locations = await BackgroundGeolocation.getLocations();
|
const { userId } = getSessionState();
|
||||||
if (!Array.isArray(locations) || !locations.length) return 0;
|
await BackgroundGeolocation.setConfig({
|
||||||
|
extras: {
|
||||||
let deleted = 0;
|
tracking_ctx: {
|
||||||
// Defensive: only scan a bounded amount to avoid heavy work.
|
reason,
|
||||||
const toScan = locations.slice(-200);
|
app_state: appState,
|
||||||
for (const loc of toScan) {
|
profile: currentProfile,
|
||||||
const acc = loc?.coords?.accuracy;
|
auth_ready: authReady,
|
||||||
const uuid = loc?.uuid;
|
session_user_id: userId || null,
|
||||||
if (
|
at: new Date().toISOString(),
|
||||||
typeof acc === "number" &&
|
},
|
||||||
acc > BAD_ACCURACY_THRESHOLD_M &&
|
},
|
||||||
uuid
|
});
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await BackgroundGeolocation.destroyLocation(uuid);
|
|
||||||
deleted++;
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
} catch (e) {
|
} 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) => {
|
const safeSync = async (reason) => {
|
||||||
// Sync can fail transiently (SDK busy, network warming up, etc). Retry a few times.
|
// Sync can fail transiently (SDK busy, network warming up, etc). Retry a few times.
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
|
@ -181,8 +183,10 @@ export default function trackLocation() {
|
||||||
let startupFixInFlight = null;
|
let startupFixInFlight = null;
|
||||||
|
|
||||||
// Startup fix should be persisted so it can be auto-synced immediately (user expects
|
// 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
|
// to appear on server soon after first app open).
|
||||||
// from auth-refresh fixes, which are non-persisted to avoid unlock/resume noise.
|
//
|
||||||
|
// IMPORTANT: restrict this to the ACTIVE profile only.
|
||||||
|
// Persisted startup fixes while IDLE can create "no-move" uploads on some devices.
|
||||||
const requestStartupPersistedFix = async () => {
|
const requestStartupPersistedFix = async () => {
|
||||||
try {
|
try {
|
||||||
const before = await BackgroundGeolocation.getState();
|
const before = await BackgroundGeolocation.getState();
|
||||||
|
|
@ -192,6 +196,13 @@ export default function trackLocation() {
|
||||||
isMoving: before.isMoving,
|
isMoving: before.isMoving,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (currentProfile !== "active") {
|
||||||
|
locationLogger.info("Skipping startup persisted fix (not ACTIVE)", {
|
||||||
|
currentProfile,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const location = await BackgroundGeolocation.getCurrentPosition({
|
const location = await BackgroundGeolocation.getCurrentPosition({
|
||||||
samples: 1,
|
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).
|
// Debounced to avoid spamming `getCurrentPosition` if auth updates quickly (refresh/renew).
|
||||||
let authFixDebounceTimerId = null;
|
let authFixDebounceTimerId = null;
|
||||||
let authFixInFlight = null;
|
let authFixInFlight = null;
|
||||||
|
|
@ -301,6 +312,9 @@ export default function trackLocation() {
|
||||||
timestamp: location?.timestamp,
|
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();
|
lastAuthFixAt = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
locationLogger.warn("Auth-change location fix failed", {
|
locationLogger.warn("Auth-change location fix failed", {
|
||||||
|
|
@ -410,6 +424,9 @@ export default function trackLocation() {
|
||||||
|
|
||||||
currentProfile = profileName;
|
currentProfile = profileName;
|
||||||
|
|
||||||
|
// Update extras for observability (profile transitions are a key lifecycle change).
|
||||||
|
updateTrackingContextExtras(`profile:${profileName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const state = await BackgroundGeolocation.getState();
|
const state = await BackgroundGeolocation.getState();
|
||||||
locationLogger.info("Tracking profile applied", {
|
locationLogger.info("Tracking profile applied", {
|
||||||
|
|
@ -508,6 +525,9 @@ export default function trackLocation() {
|
||||||
authReady = false;
|
authReady = false;
|
||||||
currentProfile = null;
|
currentProfile = null;
|
||||||
|
|
||||||
|
// Ensure server/debug can see the app lifecycle context even pre-auth.
|
||||||
|
updateTrackingContextExtras("auth:anonymous");
|
||||||
|
|
||||||
if (authFixDebounceTimerId) {
|
if (authFixDebounceTimerId) {
|
||||||
clearTimeout(authFixDebounceTimerId);
|
clearTimeout(authFixDebounceTimerId);
|
||||||
authFixDebounceTimerId = null;
|
authFixDebounceTimerId = null;
|
||||||
|
|
@ -528,7 +548,11 @@ export default function trackLocation() {
|
||||||
locationLogger.debug("Updating background geolocation config");
|
locationLogger.debug("Updating background geolocation config");
|
||||||
await BackgroundGeolocation.setConfig({
|
await BackgroundGeolocation.setConfig({
|
||||||
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${userToken}`,
|
Authorization: `Bearer ${userToken}`,
|
||||||
},
|
},
|
||||||
|
|
@ -536,6 +560,8 @@ export default function trackLocation() {
|
||||||
|
|
||||||
authReady = true;
|
authReady = true;
|
||||||
|
|
||||||
|
updateTrackingContextExtras("auth:ready");
|
||||||
|
|
||||||
// Log the authorization header that was set
|
// Log the authorization header that was set
|
||||||
locationLogger.debug(
|
locationLogger.debug(
|
||||||
"Set Authorization header for background geolocation",
|
"Set Authorization header for background geolocation",
|
||||||
|
|
@ -600,16 +626,16 @@ export default function trackLocation() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always request a fresh persisted point on any token update.
|
// Always request a fresh UI-only fix on any token update.
|
||||||
// This ensures a newly connected user gets an immediate point even if they don't move.
|
|
||||||
scheduleAuthFreshFix();
|
scheduleAuthFreshFix();
|
||||||
|
|
||||||
// Request a single fresh location-fix on each app launch when tracking is enabled.
|
// 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 this only after auth headers are configured so the persisted point can sync.
|
||||||
// - We do NOT force moving mode.
|
// - We do NOT force moving mode.
|
||||||
|
// - We only persist in ACTIVE.
|
||||||
if (!didRequestStartupFix) {
|
if (!didRequestStartupFix) {
|
||||||
didRequestStartupFix = true;
|
didRequestStartupFix = true;
|
||||||
startupFixInFlight = requestStartupPersistedFix();
|
// Profile isn't applied yet. We'll request the startup fix after we apply the profile.
|
||||||
} else if (authFixInFlight) {
|
} else if (authFixInFlight) {
|
||||||
// Avoid concurrent fix calls if auth updates race.
|
// Avoid concurrent fix calls if auth updates race.
|
||||||
await authFixInFlight;
|
await authFixInFlight;
|
||||||
|
|
@ -620,6 +646,11 @@ export default function trackLocation() {
|
||||||
const shouldBeActive = computeHasOwnOpenAlert();
|
const shouldBeActive = computeHasOwnOpenAlert();
|
||||||
await applyProfile(shouldBeActive ? "active" : "idle");
|
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.
|
// Subscribe to changes that may require switching profiles.
|
||||||
if (!stopSessionSubscription) {
|
if (!stopSessionSubscription) {
|
||||||
stopSessionSubscription = subscribeSessionState(
|
stopSessionSubscription = subscribeSessionState(
|
||||||
|
|
@ -655,27 +686,14 @@ export default function trackLocation() {
|
||||||
// The final persisted location will arrive with sample=false.
|
// The final persisted location will arrive with sample=false.
|
||||||
if (location.sample) return;
|
if (location.sample) return;
|
||||||
|
|
||||||
// Quality gate: delete very poor-accuracy locations to prevent them from being synced
|
// Quality gate (UI-only): if accuracy is very poor, ignore for UI/state.
|
||||||
// and ignore them for UI/state.
|
// Do NOT delete the record here; native uploads may happen while JS is suspended.
|
||||||
const acc = location?.coords?.accuracy;
|
const acc = location?.coords?.accuracy;
|
||||||
if (typeof acc === "number" && acc > BAD_ACCURACY_THRESHOLD_M) {
|
if (typeof acc === "number" && acc > BAD_ACCURACY_THRESHOLD_M) {
|
||||||
locationLogger.info("Ignoring poor-accuracy location", {
|
locationLogger.info("Ignoring poor-accuracy location", {
|
||||||
accuracy: acc,
|
accuracy: acc,
|
||||||
uuid: location?.uuid,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -812,6 +830,24 @@ export default function trackLocation() {
|
||||||
locationLogger.info("Initializing background geolocation");
|
locationLogger.info("Initializing background geolocation");
|
||||||
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
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.
|
// Ensure critical config cannot drift due to persisted plugin state.
|
||||||
// (We intentionally keep auth headers separate and set them in handleAuth.)
|
// (We intentionally keep auth headers separate and set them in handleAuth.)
|
||||||
try {
|
try {
|
||||||
|
|
@ -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
|
// Only set the permission state if we already have the permission
|
||||||
const state = await BackgroundGeolocation.getState();
|
const state = await BackgroundGeolocation.getState();
|
||||||
locationLogger.debug("Background geolocation state", {
|
locationLogger.debug("Background geolocation state", {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ export default function Developer() {
|
||||||
const [emulatorMode, setEmulatorMode] = useState(false);
|
const [emulatorMode, setEmulatorMode] = useState(false);
|
||||||
const [syncStatus, setSyncStatus] = useState(null); // null, 'syncing', 'success', 'error'
|
const [syncStatus, setSyncStatus] = useState(null); // null, 'syncing', 'success', 'error'
|
||||||
const [syncResult, setSyncResult] = useState("");
|
const [syncResult, setSyncResult] = useState("");
|
||||||
|
const [bgGeoStatus, setBgGeoStatus] = useState(null); // null, 'loading', 'success', 'error'
|
||||||
|
const [bgGeoResult, setBgGeoResult] = useState("");
|
||||||
const [logLevel, setLogLevel] = useState(LOG_LEVELS.DEBUG);
|
const [logLevel, setLogLevel] = useState(LOG_LEVELS.DEBUG);
|
||||||
|
|
||||||
// Initialize emulator mode and log level when component mounts
|
// Initialize emulator mode and log level when component mounts
|
||||||
|
|
@ -80,15 +82,23 @@ export default function Developer() {
|
||||||
|
|
||||||
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
||||||
|
|
||||||
|
const state = await BackgroundGeolocation.getState();
|
||||||
|
|
||||||
// Get the count of pending records first
|
// Get the count of pending records first
|
||||||
const count = await BackgroundGeolocation.getCount();
|
const count = await BackgroundGeolocation.getCount();
|
||||||
|
|
||||||
// Perform the sync
|
// Perform the sync
|
||||||
const records = await BackgroundGeolocation.sync();
|
const records = await BackgroundGeolocation.sync();
|
||||||
|
|
||||||
|
const pendingAfter = await BackgroundGeolocation.getCount();
|
||||||
|
|
||||||
const result = `Synced ${
|
const result = `Synced ${
|
||||||
records?.length || 0
|
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);
|
setSyncResult(result);
|
||||||
setSyncStatus("success");
|
setSyncStatus("success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -97,6 +107,31 @@ export default function Developer() {
|
||||||
setSyncStatus("error");
|
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 = () => {
|
const triggerNullError = () => {
|
||||||
try {
|
try {
|
||||||
// Wrap the null error in try-catch
|
// Wrap the null error in try-catch
|
||||||
|
|
@ -276,6 +311,32 @@ export default function Developer() {
|
||||||
{syncResult}
|
{syncResult}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={showBgGeoStatus}
|
||||||
|
style={styles.button}
|
||||||
|
contentStyle={styles.buttonContent}
|
||||||
|
labelStyle={styles.buttonLabel}
|
||||||
|
loading={bgGeoStatus === "loading"}
|
||||||
|
disabled={bgGeoStatus === "loading"}
|
||||||
|
>
|
||||||
|
Show BGGeo Status
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{bgGeoStatus && bgGeoResult ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{
|
||||||
|
color:
|
||||||
|
bgGeoStatus === "success" ? colors.primary : colors.error,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{bgGeoResult}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Divider style={styles.divider} />
|
<Divider style={styles.divider} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue