fix(track-location): try 11

This commit is contained in:
devthejo 2026-02-08 10:36:20 +01:00
parent b8c57520df
commit 88fbd72e51
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
9 changed files with 839 additions and 1450 deletions

View file

@ -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 ~200300m 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).

View file

@ -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,10 +42,10 @@ 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
// 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,
},
@ -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: {

View file

@ -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(

View file

@ -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<typeof AppState.addEventListener> | 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,
};
}

View file

@ -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,
};
}

View file

@ -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

View file

@ -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);
// 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 ${

File diff suppressed because it is too large Load diff

View file

@ -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() {
</View>
</Section>
<Section title="BGGeo Diagnostics (no logcat)">
<Button
mode="contained"
onPress={showBgGeoDiagnostics}
loading={bgGeoDiagStatus === "loading"}
disabled={bgGeoDiagStatus === "loading"}
style={{ marginBottom: 8 }}
>
Show BGGeo diagnostics
</Button>
{bgGeoDiagResult ? (
<Text selectable variant="bodySmall">
{bgGeoDiagResult}
</Text>
) : null}
</Section>
<Section title="Logging Controls">
<Text variant="bodyLarge" style={styles.sectionLabel}>
Log Level